where('tenant_id', (int) $tenant->getKey()) ->where('policy_id', (int) $policy->getKey()) ->max('version_number'); $startingVersion = is_numeric($startingVersion) ? (int) $startingVersion : 0; $baselineVersionNumber = $startingVersion + 1; $currentVersionNumber = $startingVersion + 2; PolicyVersion::factory()->for($tenant)->for($policy)->create([ 'version_number' => $baselineVersionNumber, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => $baseline->finished_at->copy()->subMinute(), 'snapshot' => $baselineSnapshot, 'assignments' => [], ]); PolicyVersion::factory()->for($tenant)->for($policy)->create([ 'version_number' => $currentVersionNumber, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => $current->finished_at->copy()->subMinute(), 'snapshot' => $currentSnapshot, 'assignments' => [], ]); } it('reopens a resolved drift finding on recurrence and resets due_at', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $scopeKey = hash('sha256', 'scope-drift-recurrence-reopen'); $policy = Policy::factory()->for($tenant)->create([ 'external_id' => 'policy-recur-1', 'policy_type' => 'deviceConfiguration', 'platform' => 'windows10', ]); $baseline1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), ]); $current1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline1, current: $current1, baselineSnapshot: ['setting' => 'old'], currentSnapshot: ['setting' => 'new'], ); $generator = app(DriftFindingGenerator::class); $created1 = $generator->generate($tenant, $baseline1, $current1, $scopeKey); expect($created1)->toBe(1); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) ->where('subject_type', 'policy') ->first(); $tenantId = (int) $tenant->getKey(); $expectedRecurrenceKey = hash( 'sha256', sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, (string) $policy->external_id), ); expect($finding)->not->toBeNull() ->and($finding->recurrence_key)->toBe($expectedRecurrenceKey) ->and($finding->fingerprint)->toBe($expectedRecurrenceKey) ->and($finding->status)->toBe(Finding::STATUS_NEW) ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') ->and($finding->times_seen)->toBe(1) ->and($finding->sla_days)->toBe(14) ->and($finding->due_at?->toIso8601String())->toBe('2026-03-07T00:00:00+00:00'); $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), 'resolved_reason' => 'fixed', ])->save(); $baseline2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), ]); $current2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline2, current: $current2, baselineSnapshot: ['setting' => 'old-again'], currentSnapshot: ['setting' => 'new-again'], ); $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); expect($created2)->toBe(0); $finding->refresh(); expect(Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('recurrence_key', $expectedRecurrenceKey) ->count())->toBe(1); expect($finding->status)->toBe(Finding::STATUS_REOPENED) ->and($finding->reopened_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') ->and($finding->resolved_at)->toBeNull() ->and($finding->resolved_reason)->toBeNull() ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') ->and($finding->times_seen)->toBe(2) ->and($finding->sla_days)->toBe(14) ->and($finding->due_at?->toIso8601String())->toBe('2026-03-11T00:00:00+00:00') ->and((int) $finding->baseline_operation_run_id)->toBe((int) $baseline2->getKey()) ->and((int) $finding->current_operation_run_id)->toBe((int) $current2->getKey()); }); it('keeps closed drift findings terminal on recurrence but updates seen tracking', function (): void { [, $tenant] = createUserWithTenant(role: 'manager'); $scopeKey = hash('sha256', 'scope-drift-recurrence-closed-terminal'); $policy = Policy::factory()->for($tenant)->create([ 'external_id' => 'policy-recur-2', 'policy_type' => 'deviceConfiguration', 'platform' => 'windows10', ]); $baseline1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), ]); $current1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline1, current: $current1, baselineSnapshot: ['setting' => 'old'], currentSnapshot: ['setting' => 'new'], ); $generator = app(DriftFindingGenerator::class); $generator->generate($tenant, $baseline1, $current1, $scopeKey); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) ->where('subject_type', 'policy') ->firstOrFail(); $initialDueAt = $finding->due_at; $finding->forceFill([ 'status' => Finding::STATUS_CLOSED, 'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), 'closed_reason' => 'accepted', ])->save(); $baseline2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), ]); $current2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline2, current: $current2, baselineSnapshot: ['setting' => 'old-again'], currentSnapshot: ['setting' => 'new-again'], ); $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); expect($created2)->toBe(0); $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_CLOSED) ->and($finding->reopened_at)->toBeNull() ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') ->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'); $scopeKey = hash('sha256', 'scope-drift-recurrence-concurrency'); $policy = Policy::factory()->for($tenant)->create([ 'external_id' => 'policy-recur-3', 'policy_type' => 'deviceConfiguration', 'platform' => 'windows10', ]); $baseline1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), ]); $current1 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline1, current: $current1, baselineSnapshot: ['setting' => 'old'], currentSnapshot: ['setting' => 'new'], ); $generator = app(DriftFindingGenerator::class); $generator->generate($tenant, $baseline1, $current1, $scopeKey); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('scope_key', $scopeKey) ->where('subject_type', 'policy') ->firstOrFail(); $initialDueAt = $finding->due_at; $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'), 'resolved_reason' => 'manual', ])->save(); $baseline2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), ]); $current2 = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, 'selection_payload' => ['policy_types' => [$policy->policy_type]], 'status' => 'success', 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), ]); seedPolicySnapshotVersions( tenant: $tenant, policy: $policy, baseline: $baseline2, current: $current2, baselineSnapshot: ['setting' => 'old-again'], currentSnapshot: ['setting' => 'new-again'], ); $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); expect($created2)->toBe(0); $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('2026-02-25T00:00:00+00:00') ->and($finding->times_seen)->toBe(2) ->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String()); });