TenantAtlas/apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php
ahmido 421261a517
Some checks failed
Main Confidence / confidence (push) Failing after 48s
feat: implement finding outcome taxonomy (#267)
## Summary
- implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics
- align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics
- add focused Pest coverage and complete the spec artifacts for feature 231

## Details
- manual resolve is limited to the canonical `remediated` outcome
- close and reopen flows now use bounded canonical reasons
- trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths
- duplicate lifecycle backfill now closes findings canonically as `duplicate`
- accepted-risk recording now uses the canonical `accepted_risk` reason
- finding detail and list surfaces now expose terminal outcome and verification summaries
- review, snapshot, and review-pack consumers now propagate the same outcome buckets

## Filament / Platform Contract
- Livewire v4.0+ compatibility remains intact
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false
- lifecycle mutations still run through confirmed Filament actions with capability enforcement
- no new asset family was added; the existing `filament:assets` deploy step is unchanged

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`
- browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed

## Notes
- this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #267
2026-04-23 07:29:05 +00:00

331 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\AuditLog;
use App\Models\BaselineProfile;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorType;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
if (! function_exists('automationBaselineCompareDriftItem')) {
/**
* @return array<string, mixed>
*/
function automationBaselineCompareDriftItem(int $baselineProfileId, int $compareOperationRunId, string $subjectKey): array
{
return [
'change_type' => 'different_version',
'severity' => Finding::SEVERITY_MEDIUM,
'subject_type' => 'policy',
'subject_external_id' => $subjectKey,
'subject_key' => $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => 'baseline',
'current_hash' => 'current',
'evidence_fidelity' => 'meta',
'evidence' => [
'change_type' => 'different_version',
'policy_type' => 'deviceConfiguration',
'subject_key' => $subjectKey,
'summary' => ['kind' => 'policy_snapshot'],
'baseline' => ['policy_version_id' => null, 'hash' => 'baseline'],
'current' => ['policy_version_id' => null, 'hash' => 'current'],
'fidelity' => 'meta',
'provenance' => [
'baseline_profile_id' => $baselineProfileId,
'baseline_snapshot_id' => 1,
'compare_operation_run_id' => $compareOperationRunId,
'inventory_sync_run_id' => null,
],
],
];
}
}
if (! function_exists('invokeAutomationBaselineCompareUpsertFindings')) {
/**
* @param array<int, array<string, mixed>> $driftResults
* @return array{processed_count:int,created_count:int,reopened_count:int,unchanged_count:int,seen_fingerprints:array<int,string>}
*/
function invokeAutomationBaselineCompareUpsertFindings(
CompareBaselineToTenantJob $job,
\App\Models\Tenant $tenant,
BaselineProfile $profile,
string $scopeKey,
array $driftResults,
): array {
$reflection = new ReflectionMethod($job, 'upsertFindings');
/** @var array{processed_count:int,created_count:int,reopened_count:int,unchanged_count:int,seen_fingerprints:array<int,string>} $result */
$result = $reflection->invoke($job, $tenant, $profile, $scopeKey, $driftResults);
return $result;
}
}
it('writes a system-origin audit row when stale drift findings auto-resolve', function (): void {
[, $tenant] = createUserWithTenant(role: 'manager');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
]);
$observedAt = CarbonImmutable::parse('2026-03-18T09:00:00Z');
CarbonImmutable::setTestNow($observedAt);
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'fingerprint' => 'drift-stale-audit',
'recurrence_key' => 'drift-stale-audit',
'status' => Finding::STATUS_NEW,
]);
expect(app(BaselineAutoCloseService::class)->resolveStaleFindings(
tenant: $tenant,
baselineProfileId: (int) $profile->getKey(),
seenFingerprints: [],
currentOperationRunId: (int) $run->getKey(),
))->toBe(1);
$audit = AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingResolved->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($audit->metadata, 'system_origin'))->toBeTrue()
->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING);
});
it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void {
[, $tenant] = createUserWithTenant();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T10:00:00Z'));
$generator = app(PermissionPostureFindingGenerator::class);
$generator->generate($tenant, [
'overall_status' => 'missing',
'permissions' => [[
'key' => 'Perm.Automation',
'type' => 'application',
'status' => 'missing',
'features' => ['policy-sync'],
]],
'last_refreshed_at' => now()->toIso8601String(),
]);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T11:00:00Z'));
$generator->generate($tenant, [
'overall_status' => 'granted',
'permissions' => [[
'key' => 'Perm.Automation',
'type' => 'application',
'status' => 'granted',
'features' => ['policy-sync'],
]],
'last_refreshed_at' => now()->toIso8601String(),
]);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T12:00:00Z'));
$generator->generate($tenant, [
'overall_status' => 'missing',
'permissions' => [[
'key' => 'Perm.Automation',
'type' => 'application',
'status' => 'missing',
'features' => ['policy-sync'],
]],
'last_refreshed_at' => now()->toIso8601String(),
]);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
->firstOrFail();
$resolvedAudit = AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingResolved->value)
->latest('id')
->first();
$reopenedAudit = AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingReopened->value)
->latest('id')
->first();
expect($resolvedAudit)->not->toBeNull()
->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED)
->and($reopenedAudit)->not->toBeNull()
->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED);
});
it('writes system-origin audit rows for entra admin role auto-resolve and recurrence reopen', function (): void {
[, $tenant] = createUserWithTenant();
$generator = new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
$generator->generate($tenant, [
'measured_at' => '2026-03-18T10:00:00Z',
'role_definitions' => [[
'id' => 'def-ga',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
]],
'role_assignments' => [[
'id' => 'a1',
'roleDefinitionId' => 'def-ga',
'principalId' => 'user-1',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Alice',
],
]],
]);
$generator->generate($tenant, [
'measured_at' => '2026-03-18T11:00:00Z',
'role_definitions' => [[
'id' => 'def-ga',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
]],
'role_assignments' => [],
]);
$generator->generate($tenant, [
'measured_at' => '2026-03-18T12:00:00Z',
'role_definitions' => [[
'id' => 'def-ga',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
]],
'role_assignments' => [[
'id' => 'a2',
'roleDefinitionId' => 'def-ga',
'principalId' => 'user-1',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Alice Reactivated',
],
]],
]);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->where('subject_external_id', 'user-1:def-ga')
->firstOrFail();
expect(AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingResolved->value)
->latest('id')
->first()?->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingReopened->value)
->latest('id')
->first()?->actorSnapshot()->type)->toBe(AuditActorType::System);
});
it('writes a system-origin reopen audit row for baseline recurrence handling', function (): void {
[, $tenant] = createUserWithTenant(role: 'manager');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$run1 = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
]);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T08:00:00Z'));
invokeAutomationBaselineCompareUpsertFindings(
new CompareBaselineToTenantJob($run1),
$tenant,
$profile,
$scopeKey,
[automationBaselineCompareDriftItem((int) $profile->getKey(), (int) $run1->getKey(), 'policy-audit-recur')],
);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->where('subject_external_id', 'policy-audit-recur')
->firstOrFail();
$finding->forceFill([
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'),
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
])->save();
$run2 = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
]);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T10:00:00Z'));
invokeAutomationBaselineCompareUpsertFindings(
new CompareBaselineToTenantJob($run2),
$tenant,
$profile,
$scopeKey,
[automationBaselineCompareDriftItem((int) $profile->getKey(), (int) $run2->getKey(), 'policy-audit-recur')],
);
$audit = AuditLog::query()
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingReopened->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($audit->metadata, 'system_origin'))->toBeTrue();
});