Some checks failed
Main Confidence / confidence (push) Failing after 53s
## 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
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;
|
|
}
|