TenantAtlas/tests/Feature/Guards/TenantOwnedQueryGuardTest.php
ahmido 1f3619bd16 feat: tenant-owned query canon and wrong-tenant guards (#180)
## Summary
- introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources
- harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access
- add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics

## Included
- Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/`
- shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver`
- shared Filament concern: `InteractsWithTenantOwnedRecords`
- resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups
- additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails

## Validation
- `vendor/bin/sail artisan test --compact` passed
- full suite result: `2733 passed, 8 skipped`
- formatting applied with `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliant via existing Filament v5 stack
- provider registration remains in `bootstrap/providers.php`
- globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled
- destructive actions continue to use confirmation and policy authorization
- no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #180
2026-03-18 08:33:13 +00:00

109 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
function tenantOwnedFamilySource(string $className): string
{
$reflection = new ReflectionClass($className);
$fileName = $reflection->getFileName();
expect($fileName)->toBeString();
$contents = file_get_contents((string) $fileName);
expect($contents)->not->toBeFalse();
return (string) $contents;
}
it('keeps the first-slice tenant-owned family inventory aligned to tenant-owned tables', function (): void {
$allowedSearchPostures = ['scoped', 'disabled', 'not_applicable'];
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
expect(TenantOwnedTables::contains($family['table']))
->toBeTrue("{$familyName} must point at a known tenant-owned table.");
expect(TenantOwnedTables::firstSlice())
->toContain($family['table']);
expect($allowedSearchPostures)
->toContain($family['search_posture']);
expect(class_exists($family['model']))->toBeTrue();
expect(class_exists($family['resource']))->toBeTrue();
}
});
it('keeps first-slice family names unique and explicit', function (): void {
$names = TenantOwnedModelFamilies::names();
expect($names)->not->toBeEmpty();
expect(array_unique($names))->toBe($names);
});
it('keeps the first-slice family registry exhaustive for declared first-slice tenant-owned tables', function (): void {
$familyTables = array_map(
static fn (array $family): string => $family['table'],
TenantOwnedModelFamilies::firstSlice(),
);
expect($familyTables)->toEqualCanonicalizing(TenantOwnedTables::firstSlice());
});
it('keeps the residual tenant-owned rollout inventory aligned to non-first-slice tenant-owned tables', function (): void {
$residualTables = array_map(
static fn (array $entry): string => $entry['table'],
TenantOwnedModelFamilies::residualRolloutInventory(),
);
expect($residualTables)->toEqualCanonicalizing(TenantOwnedTables::residual());
foreach (TenantOwnedModelFamilies::residualRolloutInventory() as $familyName => $entry) {
expect(trim($familyName))->not->toBe('');
expect(trim($entry['likely_surface']))->not->toBe('');
expect(trim($entry['why_not_in_first_slice']))->not->toBe('');
}
});
it('requires first-slice resources to use the shared tenant-owned query and record resolver entry points', function (): void {
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
$resourceClass = $family['resource'];
$traits = class_uses_recursive($resourceClass);
$source = tenantOwnedFamilySource($resourceClass);
expect(in_array(InteractsWithTenantOwnedRecords::class, $traits, true))
->toBeTrue("{$familyName} must use the shared tenant-owned resource trait.");
expect(preg_match('/public\s+static\s+function\s+getEloquentQuery\s*\(/', $source) === 1)
->toBeTrue("{$familyName} must expose an explicit scoped query entry point.");
expect(preg_match('/static::(?:getTenantOwnedEloquentQuery|scopeTenantOwnedQuery)\s*\(/', $source) === 1)
->toBeTrue("{$familyName} must derive records from the canonical tenant-owned query helper.");
expect(preg_match('/public\s+static\s+function\s+resolveScopedRecordOrFail\s*\(/', $source) === 1)
->toBeTrue("{$familyName} must expose an explicit scoped-record resolver.");
expect(str_contains($source, 'resolveTenantOwnedRecordOrFail'))
->toBeTrue("{$familyName} must resolve detail records through the shared tenant-owned resolver.");
}
});
it('documents explicit scope exceptions with non-empty reasons', function (): void {
$exceptions = TenantOwnedModelFamilies::explicitScopeExceptions();
$exceptionMetadata = TenantOwnedModelFamilies::scopeExceptions();
expect($exceptions)->not->toBeEmpty();
expect(array_keys($exceptionMetadata))->toEqual(array_keys($exceptions));
foreach ($exceptions as $surfaceName => $reason) {
expect($surfaceName)->not->toBe('');
expect(trim($reason))->not->toBe('');
expect($exceptionMetadata[$surfaceName]['exception_kind'])->not->toBe('');
expect($exceptionMetadata[$surfaceName]['still_required_checks'])->not->toBeEmpty();
}
});