TenantAtlas/apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php
ahmido c86b399b43
Some checks failed
Main Confidence / confidence (push) Failing after 53s
feat(219): Finding ownership semantics + LEAN-001 constitution + backup_set unification (#256)
## Summary

This PR delivers three related improvements:

### 1. Finding Ownership Semantics (Spec 219)
- Add responsibility/accountability labels to findings and finding exceptions
- `owner_user_id` = accountable party (governance owner)
- `assignee_user_id` = responsible party (technical implementer)
- Expose Assign/Reassign actions in FindingResource with audit logging
- Add ownership columns and filters to finding list
- Propagate owner from finding to exception on creation
- Tests: ownership semantics, assignment audit, workflow actions

### 2. Constitution v2.7.0 — LEAN-001 Pre-Production Lean Doctrine
- New principle forbidding legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase
- AI-agent 4-question verification gate before adding any compatibility path
- Review rule: compatibility shims without answering the gate questions = merge blocker
- Exit condition: LEAN-001 expires at first production deployment
- Spec template: added default "Compatibility posture" block
- Agent instructions: added "Pre-production compatibility check" section

### 3. Backup Set Operation Type Unification
- Unified `backup_set.add_policies` and `backup_set.remove_policies` into single canonical `backup_set.update`
- Removed all legacy aliases, constants, and test fixtures
- Added lifecycle coverage for `backup_set.update` in config
- Updated all 14+ test files referencing legacy types

### Spec Artifacts
- `specs/219-finding-ownership-semantics/` — full spec, plan, tasks, research, data model, contracts, checklist

### Tests
- All affected tests pass (OperationCatalog, backup set, finding workflow, ownership semantics)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #256
2026-04-20 17:54:33 +00:00

243 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Schemas\Components\Text;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders explicit responsibility states on findings list and detail surfaces', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
$ownerOnly = tenantFindingUser($tenant, 'Owner Only');
$listOwner = tenantFindingUser($tenant, 'List Owner');
$listAssignee = tenantFindingUser($tenant, 'List Assignee');
$assigneeOnly = tenantFindingUser($tenant, 'Assignee Only');
$samePerson = tenantFindingUser($tenant, 'Same Person');
$ownerOnlyFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $ownerOnly->getKey(),
'assignee_user_id' => null,
]);
$assignedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $listOwner->getKey(),
'assignee_user_id' => (int) $listAssignee->getKey(),
]);
$assigneeOnlyFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => null,
'assignee_user_id' => (int) $assigneeOnly->getKey(),
]);
$bothNullFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => null,
'assignee_user_id' => null,
]);
$sameUserFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $samePerson->getKey(),
'assignee_user_id' => (int) $samePerson->getKey(),
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertCanSeeTableRecords([
$ownerOnlyFinding,
$assignedFinding,
$assigneeOnlyFinding,
$bothNullFinding,
$sameUserFinding,
])
->assertSee('Responsibility')
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('owned but unassigned')
->assertSee('assigned')
->assertSee('orphaned accountability')
->assertSee('Owner Only')
->assertSee('List Owner')
->assertSee('List Assignee')
->assertSee('Assignee Only')
->assertSee('Same Person');
Livewire::test(ViewFinding::class, ['record' => $assigneeOnlyFinding->getKey()])
->assertSee('Responsibility state')
->assertSee('orphaned accountability')
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('Assignee Only');
Livewire::test(ViewFinding::class, ['record' => $sameUserFinding->getKey()])
->assertSee('assigned')
->assertSee('Same Person');
});
it('isolates owner accountability and assigned work with separate personal filters', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'manager');
$otherOwner = tenantFindingUser($tenant, 'Other Owner');
$otherAssignee = tenantFindingUser($tenant, 'Other Assignee');
$assignedToMe = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $otherOwner->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
]);
$ownedByMe = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$bothMine = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
]);
$neitherMine = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $otherOwner->getKey(),
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->filterTable('my_assigned', true)
->assertCanSeeTableRecords([$assignedToMe, $bothMine])
->assertCanNotSeeTableRecords([$ownedByMe, $neitherMine])
->removeTableFilter('my_assigned')
->filterTable('my_accountability', true)
->assertCanSeeTableRecords([$ownedByMe, $bothMine])
->assertCanNotSeeTableRecords([$assignedToMe, $neitherMine]);
});
it('keeps exception ownership visibly separate from finding ownership', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$findingOwner = tenantFindingUser($tenant, 'Finding Owner');
$findingAssignee = tenantFindingUser($tenant, 'Finding Assignee');
$exceptionOwner = tenantFindingUser($tenant, 'Exception Owner');
$findingWithException = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $findingOwner->getKey(),
'assignee_user_id' => (int) $findingAssignee->getKey(),
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $findingWithException->getKey(),
'requested_by_user_id' => (int) $owner->getKey(),
'owner_user_id' => (int) $exceptionOwner->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Needs temporary governance coverage.',
'requested_at' => now(),
'review_due_at' => now()->addDays(7),
'evidence_summary' => ['reference_count' => 0],
]);
$requestFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $findingOwner->getKey(),
]);
$this->actingAs($owner);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$this->get(FindingResource::getUrl('view', ['record' => $findingWithException], panel: 'tenant', tenant: $tenant))
->assertSuccessful()
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('Exception owner')
->assertSee('Finding Owner')
->assertSee('Finding Assignee')
->assertSee('Exception Owner');
$component = Livewire::test(ViewFinding::class, ['record' => $requestFinding->getKey()])
->mountAction('request_exception');
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
$method->setAccessible(true);
$form = $method->invoke($component->instance());
$field = collect($form?->getFlatFields(withHidden: true) ?? [])
->first(fn (Field $field): bool => $field->getName() === 'owner_user_id');
$helperText = collect($field?->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
->filter(fn (mixed $schemaComponent): bool => $schemaComponent instanceof Text)
->map(fn (Text $schemaComponent): string => (string) $schemaComponent->getContent())
->implode(' ');
expect($field?->getLabel())->toBe('Exception owner')
->and($helperText)->toContain('Owns the exception record')
->and($helperText)->toContain('not the finding outcome');
});
it('allows in-scope members and returns 404 for non-members on tenant findings routes', function (): void {
$tenant = Tenant::factory()->create();
[$member, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$this->actingAs($member)
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->assertSuccessful();
$this->actingAs($member)
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertSuccessful();
$tenantInSameWorkspace = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
[$outsider] = createUserWithTenant(tenant: $tenantInSameWorkspace, role: 'owner');
$this->actingAs($outsider)
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->assertNotFound();
$this->actingAs($outsider)
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
function tenantFindingUser(Tenant $tenant, string $name): User
{
$user = User::factory()->create([
'name' => $name,
]);
createUserWithTenant(tenant: $tenant, user: $user, role: 'operator');
return $user;
}