TenantAtlas/tests/Feature/Baselines/BaselineCompareFindingsTest.php

415 lines
15 KiB
PHP

<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
// --- T041: Compare idempotent finding fingerprint tests ---
it('creates drift findings when baseline and tenant inventory differ', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// Baseline has policyA and policyB
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-a'),
'meta_jsonb' => ['display_name' => 'Policy A'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-b-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-b'),
'meta_jsonb' => ['display_name' => 'Policy B'],
]);
// Tenant has policyA (different content) and policyC (unexpected)
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-c-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['new_policy' => true],
'display_name' => 'Policy C unexpected',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$scopeKey = 'baseline_profile:' . $profile->getKey();
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->get();
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
expect($findings->count())->toBe(3);
$severities = $findings->pluck('severity')->sort()->values()->all();
expect($severities)->toContain(Finding::SEVERITY_HIGH);
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
expect($severities)->toContain(Finding::SEVERITY_LOW);
});
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'meta_jsonb' => ['display_name' => 'Policy X'],
]);
// Tenant does NOT have policy-x → missing_policy finding
$opService = app(OperationRunService::class);
// First run
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job1 = new CompareBaselineToTenantJob($run1);
$job1->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:' . $profile->getKey();
$countAfterFirst = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
expect($countAfterFirst)->toBe(1);
// Second run - new OperationRun so we can dispatch again
// Mark first run as completed so ensureRunWithIdentity creates a new one
$run1->update(['status' => 'completed', 'completed_at' => now()]);
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job2 = new CompareBaselineToTenantJob($run2);
$job2->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$countAfterSecond = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
// Same fingerprint → same finding updated, not duplicated
expect($countAfterSecond)->toBe(1);
});
it('creates zero findings when baseline matches tenant inventory exactly', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// Baseline item
$metaContent = ['policy_key' => 'value123'];
$driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $contentHash,
'meta_jsonb' => ['display_name' => 'Matching Policy'],
]);
// Tenant inventory with same content → same hash
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? -1))->toBe(0);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->count();
expect($findings)->toBe(0);
});
// --- T042: Summary counts severity breakdown tests ---
it('writes severity breakdown in summary_counts', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// 2 baseline items: one will be missing (high), one will be different (medium)
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'missing-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'missing-content'),
'meta_jsonb' => ['display_name' => 'Missing Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'changed-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'original-content'),
'meta_jsonb' => ['display_name' => 'Changed Policy'],
]);
// Tenant only has changed-uuid with different content + extra-uuid (unexpected)
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'changed-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['modified_content' => true],
'display_name' => 'Changed Policy',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'extra-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['extra_content' => true],
'display_name' => 'Extra Policy',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? -1))->toBe(3);
expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid
expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid
expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid
});
it('writes result context with findings breakdown', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// One missing policy
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'gone-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'gone-content'),
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
$context = is_array($run->context) ? $run->context : [];
$result = $context['result'] ?? [];
expect($result)->toHaveKey('findings_total');
expect($result)->toHaveKey('findings_upserted');
expect($result)->toHaveKey('severity_breakdown');
expect((int) $result['findings_total'])->toBe(1);
expect((int) $result['findings_upserted'])->toBe(1);
});