TenantAtlas/apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
ahmido 1c291fb9fe feat: close spec 195 action surface residuals (#230)
## 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
2026-04-13 07:47:58 +00:00

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');
});