From 559bba09a0c495886ddacd63dd8df7a19c697298 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 4 Mar 2026 00:09:27 +0100 Subject: [PATCH] fix: align compare UI + drift engine tests with subject_key --- app/Filament/Pages/BaselineCompareLanding.php | 8 + app/Filament/Resources/FindingResource.php | 29 +- .../Resources/OperationRunResource.php | 124 +++++++++ .../Baselines/BaselineCompareStats.php | 67 +++++ .../pages/baseline-compare-landing.blade.php | 41 ++- specs/118-baseline-drift-engine/tasks.md | 36 +-- .../CompareContentEvidenceTest.php | 41 ++- .../CompareFidelityMismatchTest.php | 37 ++- .../FindingFidelityTest.php | 29 +- .../FindingProvenanceTest.php | 22 +- .../BaselineDriftEngine/ResolverTest.php | 10 +- .../BaselineCompareCoverageGuardTest.php | 23 +- .../Baselines/BaselineCompareFindingsTest.php | 251 +++++++++++++----- 13 files changed, 593 insertions(+), 125 deletions(-) diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 12b6e00..c2360e8 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -72,6 +72,11 @@ class BaselineCompareLanding extends Page public ?string $fidelity = null; + public ?int $evidenceGapsCount = null; + + /** @var array|null */ + public ?array $evidenceGapsTopReasons = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -116,6 +121,9 @@ public function refreshStats(): void $this->uncoveredTypesCount = $stats->uncoveredTypesCount; $this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null; $this->fidelity = $stats->fidelity; + + $this->evidenceGapsCount = $stats->evidenceGapsCount; + $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 8363c8a..29d31ba 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -144,7 +144,20 @@ public static function infolist(Schema $schema): Schema ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), TextEntry::make('fingerprint')->label('Fingerprint')->copyable(), TextEntry::make('scope_key')->label('Scope')->copyable(), - TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), + TextEntry::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->state(function (Finding $record): ?string { + $state = $record->subject_display_name; + if (is_string($state) && trim($state) !== '') { + return $state; + } + + $fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name'); + $fallback = is_string($fallback) ? trim($fallback) : null; + + return $fallback !== '' ? $fallback : null; + }), TextEntry::make('subject_type')->label('Subject type'), TextEntry::make('subject_external_id')->label('External ID')->copyable(), TextEntry::make('baseline_operation_run_id') @@ -372,7 +385,19 @@ public static function table(Table $table): Table default => 'gray', }) ->sortable(), - Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), + Tables\Columns\TextColumn::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->formatStateUsing(function (?string $state, Finding $record): ?string { + if (is_string($state) && trim($state) !== '') { + return $state; + } + + $fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name'); + $fallback = is_string($fallback) ? trim($fallback) : null; + + return $fallback !== '' ? $fallback : null; + }), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('due_at') ->label('Due') diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 78d61ea..b7bd719 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -259,6 +259,130 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), + Section::make('Baseline compare evidence') + ->schema([ + TextEntry::make('baseline_compare_subjects_total') + ->label('Subjects total') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.subjects_total'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_compare_gap_count') + ->label('Evidence gaps') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_gaps.count'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_compare_resume_token') + ->label('Resume token') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.resume_token'); + + return is_string($value) && $value !== '' ? $value : null; + }) + ->copyable() + ->placeholder('—') + ->columnSpanFull() + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.resume_token'); + + return is_string($value) && $value !== ''; + }), + ViewEntry::make('baseline_compare_evidence_capture') + ->label('Evidence capture') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_capture'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ViewEntry::make('baseline_compare_evidence_gaps') + ->label('Evidence gaps') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_gaps'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ]) + ->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare') + ->columns(2) + ->columnSpanFull(), + + Section::make('Baseline capture evidence') + ->schema([ + TextEntry::make('baseline_capture_subjects_total') + ->label('Subjects total') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.subjects_total'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_capture_gap_count') + ->label('Gaps') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.gaps.count'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_capture_resume_token') + ->label('Resume token') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.resume_token'); + + return is_string($value) && $value !== '' ? $value : null; + }) + ->copyable() + ->placeholder('—') + ->columnSpanFull() + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.resume_token'); + + return is_string($value) && $value !== ''; + }), + ViewEntry::make('baseline_capture_evidence_capture') + ->label('Evidence capture') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.evidence_capture'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ViewEntry::make('baseline_capture_gaps') + ->label('Gaps') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.gaps'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ]) + ->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_capture') + ->columns(2) + ->columnSpanFull(), + Section::make('Verification report') ->schema([ ViewEntry::make('verification_report') diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index ec9be9b..3cbf473 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -15,6 +15,7 @@ final class BaselineCompareStats /** * @param array $severityCounts * @param list $uncoveredTypes + * @param array $evidenceGapsTopReasons */ private function __construct( public readonly string $state, @@ -32,6 +33,8 @@ private function __construct( public readonly ?int $uncoveredTypesCount = null, public readonly array $uncoveredTypes = [], public readonly ?string $fidelity = null, + public readonly ?int $evidenceGapsCount = null, + public readonly array $evidenceGapsTopReasons = [], ) {} public static function forTenant(?Tenant $tenant): self @@ -80,6 +83,7 @@ public static function forTenant(?Tenant $tenant): self ->first(); [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); + [$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun); // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { @@ -99,6 +103,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -125,6 +131,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -173,6 +181,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -195,6 +205,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -214,6 +226,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -321,6 +335,59 @@ private static function coverageInfoForRun(?OperationRun $run): array return [$coverageStatus, $uncoveredTypes, $fidelity]; } + /** + * @return array{0: ?int, 1: array} + */ + private static function evidenceGapSummaryForRun(?OperationRun $run): array + { + if (! $run instanceof OperationRun) { + return [null, []]; + } + + $context = is_array($run->context) ? $run->context : []; + $baselineCompare = $context['baseline_compare'] ?? null; + + if (! is_array($baselineCompare)) { + return [null, []]; + } + + $gaps = $baselineCompare['evidence_gaps'] ?? null; + + if (! is_array($gaps)) { + return [null, []]; + } + + $count = $gaps['count'] ?? null; + $count = is_numeric($count) ? (int) $count : null; + + $byReason = $gaps['by_reason'] ?? null; + $byReason = is_array($byReason) ? $byReason : []; + + $normalized = []; + + foreach ($byReason as $reason => $value) { + if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) { + continue; + } + + $intValue = (int) $value; + + if ($intValue <= 0) { + continue; + } + + $normalized[trim($reason)] = $intValue; + } + + if ($count === null) { + $count = array_sum($normalized); + } + + arsort($normalized); + + return [$count, array_slice($normalized, 0, 6, true)]; + } + private static function empty( string $state, ?string $message, diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 224e689..27458ee 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -6,6 +6,29 @@ @php $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); + $evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0); + $hasEvidenceGaps = $evidenceGapsCountValue > 0; + $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; + + $evidenceGapsSummary = null; + $evidenceGapsTooltip = null; + + if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) { + $parts = []; + + foreach (array_slice($evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { + if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { + continue; + } + + $parts[] = $reason.' ('.((int) $count).')'; + } + + if ($parts !== []) { + $evidenceGapsSummary = implode(', ', $parts); + $evidenceGapsTooltip = 'Top gaps: '.$evidenceGapsSummary; + } + } @endphp {{-- Row 1: Stats Overview --}} @@ -38,7 +61,19 @@ class="w-fit" Fidelity: {{ Str::title($fidelity) }} @endif + + @if ($hasEvidenceGaps) + + Evidence gaps: {{ $evidenceGapsCountValue }} + + @endif + + @if ($hasEvidenceGaps && filled($evidenceGapsSummary)) +
+ Top gaps: {{ $evidenceGapsSummary }} +
+ @endif @@ -49,7 +84,7 @@ class="w-fit" @if ($state === 'failed')
Error
@else -
+
{{ $findingsCount ?? 0 }}
@endif @@ -58,10 +93,12 @@ class="w-fit" Comparing…
- @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings) + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings) All clear @elseif ($state === 'ready' && $hasCoverageWarnings) Coverage warnings + @elseif ($state === 'ready' && $hasEvidenceGaps) + Evidence gaps @endif diff --git a/specs/118-baseline-drift-engine/tasks.md b/specs/118-baseline-drift-engine/tasks.md index 7f5a144..f8d41fa 100644 --- a/specs/118-baseline-drift-engine/tasks.md +++ b/specs/118-baseline-drift-engine/tasks.md @@ -107,27 +107,27 @@ ## Phase 4: User Story 2 — Compare now with full content and get explainable d ### Tests (write first) -- [ ] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` -- [ ] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding) -- [ ] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context) -- [ ] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update) -- [ ] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` -- [ ] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` -- [ ] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary) +- [X] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` +- [X] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding) +- [X] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context) +- [X] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update) +- [X] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [X] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` +- [X] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary) ### Implementation -- [ ] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side) -- [ ] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard) -- [ ] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys) -- [ ] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes) -- [ ] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings) -- [ ] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name) -- [ ] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` -- [ ] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php` -- [ ] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php` -- [ ] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side) +- [X] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard) +- [X] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys) +- [X] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes) +- [X] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings) +- [X] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name) +- [X] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [X] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php` +- [X] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php` +- [X] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php` **Parallel execution example (US2)**: diff --git a/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php index 6f5e53b..7dcd4d4 100644 --- a/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php +++ b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -53,18 +56,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy A', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', @@ -168,18 +180,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy B', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', diff --git a/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php index 43ef3f6..da91741 100644 --- a/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php +++ b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -68,14 +71,21 @@ metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], ); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineMetaHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Meta', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'meta', 'source' => 'inventory', @@ -123,7 +133,7 @@ $context = is_array($run->context) ? $run->context : []; expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta'); - expect(data_get($context, 'baseline_compare.coverage.resolved_content'))->toBe(1); + expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1); expect(data_get($context, 'baseline_compare.coverage.baseline_meta'))->toBe(1); }); @@ -164,18 +174,27 @@ ], ]; - $baselineContentHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineContentHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineContentHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Content', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', @@ -234,7 +253,7 @@ $context = is_array($run->context) ? $run->context : []; expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta'); - expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1); + expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(0); expect(data_get($context, 'baseline_compare.coverage.baseline_content'))->toBe(1); expect(data_get($context, 'baseline_compare.evidence_gaps.missing_current'))->toBe(1); }); diff --git a/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php index 93e3b68..74cd6ca 100644 --- a/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php +++ b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -51,14 +54,23 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ @@ -169,10 +181,17 @@ metaJsonb: $baselineMetaJsonb, ); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ diff --git a/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php index 62d0132..84b2def 100644 --- a/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php +++ b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -51,18 +54,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Provenance', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', diff --git a/tests/Feature/BaselineDriftEngine/ResolverTest.php b/tests/Feature/BaselineDriftEngine/ResolverTest.php index b74d2e8..821d68c 100644 --- a/tests/Feature/BaselineDriftEngine/ResolverTest.php +++ b/tests/Feature/BaselineDriftEngine/ResolverTest.php @@ -6,7 +6,9 @@ use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use Carbon\CarbonImmutable; it('Baseline resolver prefers content evidence over meta evidence when available', function () { @@ -46,13 +48,15 @@ 'last_seen_operation_run_id' => null, ]); - $expectedContentHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff( + $expectedContentHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff( is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [], (string) $policyVersion->policy_type, is_string($policyVersion->platform) ? $policyVersion->platform : null, ), - ); + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); $expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( policyType: (string) $inventory->policy_type, diff --git a/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php index f109d84..1131856 100644 --- a/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php +++ b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php @@ -12,6 +12,7 @@ use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; @@ -43,13 +44,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], ); + $coveredDisplayName = 'Covered Policy'; + $coveredSubjectKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName); + expect($coveredSubjectKey)->not->toBeNull(); + $coveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $coveredSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'covered-uuid', + 'subject_external_id' => $coveredWorkspaceSafeExternalId, + 'subject_key' => (string) $coveredSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $hasher->hashNormalized($coveredContract), - 'meta_jsonb' => ['display_name' => 'Covered Policy'], + 'meta_jsonb' => ['display_name' => $coveredDisplayName], ]); $uncoveredContract = $builder->build( @@ -58,13 +65,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'], ); + $uncoveredDisplayName = 'Uncovered Policy'; + $uncoveredSubjectKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName); + expect($uncoveredSubjectKey)->not->toBeNull(); + $uncoveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'uncovered-uuid', + 'subject_external_id' => $uncoveredWorkspaceSafeExternalId, + 'subject_key' => (string) $uncoveredSubjectKey, 'policy_type' => 'deviceCompliancePolicy', 'baseline_hash' => $hasher->hashNormalized($uncoveredContract), - 'meta_jsonb' => ['display_name' => 'Uncovered Policy'], + 'meta_jsonb' => ['display_name' => $uncoveredDisplayName], ]); $inventorySyncRun = OperationRun::factory()->create([ @@ -93,7 +106,7 @@ 'external_id' => 'covered-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], - 'display_name' => 'Covered Policy Changed', + 'display_name' => $coveredDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 6ed5527..cd81ae6 100644 --- a/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -15,6 +15,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use App\Support\OpsUx\OperationSummaryKeys; @@ -42,22 +43,46 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $policyType = 'deviceConfiguration'; + + $displayNameA = 'Policy A'; + $subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA); + expect($subjectKeyA)->not->toBeNull(); + $workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA); + $baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-a-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'], + ); + + $displayNameB = 'Policy B'; + $subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB); + expect($subjectKeyB)->not->toBeNull(); + $workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB); + $baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-b-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'], + ); + // 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'], + 'subject_external_id' => $workspaceSafeExternalIdA, + 'subject_key' => (string) $subjectKeyA, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashA, + 'meta_jsonb' => ['display_name' => $displayNameA], ]); 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'], + 'subject_external_id' => $workspaceSafeExternalIdB, + 'subject_key' => (string) $subjectKeyB, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashB, + 'meta_jsonb' => ['display_name' => $displayNameB], ]); // Tenant has policyA (different content) and policyC (unexpected) @@ -65,9 +90,9 @@ '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', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'], + 'display_name' => $displayNameA, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -75,9 +100,9 @@ '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', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_C'], + 'display_name' => 'Policy C', 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -157,30 +182,54 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $policyType = 'deviceConfiguration'; + + $displayNameA = 'Policy A'; + $subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA); + expect($subjectKeyA)->not->toBeNull(); + $workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA); + $baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-a-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'], + ); + + $displayNameB = 'Policy B'; + $subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB); + expect($subjectKeyB)->not->toBeNull(); + $workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB); + $baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-b-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'], + ); + 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'], + 'subject_external_id' => $workspaceSafeExternalIdA, + 'subject_key' => (string) $subjectKeyA, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashA, + 'meta_jsonb' => ['display_name' => $displayNameA], ]); 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'], + 'subject_external_id' => $workspaceSafeExternalIdB, + 'subject_key' => (string) $subjectKeyB, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashB, + 'meta_jsonb' => ['display_name' => $displayNameB], ]); 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', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'], + 'display_name' => $displayNameA, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -266,13 +315,19 @@ ], ); + $displayName = 'Settings Catalog A'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('settingsCatalogPolicy', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'settings-catalog-policy-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'settingsCatalogPolicy', 'baseline_hash' => hash('sha256', 'content-a'), - 'meta_jsonb' => ['display_name' => 'Settings Catalog A'], + 'meta_jsonb' => ['display_name' => $displayName], ]); // Inventory item exists, but it was NOT observed in the latest sync run. @@ -281,7 +336,7 @@ 'workspace_id' => $tenant->workspace_id, 'external_id' => 'settings-catalog-policy-uuid', 'policy_type' => 'settingsCatalogPolicy', - 'display_name' => 'Settings Catalog A', + 'display_name' => $displayName, 'meta_jsonb' => ['etag' => 'abc'], 'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(), 'last_seen_at' => now()->subMinutes(5), @@ -351,13 +406,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], ); + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $hasher->hashNormalized($baselineContract), - 'meta_jsonb' => ['display_name' => 'Policy X'], + 'meta_jsonb' => ['display_name' => $displayName], ]); InventoryItem::factory()->create([ @@ -366,7 +427,7 @@ 'external_id' => 'policy-x-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], - 'display_name' => 'Policy X modified', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -453,7 +514,7 @@ expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1); }); -it('creates new finding identities when a new snapshot is captured (snapshot-scoped recurrence)', function () { +it('does not create new finding identities when a new snapshot is captured', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ @@ -476,6 +537,11 @@ ); $baselineHash = $hasher->hashNormalized($baselineContract); + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + $snapshot1 = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), @@ -484,9 +550,11 @@ BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot1->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $baselineHash, + 'meta_jsonb' => ['display_name' => $displayName], ]); InventoryItem::factory()->create([ @@ -495,6 +563,7 @@ 'external_id' => 'policy-x-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -520,13 +589,14 @@ $scopeKey = 'baseline_profile:'.$profile->getKey(); - $fingerprint1 = (string) Finding::query() + $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) - ->orderBy('id') - ->firstOrFail() - ->fingerprint; + ->sole(); + + expect($finding->times_seen)->toBe(1); + $fingerprint1 = (string) $finding->fingerprint; $snapshot2 = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, @@ -536,9 +606,11 @@ BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot2->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $baselineHash, + 'meta_jsonb' => ['display_name' => $displayName], ]); $run2 = $opService->ensureRunWithIdentity( @@ -566,9 +638,9 @@ ->orderBy('id') ->get(); - expect($findings)->toHaveCount(2); - expect($findings->pluck('fingerprint')->unique()->count())->toBe(2); - expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1); + expect($findings)->toHaveCount(1); + expect((string) $findings->first()?->fingerprint)->toBe($fingerprint1); + expect((int) $findings->first()?->times_seen)->toBe(2); }); it('creates zero findings when baseline matches tenant inventory exactly', function () { @@ -607,13 +679,19 @@ metaJsonb: $metaContent, )); + $displayName = 'Matching Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'matching-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $contentHash, - 'meta_jsonb' => ['display_name' => 'Matching Policy'], + 'meta_jsonb' => ['display_name' => $displayName], ]); // Tenant inventory with same content → same hash @@ -623,7 +701,7 @@ 'external_id' => 'matching-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, - 'display_name' => 'Matching Policy', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -698,22 +776,37 @@ metaJsonb: $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'], - ]); + $displayName = 'Matching Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'foundation-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $contentHash, + 'meta_jsonb' => ['display_name' => $displayName], + ]); + + $foundationDisplayName = 'Foundation Template'; + $foundationSubjectKey = BaselineSubjectKey::fromDisplayName($foundationDisplayName); + expect($foundationSubjectKey)->not->toBeNull(); + $foundationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + 'notificationMessageTemplate', + (string) $foundationSubjectKey, + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $foundationWorkspaceSafeExternalId, + 'subject_key' => (string) $foundationSubjectKey, 'policy_type' => 'notificationMessageTemplate', 'baseline_hash' => hash('sha256', 'foundation-content'), - 'meta_jsonb' => ['display_name' => 'Foundation Template'], + 'meta_jsonb' => ['display_name' => $foundationDisplayName], ]); InventoryItem::factory()->create([ @@ -722,7 +815,7 @@ 'external_id' => 'matching-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, - 'display_name' => 'Matching Policy', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -733,7 +826,7 @@ 'external_id' => 'foundation-uuid', 'policy_type' => 'notificationMessageTemplate', 'meta_jsonb' => ['some' => 'value'], - 'display_name' => 'Foundation Template', + 'display_name' => $foundationDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -795,21 +888,34 @@ ); // 2 baseline items: one will be missing (high), one will be different (medium) + $missingDisplayName = 'Missing Policy'; + $missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName); + expect($missingSubjectKey)->not->toBeNull(); + $missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'missing-uuid', + 'subject_external_id' => $missingWorkspaceSafeExternalId, + 'subject_key' => (string) $missingSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'missing-content'), - 'meta_jsonb' => ['display_name' => 'Missing Policy'], + 'meta_jsonb' => ['display_name' => $missingDisplayName], ]); + + $changedDisplayName = 'Changed Policy'; + $changedSubjectKey = BaselineSubjectKey::fromDisplayName($changedDisplayName); + expect($changedSubjectKey)->not->toBeNull(); + $changedWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $changedSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'changed-uuid', + 'subject_external_id' => $changedWorkspaceSafeExternalId, + 'subject_key' => (string) $changedSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'original-content'), - 'meta_jsonb' => ['display_name' => 'Changed Policy'], + 'meta_jsonb' => ['display_name' => $changedDisplayName], ]); // Tenant only has changed-uuid with different content + extra-uuid (unexpected) @@ -819,7 +925,7 @@ 'external_id' => 'changed-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['modified_content' => true], - 'display_name' => 'Changed Policy', + 'display_name' => $changedDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -1126,21 +1232,34 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $missingDisplayName = 'Missing Policy'; + $missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName); + expect($missingSubjectKey)->not->toBeNull(); + $missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'missing-policy', + 'subject_external_id' => $missingWorkspaceSafeExternalId, + 'subject_key' => (string) $missingSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'baseline-a'), - 'meta_jsonb' => ['display_name' => 'Missing Policy'], + 'meta_jsonb' => ['display_name' => $missingDisplayName], ]); + + $differentDisplayName = 'Different Policy'; + $differentSubjectKey = BaselineSubjectKey::fromDisplayName($differentDisplayName); + expect($differentSubjectKey)->not->toBeNull(); + $differentWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $differentSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'different-policy', + 'subject_external_id' => $differentWorkspaceSafeExternalId, + 'subject_key' => (string) $differentSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'baseline-b'), - 'meta_jsonb' => ['display_name' => 'Different Policy'], + 'meta_jsonb' => ['display_name' => $differentDisplayName], ]); InventoryItem::factory()->create([ @@ -1149,7 +1268,7 @@ 'external_id' => 'different-policy', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['different_content' => true], - 'display_name' => 'Different Policy', + 'display_name' => $differentDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]);