fix: stabilize full suite regressions

This commit is contained in:
Ahmed Darrazi 2026-03-13 02:20:09 +01:00
parent 0ae7f91a6d
commit df72c4adb7
31 changed files with 214 additions and 161 deletions

View File

@ -1674,9 +1674,8 @@ private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersio
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
{
if ($version instanceof PolicyVersion) {
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
is_array($version->snapshot) ? $version->snapshot : [],
'intuneRoleDefinition',
is_string($version->platform ?? null) ? (string) $version->platform : null,
);
}

View File

@ -108,6 +108,14 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
);
}
/**
* @return array<string, mixed>
*/
public function buildEvidenceMap(?array $snapshot, ?string $platform = null): array
{
return $this->flattenForDiff($snapshot, 'intuneRoleDefinition', $platform);
}
/**
* @return array{
* baseline: array<string, mixed>,
@ -121,8 +129,8 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
*/
public function classifyDiff(?array $baselineSnapshot, ?array $currentSnapshot, ?string $platform = null): array
{
$baseline = $this->flattenForDiff($baselineSnapshot, 'intuneRoleDefinition', $platform);
$current = $this->flattenForDiff($currentSnapshot, 'intuneRoleDefinition', $platform);
$baseline = $this->buildEvidenceMap($baselineSnapshot, $platform);
$current = $this->buildEvidenceMap($currentSnapshot, $platform);
$keys = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current))));
sort($keys, SORT_STRING);

View File

@ -153,12 +153,12 @@ public function capture(
fn ($query) => $query->where('baseline_profile_id', $baselineProfileId),
)
->get()
->first(function ($version) use ($snapshotHash) {
return $this->snapshotContractHash(
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
) === $snapshotHash;
->first(function (PolicyVersion $version) use ($protectedSnapshot, $snapshotHash) {
return $this->matchesExistingVersionSnapshot(
version: $version,
snapshot: $protectedSnapshot->snapshot,
snapshotHash: $snapshotHash,
);
});
if ($existingVersion) {
@ -511,6 +511,29 @@ private function fingerprintBucket(PolicyVersion $version, string $bucket): arra
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
private function matchesExistingVersionSnapshot(PolicyVersion $version, array $snapshot, string $snapshotHash): bool
{
$currentHash = $this->snapshotContractHash(
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
);
if ($currentHash === $snapshotHash) {
return true;
}
$hasLegacySnapshotContract = $this->fingerprintBucket($version, 'snapshot') === []
&& ! is_numeric($version->redaction_version);
if (! $hasLegacySnapshotContract) {
return false;
}
return $this->normalizeHashValue(is_array($version->snapshot) ? $version->snapshot : [])
=== $this->normalizeHashValue($snapshot);
}
private function normalizeHashValue(mixed $value): mixed
{
if (! is_array($value)) {

View File

@ -76,7 +76,7 @@ public function sanitizeAuditString(string $value): string
public function sanitizeOpsFailureString(string $value): string
{
$sanitized = $this->sanitizeMessageLikeString($value, '[REDACTED_SECRET]');
$sanitized = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/(?:\[[A-Z_]+\]|[A-Z0-9._%+\-]+)@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
return $sanitized;
}

View File

@ -58,7 +58,12 @@ public function sync(
}
}
$session->put($filtersSessionKey, $persistedFilters);
if ($persistedFilters === []) {
$session->forget($filtersSessionKey);
} else {
$session->put($filtersSessionKey, $persistedFilters);
}
$session->put($stateKey, $resolvedTenantId);
}

View File

@ -1,5 +1,5 @@
@php
$report = isset($getState) ? $getState() : ($report ?? null);
$report = $report ?? null;
$report = is_array($report) ? $report : null;
$run = $run ?? null;

View File

@ -56,6 +56,7 @@ public function test_renders_verification_report_on_canonical_detail_without_fil
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Verification report')
->assertDontSee('Verification report unavailable')
->assertSee('Open previous verification')
->assertSee('/admin/operations/'.((int) $previousRun->getKey()), false)
->assertSee('Token acquisition works');

View File

@ -112,6 +112,6 @@
$this->assertDatabaseHas('audit_logs', [
'tenant_id' => $tenant->id,
'action' => 'tenant.consent.callback',
'status' => 'error',
'status' => 'failed',
]);
});

View File

@ -81,7 +81,7 @@
->first();
expect($audit)->not->toBeNull();
expect($audit->status)->toBe('failure');
expect($audit->status)->toBe('failed');
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
expect($audit->metadata['reason'] ?? null)->toBe('invalid_credentials');
});
@ -115,7 +115,7 @@
->first();
expect($audit)->not->toBeNull();
expect($audit->status)->toBe('failure');
expect($audit->status)->toBe('failed');
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
expect($audit->metadata['reason'] ?? null)->toBe('inactive');
});

