959 lines
35 KiB
PHP
959 lines
35 KiB
PHP
<?php
|
|
|
|
use App\Jobs\CompareBaselineToTenantJob;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\WorkspaceSetting;
|
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
|
use App\Services\Drift\DriftHasher;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
|
|
use function Pest\Laravel\mock;
|
|
|
|
// --- 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: OperationRunType::BaselineCompare->value,
|
|
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);
|
|
|
|
// Lifecycle v2 fields must be initialized for new findings.
|
|
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
|
|
expect($findings->pluck('last_seen_at')->filter()->count())->toBe($findings->count());
|
|
expect($findings->pluck('times_seen')->every(fn ($value) => (int) $value === 1))->toBeTrue();
|
|
|
|
$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('does not fail compare when baseline severity mapping setting is missing', 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-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'],
|
|
]);
|
|
|
|
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',
|
|
]);
|
|
|
|
$opService = app(OperationRunService::class);
|
|
$run = $opService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
$settingsResolver = mock(SettingsResolver::class);
|
|
$settingsResolver
|
|
->shouldReceive('resolveValue')
|
|
->andThrow(new InvalidArgumentException('Unknown setting key: baseline.severity_mapping'));
|
|
|
|
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
|
|
|
|
(new CompareBaselineToTenantJob($run))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$opService,
|
|
settingsResolver: $settingsResolver,
|
|
baselineAutoCloseService: $baselineAutoCloseService,
|
|
);
|
|
|
|
$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) = 2 findings.
|
|
expect($findings->count())->toBe(2);
|
|
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_HIGH);
|
|
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_MEDIUM);
|
|
});
|
|
|
|
it('treats inventory items not seen in latest inventory sync as missing', function () {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$profile = BaselineProfile::factory()->active()->create([
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'scope_jsonb' => ['policy_types' => ['settingsCatalogPolicy']],
|
|
]);
|
|
|
|
$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' => 'settings-catalog-policy-uuid',
|
|
'policy_type' => 'settingsCatalogPolicy',
|
|
'baseline_hash' => hash('sha256', 'content-a'),
|
|
'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
|
|
]);
|
|
|
|
$olderInventoryRun = OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'type' => OperationRunType::InventorySync->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now()->subMinutes(5),
|
|
'context' => [
|
|
'policy_types' => ['settingsCatalogPolicy'],
|
|
'selection_hash' => 'older',
|
|
],
|
|
]);
|
|
|
|
// Inventory item exists, but it was NOT observed in the latest sync run.
|
|
InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'external_id' => 'settings-catalog-policy-uuid',
|
|
'policy_type' => 'settingsCatalogPolicy',
|
|
'display_name' => 'Settings Catalog A',
|
|
'meta_jsonb' => ['etag' => 'abc'],
|
|
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
|
|
'last_seen_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'type' => OperationRunType::InventorySync->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now(),
|
|
'context' => [
|
|
'policy_types' => ['settingsCatalogPolicy'],
|
|
'selection_hash' => 'latest',
|
|
],
|
|
]);
|
|
|
|
$opService = app(OperationRunService::class);
|
|
$run = $opService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
|
context: [
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
'effective_scope' => ['policy_types' => ['settingsCatalogPolicy']],
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($run))->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();
|
|
|
|
expect($findings->count())->toBe(1);
|
|
expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy');
|
|
});
|
|
|
|
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: OperationRunType::BaselineCompare->value,
|
|
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: OperationRunType::BaselineCompare->value,
|
|
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: OperationRunType::BaselineCompare->value,
|
|
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);
|
|
});
|
|
|
|
it('does not create missing_policy findings for baseline snapshot items outside effective scope', 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()]);
|
|
|
|
$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'],
|
|
]);
|
|
|
|
BaselineSnapshotItem::factory()->create([
|
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => 'foundation-uuid',
|
|
'policy_type' => 'notificationMessageTemplate',
|
|
'baseline_hash' => hash('sha256', 'foundation-content'),
|
|
'meta_jsonb' => ['display_name' => 'Foundation Template'],
|
|
]);
|
|
|
|
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',
|
|
]);
|
|
|
|
InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'external_id' => 'foundation-uuid',
|
|
'policy_type' => 'notificationMessageTemplate',
|
|
'meta_jsonb' => ['some' => 'value'],
|
|
'display_name' => 'Foundation Template',
|
|
]);
|
|
|
|
$opService = app(OperationRunService::class);
|
|
$run = $opService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($run))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$opService,
|
|
);
|
|
|
|
$run = $run->fresh();
|
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
|
expect((int) ($counts['total'] ?? -1))->toBe(0);
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
expect(
|
|
Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey)
|
|
->count()
|
|
)->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: OperationRunType::BaselineCompare->value,
|
|
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: OperationRunType::BaselineCompare->value,
|
|
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);
|
|
});
|
|
|
|
it('reopens a previously resolved baseline finding when the same drift reappears', function (): void {
|
|
[$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-reappears',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'baseline_hash' => hash('sha256', 'baseline-content'),
|
|
'meta_jsonb' => ['display_name' => 'Policy Reappears'],
|
|
]);
|
|
|
|
$operationRuns = app(OperationRunService::class);
|
|
|
|
$firstRun = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($firstRun))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$operationRuns,
|
|
);
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('source', 'baseline.compare')
|
|
->sole();
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
'resolved_at' => now()->subMinute(),
|
|
'resolved_reason' => 'manually_resolved',
|
|
])->save();
|
|
|
|
$firstRun->update(['completed_at' => now()->subMinute()]);
|
|
|
|
$secondRun = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($secondRun))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$operationRuns,
|
|
);
|
|
|
|
$finding->refresh();
|
|
expect($finding->status)->toBe(Finding::STATUS_REOPENED);
|
|
expect($finding->resolved_at)->toBeNull();
|
|
expect($finding->resolved_reason)->toBeNull();
|
|
expect($finding->reopened_at)->not->toBeNull();
|
|
});
|
|
|
|
it('preserves an existing open workflow status when the same baseline drift is seen again', function (): void {
|
|
[$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' => 'triaged-policy',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'baseline_hash' => hash('sha256', 'baseline-content'),
|
|
'meta_jsonb' => ['display_name' => 'Triaged Policy'],
|
|
]);
|
|
|
|
$operationRuns = app(OperationRunService::class);
|
|
|
|
$firstRun = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($firstRun))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$operationRuns,
|
|
);
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('source', 'baseline.compare')
|
|
->sole();
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'triaged_at' => now()->subMinute(),
|
|
])->save();
|
|
|
|
$firstRun->update(['completed_at' => now()->subMinute()]);
|
|
|
|
$secondRun = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($secondRun))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$operationRuns,
|
|
);
|
|
|
|
$finding->refresh();
|
|
expect($finding->status)->toBe(Finding::STATUS_TRIAGED);
|
|
expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey());
|
|
});
|
|
|
|
it('applies the workspace baseline severity mapping by change type', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
WorkspaceSetting::query()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'domain' => 'baseline',
|
|
'key' => 'severity_mapping',
|
|
'value' => [
|
|
'missing_policy' => Finding::SEVERITY_CRITICAL,
|
|
'different_version' => Finding::SEVERITY_LOW,
|
|
'unexpected_policy' => Finding::SEVERITY_MEDIUM,
|
|
],
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
]);
|
|
|
|
$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' => 'missing-policy',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'baseline_hash' => hash('sha256', 'baseline-a'),
|
|
'meta_jsonb' => ['display_name' => 'Missing Policy'],
|
|
]);
|
|
BaselineSnapshotItem::factory()->create([
|
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => 'different-policy',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'baseline_hash' => hash('sha256', 'baseline-b'),
|
|
'meta_jsonb' => ['display_name' => 'Different Policy'],
|
|
]);
|
|
|
|
InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'external_id' => 'different-policy',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'meta_jsonb' => ['different_content' => true],
|
|
'display_name' => 'Different Policy',
|
|
]);
|
|
InventoryItem::factory()->create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'workspace_id' => $tenant->workspace_id,
|
|
'external_id' => 'unexpected-policy',
|
|
'policy_type' => 'deviceConfiguration',
|
|
'meta_jsonb' => ['unexpected' => true],
|
|
'display_name' => 'Unexpected Policy',
|
|
]);
|
|
|
|
$operationRuns = app(OperationRunService::class);
|
|
$run = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::BaselineCompare->value,
|
|
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,
|
|
);
|
|
|
|
(new CompareBaselineToTenantJob($run))->handle(
|
|
app(DriftHasher::class),
|
|
app(BaselineSnapshotIdentity::class),
|
|
app(AuditLogger::class),
|
|
$operationRuns,
|
|
);
|
|
|
|
$findings = Finding::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('source', 'baseline.compare')
|
|
->get()
|
|
->keyBy(fn (Finding $finding): string => (string) data_get($finding->evidence_jsonb, 'change_type'));
|
|
|
|
expect($findings['missing_policy']->severity)->toBe(Finding::SEVERITY_CRITICAL);
|
|
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
|
|
expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM);
|
|
});
|