create(); $owner = User::factory()->create(['name' => 'Decision Owner']); $approver = User::factory()->create(['name' => 'Decision Approver']); $visibleTenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'name' => 'Visible ManagedEnvironment', 'external_id' => 'visible-tenant', ]); $hiddenTenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'name' => 'Hidden ManagedEnvironment', 'external_id' => 'hidden-tenant', ]); $pendingApproval = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $owner, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Pending workspace approval', exceptionAttributes: [ 'requested_at' => now()->subDays(2), 'review_due_at' => now()->addDay(), ], decisionAttributes: [ 'decided_at' => now()->subDays(2), ], ); $followUpNeeded = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_EXPIRING, validityState: FindingException::VALIDITY_EXPIRING, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Approved until remediation completes', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'approved_at' => now()->subDays(5), 'effective_from' => now()->subDays(5), 'expires_at' => now()->addDays(2), 'review_due_at' => now()->addDay(), ], decisionAttributes: [ 'decided_at' => now()->subDays(5), ], ); $recentlyRejected = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_REJECTED, validityState: FindingException::VALIDITY_REJECTED, decisionType: FindingExceptionDecision::TYPE_REJECTED, decisionReason: 'Evidence bundle was incomplete', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'rejected_at' => now()->subDays(3), 'review_due_at' => now()->subDays(4), ], decisionAttributes: [ 'decided_at' => now()->subDays(3), ], ); makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_REVOKED, validityState: FindingException::VALIDITY_REVOKED, decisionType: FindingExceptionDecision::TYPE_REVOKED, decisionReason: 'Closed long ago', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'revoked_at' => now()->subDays(45), 'review_due_at' => now()->subDays(46), ], decisionAttributes: [ 'decided_at' => now()->subDays(45), ], ); makeFindingExceptionWithCurrentDecision( tenant: $hiddenTenant, owner: $owner, actor: $owner, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Hidden tenant request', exceptionAttributes: [ 'requested_at' => now()->subDay(), 'review_due_at' => now()->addDays(2), ], decisionAttributes: [ 'decided_at' => now()->subDay(), ], ); $builder = app(GovernanceDecisionRegisterBuilder::class); $openPayload = $builder->build( workspace: $workspace, visibleTenants: [$visibleTenant], registerState: 'open', ); $openRows = collect($openPayload['rows'])->keyBy('exception_id'); expect($openPayload['counts'])->toMatchArray([ 'open' => 2, 'recently_closed' => 1, ]) ->and($openRows->keys()->all())->toBe([ (int) $pendingApproval->getKey(), (int) $followUpNeeded->getKey(), ]) ->and($openRows[(int) $pendingApproval->getKey()]['tenant_name'])->toBe('Visible ManagedEnvironment') ->and($openRows[(int) $pendingApproval->getKey()]['owner_name'])->toBe('Decision Owner') ->and($openRows[(int) $pendingApproval->getKey()]['next_action_label'])->toBe('Review approval') ->and($openRows[(int) $followUpNeeded->getKey()]['next_action_label'])->toBe('Review follow-up'); $recentlyClosedPayload = $builder->build( workspace: $workspace, visibleTenants: [$visibleTenant], registerState: 'recently_closed', ); expect($recentlyClosedPayload['counts'])->toMatchArray([ 'open' => 2, 'recently_closed' => 1, ]) ->and(collect($recentlyClosedPayload['rows'])->pluck('exception_id')->all())->toBe([ (int) $recentlyRejected->getKey(), ]) ->and($recentlyClosedPayload['rows'][0]['closure_reason'])->toBe('Evidence bundle was incomplete') ->and($recentlyClosedPayload['rows'][0]['status'])->toBe(FindingException::STATUS_REJECTED); }); it('keeps missing owner visible instead of omitting follow-up-needed rows', function (): void { $workspace = Workspace::factory()->create(); $requester = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); $unownedException = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: null, actor: $requester, status: FindingException::STATUS_EXPIRED, validityState: FindingException::VALIDITY_EXPIRED, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Expired and needs a fresh decision', exceptionAttributes: [ 'requested_by_user_id' => (int) $requester->getKey(), 'owner_user_id' => null, 'approved_by_user_id' => (int) $requester->getKey(), 'approved_at' => now()->subDays(20), 'effective_from' => now()->subDays(20), 'expires_at' => now()->subDay(), 'review_due_at' => now()->subDays(2), ], decisionAttributes: [ 'decided_at' => now()->subDays(20), ], ); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); expect($payload['rows'])->toHaveCount(1) ->and($payload['rows'][0]['exception_id'])->toBe((int) $unownedException->getKey()) ->and($payload['rows'][0]['owner_name'])->toBeNull() ->and($payload['rows'][0]['next_action_label'])->toBe('Review follow-up'); }); it('exposes missing proof and aggregate detail proof states truthfully', function (): void { $workspace = Workspace::factory()->create(); $actor = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); $withoutProof = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'No proof linked', ); $withMultipleProof = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_ACTIVE, validityState: FindingException::VALIDITY_VALID, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Multiple proof linked', exceptionAttributes: [ 'approved_by_user_id' => (int) $actor->getKey(), 'approved_at' => now()->subDays(3), 'effective_from' => now()->subDays(3), 'evidence_summary' => ['reference_count' => 2], 'review_due_at' => now()->addDays(2), ], ); $withMultipleProof->evidenceReferences()->createMany([ [ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'evidence_snapshot', 'source_id' => 'snapshot-001', 'label' => 'Snapshot summary', 'summary_payload' => [], ], [ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'review_pack', 'source_id' => 'review-pack-001', 'label' => 'Review pack summary', 'summary_payload' => [], ], ]); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); $rows = collect($payload['rows'])->keyBy('exception_id'); expect($rows[(int) $withoutProof->getKey()]) ->toMatchArray([ 'proof_count' => 0, 'proof_state' => 'not_linked', 'proof_label' => 'No linked proof', 'proof_url' => null, 'proof_url_label' => null, 'operation_run_state' => 'not_linked', 'operation_run_url' => null, 'operation_run_label' => 'No operation linked', ]) ->and($rows[(int) $withMultipleProof->getKey()]['proof_count'])->toBe(2) ->and($rows[(int) $withMultipleProof->getKey()]['proof_state'])->toBe('linked_detail_section') ->and($rows[(int) $withMultipleProof->getKey()]['proof_label'])->toBe('2 proof items') ->and($rows[(int) $withMultipleProof->getKey()]['proof_url'])->toContain('/admin/workspaces/') ->and($rows[(int) $withMultipleProof->getKey()]['proof_url'])->not->toContain('/admin/t') ->and($rows[(int) $withMultipleProof->getKey()]['proof_url_label'])->toBe('View proof'); }); it('links a single same-scope evidence snapshot and its operation when authorized', function (): void { $workspace = Workspace::factory()->create(); $actor = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); createUserWithTenant(tenant: $tenant, user: $actor, role: 'owner', workspaceRole: 'owner'); $this->actingAs($actor); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'tenant.evidence.snapshot.generate', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, 'completed_at' => now(), ]); $snapshot = EvidenceSnapshot::query()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'operation_run_id' => (int) $run->getKey(), 'status' => EvidenceSnapshotStatus::Active->value, 'completeness_state' => EvidenceCompletenessState::Complete->value, 'summary' => ['finding_count' => 1], 'generated_at' => now(), ]); $exception = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_ACTIVE, validityState: FindingException::VALIDITY_VALID, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Evidence snapshot proof', exceptionAttributes: [ 'approved_by_user_id' => (int) $actor->getKey(), 'approved_at' => now()->subDay(), 'effective_from' => now()->subDay(), 'evidence_summary' => ['reference_count' => 1], ], ); $exception->evidenceReferences()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'evidence_snapshot', 'source_id' => (string) $snapshot->getKey(), 'label' => 'Evidence snapshot', 'summary_payload' => [], ]); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); $row = collect($payload['rows'])->firstWhere('exception_id', (int) $exception->getKey()); expect($row)->toMatchArray([ 'proof_count' => 1, 'proof_state' => 'linked_evidence', 'proof_label' => '1 proof item', 'proof_url_label' => 'View evidence', 'operation_run_state' => 'linked_run', 'operation_run_label' => 'View operation', ]) ->and($row['proof_url'])->toContain('/admin/workspaces/') ->and($row['proof_url'])->toContain('/evidence/') ->and($row['proof_url'])->not->toContain('/admin/t') ->and($row['operation_run_url'])->toBe(OperationRunLinks::tenantlessView($run)) ->and($row['operation_run_url'])->not->toContain('/admin/t'); }); it('links a same-scope source finding operation when no evidence operation exists', function (): void { $workspace = Workspace::factory()->create(); $actor = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); createUserWithTenant(tenant: $tenant, user: $actor, role: 'owner', workspaceRole: 'owner'); $this->actingAs($actor); $run = OperationRun::factory()->forTenant($tenant)->create([ 'type' => 'tenant.finding.generate', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, 'completed_at' => now(), ]); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $workspace->getKey(), 'current_operation_run_id' => (int) $run->getKey(), ]); $exception = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Finding source operation', exceptionAttributes: [ 'finding_id' => (int) $finding->getKey(), ], ); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); $row = collect($payload['rows'])->firstWhere('exception_id', (int) $exception->getKey()); expect($row)->toMatchArray([ 'proof_count' => 0, 'proof_state' => 'not_linked', 'proof_label' => 'No linked proof', 'operation_run_state' => 'linked_run', 'operation_run_label' => 'View operation', 'operation_run_url' => OperationRunLinks::tenantlessView($run), ]) ->and($row['operation_run_url'])->not->toContain('/admin/t'); }); it('links a single same-scope stored report when authorized', function (): void { $workspace = Workspace::factory()->create(); $actor = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); createUserWithTenant(tenant: $tenant, user: $actor, role: 'owner', workspaceRole: 'owner'); $this->actingAs($actor); $report = StoredReport::factory()->permissionPosture()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'fingerprint' => hash('sha256', 'decision-register-report'), ]); $exception = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Stored report proof', exceptionAttributes: [ 'evidence_summary' => ['reference_count' => 1], ], ); $exception->evidenceReferences()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'stored_report', 'source_id' => (string) $report->getKey(), 'label' => 'Permission posture report', 'summary_payload' => [], ]); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); $row = collect($payload['rows'])->firstWhere('exception_id', (int) $exception->getKey()); expect($row)->toMatchArray([ 'proof_count' => 1, 'proof_state' => 'linked_report', 'proof_label' => '1 proof item', 'proof_url_label' => 'View report', 'operation_run_state' => 'not_linked', 'operation_run_url' => null, ]) ->and($row['proof_url'])->toContain('/stored-reports/') ->and($row['proof_url'])->not->toContain('/admin/t'); }); it('does not invent direct artifact or operation links from loose identifiers or cross-scope runs', function (): void { $workspace = Workspace::factory()->create(); $actor = User::factory()->create(); $tenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); createUserWithTenant(tenant: $tenant, user: $actor, role: 'owner', workspaceRole: 'owner'); $otherTenant = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); createUserWithTenant(tenant: $otherTenant, user: $actor, role: 'owner', workspaceRole: 'owner'); $this->actingAs($actor); $otherRun = OperationRun::factory()->forTenant($otherTenant)->create([ 'type' => 'tenant.evidence.snapshot.generate', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, ]); $sameScopeSnapshotWithOtherRun = EvidenceSnapshot::query()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'operation_run_id' => (int) $otherRun->getKey(), 'status' => EvidenceSnapshotStatus::Active->value, 'completeness_state' => EvidenceCompletenessState::Complete->value, 'summary' => ['finding_count' => 1], 'generated_at' => now(), ]); $looseIdentifier = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Loose proof identifier', exceptionAttributes: [ 'evidence_summary' => ['reference_count' => 1], ], ); $looseIdentifier->evidenceReferences()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'evidence_snapshot', 'source_id' => 'snapshot-001', 'label' => 'Loose evidence snapshot', 'summary_payload' => [], ]); $crossScopeRun = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: $actor, actor: $actor, status: FindingException::STATUS_ACTIVE, validityState: FindingException::VALIDITY_VALID, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Cross-scope operation should not link', exceptionAttributes: [ 'approved_by_user_id' => (int) $actor->getKey(), 'approved_at' => now()->subDay(), 'effective_from' => now()->subDay(), 'evidence_summary' => ['reference_count' => 1], ], ); $crossScopeRun->evidenceReferences()->create([ 'workspace_id' => (int) $workspace->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'source_type' => 'evidence_snapshot', 'source_id' => (string) $sameScopeSnapshotWithOtherRun->getKey(), 'label' => 'Snapshot with cross-scope run', 'summary_payload' => [], ]); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); $rows = collect($payload['rows'])->keyBy('exception_id'); expect($rows[(int) $looseIdentifier->getKey()]) ->toMatchArray([ 'proof_count' => 1, 'proof_state' => 'linked_detail_section', 'proof_url_label' => 'View proof', 'operation_run_state' => 'not_linked', 'operation_run_url' => null, ]) ->and($rows[(int) $crossScopeRun->getKey()]) ->toMatchArray([ 'proof_state' => 'linked_evidence', 'proof_url_label' => 'View evidence', 'operation_run_state' => 'run_not_available', 'operation_run_url' => null, 'operation_run_label' => 'No operation linked', ]); }); /** * @param array $exceptionAttributes * @param array $decisionAttributes */ function makeFindingExceptionWithCurrentDecision( ManagedEnvironment $tenant, ?User $owner, User $actor, string $status, string $validityState, string $decisionType, string $decisionReason, array $exceptionAttributes = [], array $decisionAttributes = [], ): FindingException { $requesterId = $exceptionAttributes['requested_by_user_id'] ?? (int) $actor->getKey(); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $exception = FindingException::query()->create(array_merge([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'finding_id' => (int) $finding->getKey(), 'requested_by_user_id' => $requesterId, 'owner_user_id' => $owner?->getKey(), 'status' => $status, 'current_validity_state' => $validityState, 'request_reason' => 'Decision register test setup', 'requested_at' => now()->subDays(7), 'review_due_at' => now()->addDays(7), 'evidence_summary' => ['reference_count' => 0], ], $exceptionAttributes)); $decision = $exception->decisions()->create(array_merge([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => $decisionType, 'reason' => $decisionReason, 'metadata' => [], 'decided_at' => now()->subDays(7), ], $decisionAttributes)); $exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save(); return $exception->fresh(['tenant', 'owner', 'currentDecision']); }