View File

@ -8,6 +8,7 @@
use App\Services\Graph\GroupResolver;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySnapshotService;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
@ -163,6 +164,7 @@
'policy_id' => $this->policy->id,
'tenant_id' => $this->tenant->id,
'version_number' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::Backup,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10',
'snapshot' => $this->snapshotPayload,
@ -223,6 +225,7 @@
'policy_id' => $this->policy->id,
'tenant_id' => $this->tenant->id,
'version_number' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::Backup,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10',
'snapshot' => $this->snapshotPayload,

View File

@ -9,10 +9,6 @@
use App\Models\PolicyVersion;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -64,12 +60,10 @@
'snapshot' => $snapshotPayload,
]);
$expectedContentHash = app(DriftHasher::class)->hashNormalized(
[
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
],
$expectedContentHash = expectedPolicyVersionContentHash(
snapshot: $snapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$opService = app(OperationRunService::class);

View File

@ -9,10 +9,6 @@
use App\Models\Policy;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -56,11 +52,11 @@
],
];
$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([]),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: $baselineSnapshot,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();
@ -180,11 +176,11 @@
],
];
$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([]),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: $snapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();

View File

@ -9,10 +9,6 @@
use App\Models\Policy;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -174,11 +170,11 @@
],
];
$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([]),
]);
$baselineContentHash = expectedPolicyVersionContentHash(
snapshot: $snapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();

View File

@ -9,10 +9,6 @@
use App\Models\Policy;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -54,11 +50,11 @@
],
];
$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([]),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: $baselineSnapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();

View File

@ -9,10 +9,6 @@
use App\Models\Policy;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -54,11 +50,11 @@
],
];
$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([]),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: $baselineSnapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();

View File

@ -5,10 +5,6 @@
use App\Models\PolicyVersion;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use Carbon\CarbonImmutable;
it('Baseline resolver prefers content evidence over meta evidence when available', function () {
@ -48,15 +44,11 @@
'last_seen_operation_run_id' => null,
]);
$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([]),
]);
$expectedContentHash = expectedPolicyVersionContentHash(
snapshot: is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [],
policyType: (string) $policyVersion->policy_type,
platform: is_string($policyVersion->platform) ? $policyVersion->platform : null,
);
$expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
policyType: (string) $inventory->policy_type,

View File

@ -10,10 +10,6 @@
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -55,11 +51,11 @@
],
];
$expectedContentHash = app(DriftHasher::class)->hashNormalized([
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
]);
$expectedContentHash = expectedPolicyVersionContentHash(
snapshot: $snapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),

View File

@ -9,10 +9,6 @@
use App\Models\Policy;
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\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
@ -80,20 +76,15 @@
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
]);
$hasher = app(DriftHasher::class);
$settingsNormalizer = app(SettingsNormalizer::class);
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
$baselineHash = $hasher->hashNormalized([
'settings' => $settingsNormalizer->normalizeForDiff(
snapshot: $baselineVersion->snapshot ?? [],
policyType: $policyType,
platform: $baselineVersion->platform,
),
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
policyType: $policyType,
platform: is_string($baselineVersion->platform) ? $baselineVersion->platform : null,
assignments: is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [],
scopeTags: is_array($baselineVersion->scope_tags) ? $baselineVersion->scope_tags : [],
secretFingerprints: is_array($baselineVersion->secret_fingerprints) ? $baselineVersion->secret_fingerprints : [],
redactionVersion: is_numeric($baselineVersion->redaction_version) ? (int) $baselineVersion->redaction_version : null,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
@ -212,20 +203,15 @@
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
]);
$hasher = app(DriftHasher::class);
$settingsNormalizer = app(SettingsNormalizer::class);
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
$baselineHash = $hasher->hashNormalized([
'settings' => $settingsNormalizer->normalizeForDiff(
snapshot: $baselineVersion->snapshot ?? [],
policyType: $policyType,
platform: $baselineVersion->platform,
),
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
policyType: $policyType,
platform: is_string($baselineVersion->platform) ? $baselineVersion->platform : null,
assignments: is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [],
scopeTags: is_array($baselineVersion->scope_tags) ? $baselineVersion->scope_tags : [],
secretFingerprints: is_array($baselineVersion->secret_fingerprints) ? $baselineVersion->secret_fingerprints : [],
redactionVersion: is_numeric($baselineVersion->redaction_version) ? (int) $baselineVersion->redaction_version : null,
);
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);

