TenantAtlas/apps/platform/tests/Unit/ResolutionGuidance/Spec354AcceptedRiskResolutionAdapterTest.php
ahmido a9c54205bf feat: finding exceptions accepted risk resolution guidance v1 (spec 354) (#425)
Implemented the accepted risk resolution guidance, including the AcceptedRiskResolutionAdapter, guidance cards, and updated related Filament views. Added unit, feature, and browser tests.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #425
2026-06-05 02:20:46 +00:00

267 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Support\ResolutionGuidance\Adapters\AcceptedRiskResolutionAdapter;
use App\Support\ResolutionGuidance\ResolutionAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function spec354AdapterFixture(
array $findingAttributes = [],
array $exceptionAttributes = [],
?string $decisionType = FindingExceptionDecision::TYPE_APPROVED,
array $decisionMetadata = [],
): array {
[$owner, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
], $findingAttributes));
$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' => (int) $owner->getKey(),
'owner_user_id' => (int) $owner->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Temporary compensating control is still in place.',
'approval_reason' => 'Accepted until scheduled remediation is complete.',
'requested_at' => now()->subDays(10),
'approved_at' => now()->subDays(9),
'effective_from' => now()->subDays(9),
'review_due_at' => now()->addDays(14),
'expires_at' => now()->addDays(30),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
if ($decisionType !== null) {
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $approver->getKey(),
'decision_type' => $decisionType,
'reason' => 'Spec354 adapter test decision.',
'metadata' => $decisionMetadata,
'decided_at' => now()->subDays(9),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
}
return [$tenant, $finding->fresh(), $exception->fresh(['finding', 'tenant', 'owner', 'currentDecision', 'evidenceReferences'])];
}
it('classifies ready and expiring accepted-risk states', function (): void {
[, , $ready] = spec354AdapterFixture();
[, , $expiring] = spec354AdapterFixture(
exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($ready)['key'])->toBe('accepted_risk.ready')
->and($adapter->forQueue($ready)['title'])->toBe(__('localization.accepted_risk_guidance.title_ready'))
->and($adapter->forQueue($expiring)['key'])->toBe('accepted_risk.expiring')
->and($adapter->forQueue($expiring)['title'])->toBe(__('localization.accepted_risk_guidance.title_expiring'));
});
it('classifies expired accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'review_due_at' => now()->subDays(3),
'expires_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_APPROVED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_expired'));
});
it('classifies revoked accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_REVOKED,
'current_validity_state' => FindingException::VALIDITY_REVOKED,
'revoked_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_REVOKED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_revoked'));
});
it('classifies rejected accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'rejected_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_REJECTED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_rejected'));
});
it('distinguishes pending initial review from pending renewal', function (): void {
[, , $pendingInitial] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'approved_by_user_id' => null,
'approved_at' => null,
'effective_from' => null,
'approval_reason' => null,
],
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
);
[, , $pendingRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($pendingInitial)['title'])->toBe(__('localization.accepted_risk_guidance.title_pending'))
->and($adapter->forQueue($pendingInitial)['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_pending'))
->and($adapter->forQueue($pendingRenewal)['title'])->toBe(__('localization.accepted_risk_guidance.title_pending_renewal'))
->and($adapter->forQueue($pendingRenewal)['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_pending_renewal'));
});
it('keeps lapsed carried-over governance dominant over pending renewal guidance', function (): void {
[, , $expiredRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
decisionMetadata: [
'previous_review_due_at' => now()->subDays(2)->toIso8601String(),
'previous_expires_at' => now()->subDay()->toIso8601String(),
],
);
[, , $expiringRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
decisionMetadata: [
'previous_review_due_at' => now()->addDay()->toIso8601String(),
'previous_expires_at' => now()->addDays(2)->toIso8601String(),
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($expiredRenewal)['key'])->toBe('accepted_risk.expired')
->and($adapter->forQueue($expiringRenewal)['key'])->toBe('accepted_risk.expiring');
});
it('classifies missing support, fresh decision required, and incomplete governance states', function (): void {
[, , $missingSupport] = spec354AdapterFixture(
exceptionAttributes: [
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'status' => FindingException::STATUS_ACTIVE,
],
);
[, , $freshDecision] = spec354AdapterFixture(
findingAttributes: [
'status' => Finding::STATUS_NEW,
],
);
[, , $incompleteGovernance] = spec354AdapterFixture(
exceptionAttributes: [
'owner_user_id' => null,
'request_reason' => '',
'review_due_at' => null,
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($missingSupport)['key'])->toBe('accepted_risk.missing_support')
->and($adapter->forQueue($freshDecision)['key'])->toBe('accepted_risk.fresh_decision_required')
->and($adapter->forQueue($freshDecision)['reason'])->toContain('fresh decision')
->and($adapter->forDetail($incompleteGovernance)['key'])->toBe('accepted_risk.incomplete_governance')
->and($adapter->forDetail($incompleteGovernance)['technical_details'])->toHaveKey(__('localization.accepted_risk_guidance.detail_missing_fields_label'));
});
it('keeps missing support as review focus instead of a fake primary action', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'status' => FindingException::STATUS_ACTIVE,
],
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forDetail($exception);
expect($guidance['primary_action']['type'])->toBe(ResolutionAction::TYPE_NONE)
->and($guidance['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_missing_support'))
->and($guidance['primary_action']['label'])->toContain('Review whether');
});
it('localizes dominant accepted-risk guidance copy for german locale', function (): void {
$originalLocale = app()->getLocale();
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
],
);
app()->setLocale('de');
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['reason'])->toBe(__('localization.accepted_risk_guidance.reason_expiring'))
->and($guidance['impact'])->toBe(__('localization.accepted_risk_guidance.impact_expiring'))
->and($guidance['reason'])->not->toContain('The current accepted-risk governance window');
app()->setLocale($originalLocale);
});
it('stays database-local and preserves conservative wording without mutating downstream review output state', function (): void {
bindFailHardGraphClient();
[, , $exception] = spec354AdapterFixture();
assertNoOutboundHttp(function () use ($exception): void {
$guidance = app(AcceptedRiskResolutionAdapter::class)->forDetail($exception);
expect($guidance['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_ready'));
});
});