TenantAtlas/tests/Feature/Findings/FindingAutomationWorkflowTest.php
2026-03-18 13:56:09 +01: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('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('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' => 'fixed',
])->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();
});