fix: align compare UI + drift engine tests with subject_key

This commit is contained in:
Ahmed Darrazi 2026-03-04 00:09:27 +01:00
parent 3e0dc438f7
commit 559bba09a0
13 changed files with 593 additions and 125 deletions

View File

@ -72,6 +72,11 @@ class BaselineCompareLanding extends Page
public ?string $fidelity = null;
public ?int $evidenceGapsCount = null;
/** @var array<string, int>|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

View File

@ -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')

View File

@ -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')

View File

@ -15,6 +15,7 @@ final class BaselineCompareStats
/**
* @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
* @param array<string, int> $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<string, int>}
*/
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,

View File

@ -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) }}
</x-filament::badge>
@endif
@if ($hasEvidenceGaps)
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
Evidence gaps: {{ $evidenceGapsCountValue }}
</x-filament::badge>
@endif
</div>
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
Top gaps: {{ $evidenceGapsSummary }}
</div>
@endif
</div>
</x-filament::section>
@ -49,7 +84,7 @@ class="w-fit"
@if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
@else
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasCoverageWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}">
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}">
{{ $findingsCount ?? 0 }}
</div>
@endif
@ -58,10 +93,12 @@ class="w-fit"
<x-filament::loading-indicator class="h-3 w-3" />
Comparing…
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings)
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings)
<span class="text-sm text-success-600 dark:text-success-400">All clear</span>
@elseif ($state === 'ready' && $hasCoverageWarnings)
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
@elseif ($state === 'ready' && $hasEvidenceGaps)
<span class="text-sm text-warning-600 dark:text-warning-400">Evidence gaps</span>
@endif
</div>
</x-filament::section>

View File

@ -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 dont 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 dont 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)**:

View File

@ -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',

View File

@ -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);
});

View File

@ -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' => [

View File

@ -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',

View File

@ -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,

View File

@ -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(),
]);

View File

@ -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(),
]);