> $driftResults * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ function invokeBaselineCompareUpsertFindings( CompareBaselineToTenantJob $job, Tenant $tenant, BaselineProfile $profile, string $scopeKey, array $driftResults, ): array { $reflection = new ReflectionMethod($job, 'upsertFindings'); /** @var array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} $result */ $result = $reflection->invoke($job, $tenant, $profile, $scopeKey, $driftResults); return $result; } /** * @return array */ function baselineCompareDriftItem( int $baselineProfileId, int $compareOperationRunId, string $subjectExternalId, string $subjectKey, string $changeType = 'different_version', string $severity = Finding::SEVERITY_MEDIUM, ): array { return [ 'change_type' => $changeType, 'severity' => $severity, 'subject_type' => 'policy', 'subject_external_id' => $subjectExternalId, 'subject_key' => $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => 'baseline', 'current_hash' => 'current', 'evidence_fidelity' => 'meta', 'evidence' => [ 'change_type' => $changeType, 'policy_type' => 'deviceConfiguration', 'subject_key' => $subjectKey, 'summary' => [ 'kind' => 'policy_snapshot', ], 'baseline' => [ 'policy_version_id' => null, 'hash' => 'baseline', ], 'current' => [ 'policy_version_id' => null, 'hash' => 'current', ], 'fidelity' => 'meta', 'provenance' => [ 'baseline_profile_id' => $baselineProfileId, 'baseline_snapshot_id' => 1, 'compare_operation_run_id' => $compareOperationRunId, 'inventory_sync_run_id' => null, ], ], ]; } /** * @return array */ function baselineCompareRbacDriftItem( int $baselineProfileId, int $compareOperationRunId, string $subjectExternalId, string $subjectKey, string $changeType = 'different_version', string $severity = Finding::SEVERITY_MEDIUM, string $diffFingerprint = 'rbac-diff-1', ): array { return [ 'change_type' => $changeType, 'severity' => $severity, 'subject_type' => 'policy', 'subject_external_id' => $subjectExternalId, 'subject_key' => $subjectKey, 'policy_type' => 'intuneRoleDefinition', 'baseline_hash' => 'baseline', 'current_hash' => 'current', 'evidence_fidelity' => 'content', 'evidence' => [ 'change_type' => $changeType, 'policy_type' => 'intuneRoleDefinition', 'subject_key' => $subjectKey, 'display_name' => 'Security Reader', 'summary' => [ 'kind' => 'rbac_role_definition', ], 'baseline' => [ 'policy_version_id' => 10, 'hash' => 'baseline', ], 'current' => [ 'policy_version_id' => 11, 'hash' => 'current', ], 'rbac_role_definition' => [ 'diff_kind' => 'permission_change', 'diff_fingerprint' => $diffFingerprint, 'changed_keys' => ['Permission block 1 > Allowed actions'], 'metadata_keys' => [], 'permission_keys' => ['Permission block 1 > Allowed actions'], 'baseline' => [ 'normalized' => [ 'Role definition > Display name' => 'Security Reader', 'Permission block 1 > Allowed actions' => ['microsoft.intune/devices/read'], ], 'is_built_in' => false, 'role_permission_count' => 1, ], 'current' => [ 'normalized' => [ 'Role definition > Display name' => 'Security Reader', 'Permission block 1 > Allowed actions' => ['microsoft.intune/devices/read', 'microsoft.intune/devices/delete'], ], 'is_built_in' => false, 'role_permission_count' => 1, ], ], 'fidelity' => 'content', 'provenance' => [ 'baseline_profile_id' => $baselineProfileId, 'baseline_snapshot_id' => 1, 'compare_operation_run_id' => $compareOperationRunId, 'inventory_sync_run_id' => null, ], ], ]; } it('reopens a resolved baseline compare drift finding on recurrence and resets due_at', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); $observedAt1 = CarbonImmutable::parse('2026-02-21T00:00:00Z'); CarbonImmutable::setTestNow($observedAt1); $run1 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job1 = new CompareBaselineToTenantJob($run1); $upsert1 = invokeBaselineCompareUpsertFindings( job: $job1, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run1->getKey(), subjectExternalId: 'policy-recur-1', subjectKey: 'policy-recur-1', ), ], ); expect($upsert1['created_count'])->toBe(1); $finding = Finding::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) ->firstOrFail(); $slaPolicy = app(FindingSlaPolicy::class); $expectedSlaDays1 = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); $expectedDueAt1 = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $observedAt1); expect($finding->status)->toBe(Finding::STATUS_NEW) ->and($finding->first_seen_at?->toIso8601String())->toBe($observedAt1->toIso8601String()) ->and($finding->last_seen_at?->toIso8601String())->toBe($observedAt1->toIso8601String()) ->and($finding->times_seen)->toBe(1) ->and($finding->sla_days)->toBe($expectedSlaDays1) ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt1->toIso8601String()); $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), 'resolved_reason' => 'fixed', ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); CarbonImmutable::setTestNow($observedAt2); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job2 = new CompareBaselineToTenantJob($run2); $upsert2 = invokeBaselineCompareUpsertFindings( job: $job2, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run2->getKey(), subjectExternalId: 'policy-recur-1', subjectKey: 'policy-recur-1', ), ], ); expect($upsert2['reopened_count'])->toBe(1); $finding->refresh(); $expectedSlaDays2 = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); $expectedDueAt2 = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $observedAt2); expect($finding->status)->toBe(Finding::STATUS_REOPENED) ->and($finding->reopened_at?->toIso8601String())->toBe($observedAt2->toIso8601String()) ->and($finding->resolved_at)->toBeNull() ->and($finding->resolved_reason)->toBeNull() ->and($finding->first_seen_at?->toIso8601String())->toBe($observedAt1->toIso8601String()) ->and($finding->last_seen_at?->toIso8601String())->toBe($observedAt2->toIso8601String()) ->and($finding->times_seen)->toBe(2) ->and($finding->sla_days)->toBe($expectedSlaDays2) ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt2->toIso8601String()) ->and((int) $finding->current_operation_run_id)->toBe((int) $run2->getKey()); }); it('keeps closed baseline compare drift findings terminal on recurrence but updates seen tracking', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); $observedAt1 = CarbonImmutable::parse('2026-02-21T00:00:00Z'); CarbonImmutable::setTestNow($observedAt1); $run1 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job1 = new CompareBaselineToTenantJob($run1); invokeBaselineCompareUpsertFindings( job: $job1, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run1->getKey(), subjectExternalId: 'policy-recur-2', subjectKey: 'policy-recur-2', ), ], ); $finding = Finding::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) ->firstOrFail(); $initialDueAt = $finding->due_at; $finding->forceFill([ 'status' => Finding::STATUS_CLOSED, 'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), 'closed_reason' => 'accepted', ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); CarbonImmutable::setTestNow($observedAt2); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job2 = new CompareBaselineToTenantJob($run2); $upsert2 = invokeBaselineCompareUpsertFindings( job: $job2, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run2->getKey(), subjectExternalId: 'policy-recur-2', subjectKey: 'policy-recur-2', ), ], ); expect($upsert2['reopened_count'])->toBe(0) ->and($upsert2['unchanged_count'])->toBe(1); $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_CLOSED) ->and($finding->reopened_at)->toBeNull() ->and($finding->last_seen_at?->toIso8601String())->toBe($observedAt2->toIso8601String()) ->and($finding->times_seen)->toBe(2) ->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String()); }); it('does not auto-reopen when resolved_at is after the observation time but advances seen counters', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); $observedAt1 = CarbonImmutable::parse('2026-02-21T00:00:00Z'); CarbonImmutable::setTestNow($observedAt1); $run1 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job1 = new CompareBaselineToTenantJob($run1); invokeBaselineCompareUpsertFindings( job: $job1, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run1->getKey(), subjectExternalId: 'policy-recur-3', subjectKey: 'policy-recur-3', ), ], ); $finding = Finding::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) ->firstOrFail(); $initialDueAt = $finding->due_at; $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'), 'resolved_reason' => 'manual', ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); CarbonImmutable::setTestNow($observedAt2); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $job2 = new CompareBaselineToTenantJob($run2); $upsert2 = invokeBaselineCompareUpsertFindings( job: $job2, tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run2->getKey(), subjectExternalId: 'policy-recur-3', subjectKey: 'policy-recur-3', ), ], ); expect($upsert2['reopened_count'])->toBe(0) ->and($upsert2['unchanged_count'])->toBe(1); $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->reopened_at)->toBeNull() ->and($finding->resolved_at?->toIso8601String())->toBe('2026-02-26T00:00:00+00:00') ->and($finding->last_seen_at?->toIso8601String())->toBe($observedAt2->toIso8601String()) ->and($finding->times_seen)->toBe(2) ->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String()); }); it('reopens resolved intune role definition findings without changing their recurrence fingerprint', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); $observedAt1 = CarbonImmutable::parse('2026-02-21T00:00:00Z'); CarbonImmutable::setTestNow($observedAt1); $run1 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); invokeBaselineCompareUpsertFindings( job: new CompareBaselineToTenantJob($run1), tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareRbacDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run1->getKey(), subjectExternalId: 'rbac-role-1', subjectKey: 'rbac-role-1', severity: Finding::SEVERITY_HIGH, diffFingerprint: 'rbac-diff-a', ), ], ); $finding = Finding::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) ->firstOrFail(); $fingerprint = (string) $finding->fingerprint; $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), 'resolved_reason' => 'fixed', ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); CarbonImmutable::setTestNow($observedAt2); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'baseline_compare', ]); $upsert = invokeBaselineCompareUpsertFindings( job: new CompareBaselineToTenantJob($run2), tenant: $tenant, profile: $profile, scopeKey: $scopeKey, driftResults: [ baselineCompareRbacDriftItem( baselineProfileId: (int) $profile->getKey(), compareOperationRunId: (int) $run2->getKey(), subjectExternalId: 'rbac-role-1', subjectKey: 'rbac-role-1', severity: Finding::SEVERITY_HIGH, diffFingerprint: 'rbac-diff-b', ), ], ); expect($upsert['reopened_count'])->toBe(1); $finding->refresh(); expect((string) $finding->fingerprint)->toBe($fingerprint) ->and((string) $finding->recurrence_key)->toBe($fingerprint) ->and($finding->status)->toBe(Finding::STATUS_REOPENED) ->and($finding->times_seen)->toBe(2) ->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->toBe('rbac-diff-b'); });