TenantAtlas/tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
ahmido da1adbdeb5 Spec 119: Drift cutover to Baseline Compare (golden master) (#144)
Implements Spec 119 (Drift Golden Master Cutover):

- Baseline Compare is the only drift writer (`source = baseline.compare`).
- Drift findings now store diff-compatible `evidence_jsonb` (summary.kind, baseline/current policy_version_id refs, fidelity + provenance).
- Findings UI renders one-sided diffs for `missing_policy`/`unexpected_policy` when a single ref exists; otherwise shows explicit “diff unavailable”.
- Removes legacy drift generator runtime (jobs/services/UI) and related tests.
- Adds one-time migration to delete legacy drift findings (`finding_type=drift` where source is null or != baseline.compare).
- Scopes baseline capture & landing duplicate warnings to latest completed inventory sync.
- Canonicalizes compliance `scheduledActionsForRule` drift signal and keeps legacy snapshots comparable.

Tests:
- `vendor/bin/sail artisan test --compact` (full suite per tasks)
- Focused pack: BaselinePolicyVersionResolverTest, BaselineCompareDriftEvidenceContractTest, DriftFindingDiffUnavailableTest, LegacyDriftFindingsCleanupMigrationTest, ComplianceNoncomplianceActionsDriftTest

Notes:
- Livewire v4+ / Filament v5 compatible (no legacy APIs).
- No new external dependencies.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #144
2026-03-06 14:30:49 +00:00

186 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
/**
* @return array{0: AlertRule, 1: AlertDestination}
*/
function createBaselineCompareFailedRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
{
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => 'baseline_compare_failed',
'minimum_severity' => 'low',
'is_enabled' => true,
'cooldown_seconds' => $cooldownSeconds,
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
return [$rule, $destination];
}
/**
* @return array<int, array<string, mixed>>
*/
function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$job = new EvaluateAlertsJob($workspaceId);
$reflection = new ReflectionMethod($job, 'baselineCompareFailedEvents');
/** @var array<int, array<string, mixed>> $events */
$events = $reflection->invoke($job, $workspaceId, $windowStart);
return $events;
}
it('produces baseline compare failed events for failed and partially succeeded baseline compare runs', function (): void {
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
CarbonImmutable::setTestNow($now);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
$windowStart = $now->subHour();
$failedRun = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => $now->subMinutes(10),
'failure_summary' => [
['message' => 'The baseline compare failed.'],
],
]);
$partialRun = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => $now->subMinutes(5),
'failure_summary' => [
['message' => 'The baseline compare partially succeeded.'],
],
]);
OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => $now->subMinutes(5),
]);
OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => $now->subMinutes(5),
]);
$events = invokeBaselineCompareFailedEvents($workspaceId, $windowStart);
expect($events)->toHaveCount(2);
$eventsByRunId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['operation_run_id']);
expect($eventsByRunId[$failedRun->getKey()])
->toMatchArray([
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$failedRun->getKey(),
'metadata' => [
'operation_run_id' => (int) $failedRun->getKey(),
],
]);
expect($eventsByRunId[$partialRun->getKey()])
->toMatchArray([
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$partialRun->getKey(),
'metadata' => [
'operation_run_id' => (int) $partialRun->getKey(),
],
]);
});
it('keeps baseline compare failed events compatible with dispatcher cooldown dedupe', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
[$rule, $destination] = createBaselineCompareFailedRuleWithDestination($workspaceId, cooldownSeconds: 3600);
$run = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
]);
$event = [
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$run->getKey(),
'title' => 'Baseline compare failed',
'body' => 'The baseline compare failed.',
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
],
];
$workspace = Workspace::query()->findOrFail($workspaceId);
$dispatchService = app(AlertDispatchService::class);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
$deliveries = AlertDelivery::query()
->where('workspace_id', $workspaceId)
->where('alert_rule_id', (int) $rule->getKey())
->where('alert_destination_id', (int) $destination->getKey())
->where('event_type', 'baseline_compare_failed')
->orderBy('id')
->get();
expect($deliveries)->toHaveCount(2);
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
});