- Add LEAN-001 to constitution after BIAS-001: forbids legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase - Add compatibility posture default block to spec template - Add pre-production compatibility check to agent instructions - Unify backup_set operation type to canonical backup_set.update - Remove all legacy backup_set.add_policies/remove_policies references - Add finding ownership semantics (responsibility/accountability labels) - Clean up roadmap.md and spec-candidates.md
243 lines
9.1 KiB
PHP
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;
|
|
}
|