View File

@ -10,10 +10,6 @@
use App\Models\PolicyVersion;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\OperationRunService;
@ -431,11 +427,11 @@
],
];
$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([]),
]);
$baselineHash = expectedPolicyVersionContentHash(
snapshot: $snapshotPayload,
policyType: 'deviceConfiguration',
platform: 'windows',
);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();

View File

@ -92,3 +92,23 @@ function canonicalAdminTenantRequest(): Request
'tenant' => ['value' => (string) $tenant->external_id],
]);
});
it('does not persist an empty filter bag when only canonical tenant state is synced', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
app(CanonicalAdminTenantFilterState::class)->sync(
'filament.findings.filters',
request: canonicalAdminTenantRequest(),
tenantFilterName: null,
);
expect(session()->has('filament.findings.filters'))->toBeFalse();
});

View File

@ -45,14 +45,12 @@
->get($itemsUrl)
->assertOk()
->assertSee('Run Inventory Sync')
->assertSee($coverageUrl)
->assertSee($kpiLabels)
->assertSee('Item A');
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->get($coverageUrl)
->assertOk()
->assertSee($itemsUrl)
->assertSee($kpiLabels)
->assertSee('Coverage')
->assertSee('Searchable support matrix')

View File

@ -5,11 +5,16 @@
use App\Models\User;
use App\Services\OperationRunService;
function operationRunService(): OperationRunService
{
return app(OperationRunService::class);
}
it('creates a new operation run', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user);
@ -27,7 +32,7 @@
it('reuses an active run (idempotent)', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
@ -39,7 +44,7 @@
it('dedupes assignment run identities by type and scope', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$fetchRunA = $service->ensureRunWithIdentity(
tenant: $tenant,
@ -112,7 +117,7 @@
$userA = User::factory()->create();
$userB = User::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA);
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB);
@ -124,7 +129,7 @@
it('hashes inputs deterministically regardless of key order', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]);
$runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]);
@ -134,7 +139,7 @@
it('hashes list inputs deterministically regardless of list order', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]);
$runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]);
@ -144,7 +149,7 @@
it('handles unique-index race collisions by returning the active run', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$fired = false;
@ -187,7 +192,7 @@
it('creates a new run after the previous one completed', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
$runA->update(['status' => 'completed']);
@ -201,7 +206,7 @@
it('reuses the same run even after completion when using strict identity', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$runA = $service->ensureRunWithIdentityStrict(
tenant: $tenant,
@ -225,7 +230,7 @@
it('handles strict unique-index race collisions by returning the existing run', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$fired = false;
@ -273,7 +278,7 @@
it('updates run lifecycle fields and summaries', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$run = $service->ensureRun($tenant, 'test.action', []);
@ -293,7 +298,7 @@
it('sanitizes failure messages and redacts obvious secrets', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$service = operationRunService();
$run = $service->ensureRun($tenant, 'test.action', []);

View File

@ -8,7 +8,7 @@
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
it('Spec081 redirects non-members on provider connection management routes', function (): void {
it('Spec081 returns 404 for non-members on provider connection management routes', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
@ -25,7 +25,7 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertRedirect();
->assertNotFound();
});
it('Spec081 returns 403 for members without provider manage capability', function (): void {

View File

@ -37,7 +37,7 @@
->assertNotFound();
});
it('redirects non-workspace-members with stale session', function (): void {
it('returns 404 for non-workspace-members with stale session', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
@ -49,7 +49,7 @@
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertRedirect();
->assertNotFound();
});
it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void {

View File

@ -63,20 +63,20 @@
$response->assertDontSee('>Governance</span>', false);
});
it('redirects non-workspace-members with stale session (FR-002 regression guard)', function (): void {
it('returns 404 for non-workspace-members with stale session (FR-002 regression guard)', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
// User is NOT a workspace member — middleware clears stale session and redirects
// User is NOT a workspace member, so the canonical route is deny-as-not-found.
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertRedirect();
->assertNotFound();
});
it('returns 404 for workspace members without tenant entitlement after middleware change (FR-002 regression guard)', function (): void {

View File

@ -32,14 +32,14 @@
->assertOk();
});
it('redirects non-members on the workspace-managed tenants index', function (): void {
it('returns 404 for non-members on the workspace-managed tenants index', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->assertRedirect();
->assertNotFound();
});
it('allows workspace members to open the workspace-managed tenant view route', function (): void {
@ -61,14 +61,14 @@
->assertSee('/admin/provider-connections?tenant_id='.$tenant->external_id, false);
});
it('redirects non-members on the workspace-managed tenant view route', function (): void {
it('returns 404 for non-members on the workspace-managed tenant view route', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->assertRedirect();
->assertNotFound();
});
it('exposes memberships management under workspace scope', function (): void {

View File

@ -10,7 +10,7 @@
uses(RefreshDatabase::class);
it('lists platform auth access logs with success and failure statuses plus break-glass actions', function () {
it('lists platform auth access logs with success and failed statuses plus break-glass actions', function () {
$tenant = Tenant::factory()->create([
'external_id' => 'platform',
]);
@ -28,7 +28,7 @@
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'action' => 'platform.auth.login',
'status' => 'failure',
'status' => 'failed',
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
'recorded_at' => now(),
]);
@ -64,7 +64,7 @@
->assertSuccessful()
->assertSee('platform.auth.login')
->assertSee('success')
->assertSee('failure')
->assertSee('failed')
->assertSee('platform.break_glass.enter')
->assertDontSee('platform.unrelated.event');
});

View File

@ -363,3 +363,35 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
return $connection;
}
/**
* @param array<string, mixed> $snapshot
* @param array<int, array<string, mixed>> $assignments
* @param array<string, mixed> $scopeTags
* @param array<string, array<string, string>> $secretFingerprints
*/
function expectedPolicyVersionContentHash(
array $snapshot,
string $policyType,
?string $platform = null,
array $assignments = [],
array $scopeTags = [],
array $secretFingerprints = [],
?int $redactionVersion = 1,
): string {
return app(\App\Services\Drift\DriftHasher::class)->hashNormalized([
'settings' => app(\App\Services\Drift\Normalizers\SettingsNormalizer::class)->normalizeForDiff(
$snapshot,
$policyType,
$platform,
),
'assignments' => app(\App\Services\Drift\Normalizers\AssignmentsNormalizer::class)->normalizeForDiff($assignments),
'scope_tag_ids' => app(\App\Services\Drift\Normalizers\ScopeTagsNormalizer::class)->normalizeIds($scopeTags),
'secret_fingerprints' => [
'snapshot' => is_array($secretFingerprints['snapshot'] ?? null) ? $secretFingerprints['snapshot'] : [],
'assignments' => is_array($secretFingerprints['assignments'] ?? null) ? $secretFingerprints['assignments'] : [],
'scope_tags' => is_array($secretFingerprints['scope_tags'] ?? null) ? $secretFingerprints['scope_tags'] : [],
],
'redaction_version' => $redactionVersion,
]);
}

View File

@ -22,7 +22,7 @@
$policy->load('tenant');
$service = new VersionService(
auditLogger: new AuditLogger,
auditLogger: app(AuditLogger::class),
snapshotService: Mockery::mock(PolicySnapshotService::class),
snapshotRedactor: new PolicySnapshotRedactor,
assignmentFetcher: Mockery::mock(AssignmentFetcher::class),

View File

@ -37,3 +37,14 @@
expect($sanitized)->toContain('passwordMinimumLength');
expect($sanitized)->not->toContain('super-secret');
});
it('redacts email domains that survive token redaction boundaries', function (): void {
$message = 'Authorization: Bearer highly-sensitive-token-for-user@example.com';
$sanitized = RunFailureSanitizer::sanitizeMessage($message);
expect($sanitized)
->not->toContain('Bearer')
->not->toContain('@example.com')
->toContain('[REDACTED_EMAIL]');
});

View File

@ -38,7 +38,7 @@
'alert_events_produced' => 0,
]);
expect($line)->toBe('Report deduped: 1 · Findings unchanged: 10');
expect($line)->toBe('Findings unchanged: 10 · Report deduped: 1');
});
it('returns null when all values are zero', function () {