## Summary
- add the full Spec 195 residual action-surface design package under `specs/195-action-surface-closure`
- implement residual surface inventory and validator enforcement for uncatalogued system and special Filament pages
- add focused regression coverage for residual guards, system directory pages, managed-tenants landing, and readonly register-tenant / tenant-dashboard access
- fix the system workspace detail surface by loading tenant route keys and disabling lazy system database notifications to avoid the Livewire 404 on `/system/directory/workspaces/{workspace}`
## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Notes
- branch: `195-action-surface-closure`
- target: `dev`
- no new assets, migrations, or provider-registration changes
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #230
340 lines
15 KiB
PHP
340 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
|
|
final class ActionSurfaceValidatorCompleteStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorMissingSlotStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorRunLogNoExportStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
|
|
moreGroupLabel: 'More',
|
|
exportIsDefaultBulkActionForReadOnly: false,
|
|
))
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
|
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorMissingSurfaceTypeStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, version: 2)
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorIncompatibleInspectAffordanceStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub
|
|
{
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState);
|
|
}
|
|
}
|
|
|
|
final class ActionSurfaceValidatorNoDeclarationStub {}
|
|
|
|
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
|
|
{
|
|
return new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Resource,
|
|
panelScopes: [ActionSurfacePanelScope::Tenant],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
function repositoryDiscoveredActionSurfaceClasses(): array
|
|
{
|
|
return array_map(
|
|
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
|
|
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, \App\Support\Ui\ActionSurface\ActionSurfaceValidationIssue> $issues
|
|
*/
|
|
function formatActionSurfaceIssues(array $issues): string
|
|
{
|
|
return implode("\n", array_map(
|
|
static fn ($issue): string => $issue->format(),
|
|
$issues,
|
|
));
|
|
}
|
|
|
|
it('passes when all required slots are declared', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('fails when a required slot is missing', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Required slot is not declared');
|
|
});
|
|
|
|
it('fails missing declarations when no baseline exemption exists', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
|
|
});
|
|
|
|
it('fails behavior-aware declarations when surface type is missing', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSurfaceTypeStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Behavior-aware declarations must define a surface type');
|
|
});
|
|
|
|
it('fails behavior-aware declarations when surface type and inspect affordance are incompatible', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorIncompatibleInspectAffordanceStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Inspect affordance "view_action" is incompatible with surface type "crud_list_first_resource"');
|
|
});
|
|
|
|
it('fails primary-link-column inspect affordances without an explicit reason', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Primary link column inspect affordance requires a non-empty reason');
|
|
});
|
|
|
|
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([
|
|
ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.',
|
|
]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('requires a bulk exemption reason when run-log export default is disabled', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default');
|
|
});
|
|
|
|
it('fails when slot exemption reason is missing', function (): void {
|
|
$validator = new ActionSurfaceValidator(
|
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
|
exemptions: new ActionSurfaceExemptions([]),
|
|
);
|
|
|
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]);
|
|
|
|
expect($result->hasIssues())->toBeTrue();
|
|
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
|
});
|
|
|
|
it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void {
|
|
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
|
|
|
$result = $validator->validateComponents([]);
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('accepts the repository spec 193 monitoring inventory even when only inventory validation runs', function (): void {
|
|
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
|
|
|
$result = $validator->validateComponents([]);
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('accepts the repository spec 195 residual inventory even when only inventory validation runs', function (): void {
|
|
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
|
|
|
$result = $validator->validateComponents([]);
|
|
|
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
});
|
|
|
|
it('fails when a residual system candidate is missing a spec 195 closure entry', function (): void {
|
|
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
|
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
|
|
|
|
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
|
inventory: $inventory,
|
|
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
|
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
|
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
|
|
);
|
|
|
|
expect(formatActionSurfaceIssues($issues))
|
|
->toContain(\App\Filament\System\Pages\Dashboard::class)
|
|
->toContain('Residual action surface is missing a Spec 195 closure entry');
|
|
});
|
|
|
|
it('fails when a non-enrolled spec 195 residual surface has no reason category', function (): void {
|
|
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
|
$inventory[\App\Filament\Pages\ChooseWorkspace::class]['reasonCategory'] = null;
|
|
|
|
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
|
inventory: $inventory,
|
|
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
|
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
|
);
|
|
|
|
expect(formatActionSurfaceIssues($issues))
|
|
->toContain(\App\Filament\Pages\ChooseWorkspace::class)
|
|
->toContain('reason category is invalid or missing');
|
|
});
|
|
|
|
it('fails when a spec 195 residual surface is missing structured evidence', function (): void {
|
|
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
|
$inventory[\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class]['evidence'] = [];
|
|
|
|
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
|
inventory: $inventory,
|
|
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
|
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
|
);
|
|
|
|
expect(formatActionSurfaceIssues($issues))
|
|
->toContain(\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
|
->toContain('require at least one structured evidence descriptor');
|
|
});
|
|
|
|
it('fails when a retired spec 195 residual surface still remains baseline-exempt', function (): void {
|
|
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
|
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
|
|
$baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired exemption.';
|
|
|
|
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
|
inventory: $inventory,
|
|
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
|
baselineExemptions: $baselineExemptions,
|
|
);
|
|
|
|
expect(formatActionSurfaceIssues($issues))
|
|
->toContain(\App\Filament\Pages\BreakGlassRecovery::class)
|
|
->toContain('must not remain baseline-exempt');
|
|
});
|