create(); createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager'); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_RISK_ACCEPTED, ]); /** @var FindingExceptionService $service */ $service = app(FindingExceptionService::class); $requested = $service->request($finding, $tenant, $requester, [ 'owner_user_id' => (int) $requester->getKey(), 'request_reason' => 'Initial compensating controls approved until maintenance window', 'review_due_at' => now()->addDays(7)->toDateTimeString(), 'expires_at' => now()->addDays(14)->toDateTimeString(), 'evidence_references' => [ [ 'label' => 'Initial baseline review', 'source_type' => 'evidence_snapshot', 'source_id' => 'snapshot-001', 'source_fingerprint' => 'fp-initial-001', 'measured_at' => now()->subDay()->toDateTimeString(), 'summary_payload' => ['status' => 'pending_remediation'], ], ], ]); $service->approve($requested, $approver, [ 'effective_from' => now()->subDay()->toDateTimeString(), 'expires_at' => now()->addDays(14)->toDateTimeString(), 'approval_reason' => 'Approved until remediation can be scheduled.', ]); $this->actingAs($requester); Filament::setTenant($tenant, true); Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) ->assertActionVisible('renew_exception') ->callAction('renew_exception', data: [ 'owner_user_id' => (int) $requester->getKey(), 'request_reason' => 'Renew for the next maintenance window with updated rollback proof.', 'review_due_at' => now()->addDays(10)->toDateTimeString(), 'expires_at' => now()->addDays(45)->toDateTimeString(), 'evidence_references' => [ [ 'label' => 'Rollback drill 2026-03-18', 'source_type' => 'review_pack', 'source_id' => 'rp-2026-03-18', 'source_fingerprint' => 'fp-renew-001', 'measured_at' => now()->subHours(12)->toDateTimeString(), ], [ 'label' => 'Compensating controls review', 'source_type' => 'evidence_snapshot', 'source_id' => 'snapshot-002', 'source_fingerprint' => 'fp-renew-002', 'measured_at' => now()->subHours(6)->toDateTimeString(), ], ], ]) ->assertHasNoActionErrors(); $pendingRenewal = FindingException::query() ->with(['currentDecision', 'decisions', 'evidenceReferences']) ->where('finding_id', (int) $finding->getKey()) ->firstOrFail(); expect($pendingRenewal->status)->toBe(FindingException::STATUS_PENDING) ->and($pendingRenewal->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_RENEWAL_REQUESTED) ->and($pendingRenewal->decisions->pluck('decision_type')->all())->toBe([ FindingExceptionDecision::TYPE_REQUESTED, FindingExceptionDecision::TYPE_APPROVED, FindingExceptionDecision::TYPE_RENEWAL_REQUESTED, ]) ->and($pendingRenewal->evidenceReferences)->toHaveCount(2) ->and($pendingRenewal->evidenceReferences->pluck('label')->all())->toBe([ 'Rollback drill 2026-03-18', 'Compensating controls review', ]) ->and($pendingRenewal->evidenceReferences->first()?->summary_payload)->toBe([]); $this->actingAs($approver); Filament::setCurrentPanel('admin'); Filament::setTenant(null, true); Filament::bootCurrentPanel(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); Livewire::withQueryParams([ 'exception' => (int) $pendingRenewal->getKey(), ]) ->test(FindingExceptionsQueue::class) ->assertActionVisible('approve_selected_exception') ->callAction('approve_selected_exception', data: [ 'effective_from' => now()->addDays(14)->toDateTimeString(), 'expires_at' => now()->addDays(45)->toDateTimeString(), 'approval_reason' => 'Renewed while remediation remains scheduled and evidenced.', ]) ->assertHasNoActionErrors() ->assertNotified('Exception renewed'); $renewed = $pendingRenewal->fresh(['currentDecision', 'decisions', 'evidenceReferences']); expect($renewed?->status)->toBe(FindingException::STATUS_ACTIVE) ->and($renewed?->current_validity_state)->toBe(FindingException::VALIDITY_VALID) ->and($renewed?->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_RENEWED) ->and($renewed?->decisions->pluck('decision_type')->all())->toBe([ FindingExceptionDecision::TYPE_REQUESTED, FindingExceptionDecision::TYPE_APPROVED, FindingExceptionDecision::TYPE_RENEWAL_REQUESTED, FindingExceptionDecision::TYPE_RENEWED, ]) ->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED) ->and(AuditLog::query() ->where('action', AuditActionId::FindingExceptionRenewalRequested->value) ->where('resource_type', 'finding_exception') ->where('resource_id', (string) $pendingRenewal->getKey()) ->exists())->toBeTrue() ->and(AuditLog::query() ->where('action', AuditActionId::FindingExceptionRenewed->value) ->where('resource_type', 'finding_exception') ->where('resource_id', (string) $pendingRenewal->getKey()) ->exists())->toBeTrue(); $this->actingAs($requester); Filament::setTenant($tenant, true); Livewire::test(ViewFindingException::class, ['record' => $pendingRenewal->getKey()]) ->assertOk() ->assertSee('Rollback drill 2026-03-18') ->assertSee('Compensating controls review') ->assertSee('Renewed while remediation remains scheduled and evidenced.'); }); it('rejects a pending renewal without erasing the prior approved governance window', function (): void { [$requester, $tenant] = createUserWithTenant(role: 'owner'); $approver = User::factory()->create(); createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager'); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_RISK_ACCEPTED, ]); /** @var FindingExceptionService $service */ $service = app(FindingExceptionService::class); $requested = $service->request($finding, $tenant, $requester, [ 'owner_user_id' => (int) $requester->getKey(), 'request_reason' => 'Initial acceptance window', 'review_due_at' => now()->addDays(5)->toDateTimeString(), 'expires_at' => now()->addDays(20)->toDateTimeString(), ]); $active = $service->approve($requested, $approver, [ 'effective_from' => now()->subDay()->toDateTimeString(), 'expires_at' => now()->addDays(20)->toDateTimeString(), 'approval_reason' => 'Initial approval', ]); $service->renew($active, $requester, [ 'owner_user_id' => (int) $requester->getKey(), 'request_reason' => 'Need additional time while vendor patch is validated.', 'review_due_at' => now()->addDays(8)->toDateTimeString(), 'expires_at' => now()->addDays(40)->toDateTimeString(), 'evidence_references' => [ [ 'label' => 'Patch validation evidence', 'source_type' => 'review_pack', ], ], ]); $rejected = $service->reject($active->fresh(['currentDecision']), $approver, [ 'rejection_reason' => 'Renewal denied until stronger mitigation evidence is attached.', ]); expect($rejected->status)->toBe(FindingException::STATUS_ACTIVE) ->and($rejected->current_validity_state)->toBe(FindingException::VALIDITY_VALID) ->and($rejected->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_REJECTED) ->and($rejected->decisions->pluck('decision_type')->all())->toBe([ FindingExceptionDecision::TYPE_REQUESTED, FindingExceptionDecision::TYPE_APPROVED, FindingExceptionDecision::TYPE_RENEWAL_REQUESTED, FindingExceptionDecision::TYPE_REJECTED, ]) ->and($rejected->rejection_reason)->toBe('Renewal denied until stronger mitigation evidence is attached.') ->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED); });