TenantAtlas/tests/Feature/Baselines/BaselineCompareStatsTest.php
ahmido fdfb781144 feat(115): baseline operability + alerts (#140)
Implements Spec 115 (Baseline Operability & Alert Integration).

Key changes
- Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares)
- Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics
- Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle
- Baseline Compare UX: shared stats layer + landing/widget consistency

Notes
- Livewire v4 / Filament v5 compatible.
- Destructive-like actions require confirmation (no new destructive actions added here).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #140
2026-03-01 02:26:47 +00:00

275 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareStats;
it('returns no_tenant state when tenant is null', function (): void {
$stats = BaselineCompareStats::forTenant(null);
expect($stats->state)->toBe('no_tenant')
->and($stats->message)->toContain('No tenant');
});
it('returns no_assignment state when tenant has no baseline assignment', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('no_assignment')
->and($stats->profileName)->toBeNull();
});
it('returns no_snapshot state when profile has no active snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'active_snapshot_id' => null,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('no_snapshot')
->and($stats->profileName)->toBe($profile->name);
});
it('returns comparing state when a run is queued', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'queued',
'outcome' => 'pending',
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('comparing')
->and($stats->operationRunId)->not->toBeNull();
});
it('returns failed state when the latest run has failed outcome', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'failed',
'failure_summary' => ['message' => 'Graph API timeout'],
'completed_at' => now()->subHour(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('failed')
->and($stats->failureReason)->toBe('Graph API timeout')
->and($stats->operationRunId)->not->toBeNull()
->and($stats->lastComparedHuman)->not->toBeNull()
->and($stats->lastComparedIso)->not->toBeNull();
});
it('returns ready state with grouped severity counts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
Finding::factory()->count(2)->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_LOW,
'status' => Finding::STATUS_NEW,
]);
// Terminal finding should not be counted in "open" drift totals.
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_RESOLVED,
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'succeeded',
'completed_at' => now()->subHours(2),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('ready')
->and($stats->findingsCount)->toBe(4)
->and($stats->severityCounts)->toBe([
'high' => 2,
'medium' => 1,
'low' => 1,
])
->and($stats->lastComparedHuman)->not->toBeNull()
->and($stats->lastComparedIso)->toContain('T');
});
it('returns idle state when profile is ready but no run exists yet', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('idle')
->and($stats->profileName)->toBe($profile->name)
->and($stats->snapshotId)->toBe((int) $snapshot->getKey());
});
it('forWidget returns grouped severity counts for new findings only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
// New finding (should be counted)
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
// Resolved finding (should NOT be counted)
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_RESOLVED,
]);
$stats = BaselineCompareStats::forWidget($tenant);
expect($stats->findingsCount)->toBe(1)
->and($stats->severityCounts['high'])->toBe(1);
});