create(); [$user] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->create(); $this->actingAs($user) ->get(FindingResource::getUrl('index', tenant: $tenant)) ->assertNotFound(); $this->actingAs($user) ->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) ->assertNotFound(); }); it('shows triage row action disabled for readonly members', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); Filament::setTenant($tenant, true); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_NEW, ]); Livewire::test(ListFindings::class) ->assertTableActionVisible('triage', $finding) ->assertTableActionDisabled('triage', $finding); }); it('enforces 404 for non-member and 403 for member missing capability in workflow service', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); $outsider = \App\Models\User::factory()->create(); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_NEW, ]); $service = app(FindingWorkflowService::class); expect(fn () => $service->triage($finding, $tenant, $outsider)) ->toThrow(NotFoundHttpException::class); expect(fn () => $service->triage($finding, $tenant, $readonly)) ->toThrow(AuthorizationException::class); $triaged = $service->triage($finding, $tenant, $owner); expect($triaged->status)->toBe(Finding::STATUS_TRIAGED); }); it('returns 404 and mutates nothing when a forged foreign-tenant finding action is mounted', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); Filament::setTenant($tenant, true); $foreignTenant = \App\Models\Tenant::factory()->create(); $foreignFinding = Finding::factory()->for($foreignTenant)->create([ 'status' => Finding::STATUS_NEW, ]); $component = Livewire::test(ListFindings::class); expect(fn () => $component->instance()->mountTableAction('triage', (string) $foreignFinding->getKey())) ->toThrow(NotFoundHttpException::class); expect($foreignFinding->fresh()?->status)->toBe(Finding::STATUS_NEW); }); it('denies finding view and triage as not found when tenant context workspace does not match the record workspace', function (): void { $tenant = \App\Models\Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $finding = Finding::make([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id + 999, 'status' => Finding::STATUS_NEW, ]); $this->actingAs($user); Filament::setCurrentPanel('admin'); Filament::setTenant(null, true); Filament::bootCurrentPanel(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ (string) $tenant->workspace_id => (int) $tenant->getKey(), ]); expect(Gate::forUser($user)->allows('view', $finding))->toBeFalse(); try { Gate::forUser($user)->authorize('triage', $finding); $this->fail('Expected workspace-mismatched finding authorization to be denied as not found.'); } catch (AuthorizationException $exception) { expect($exception->status())->toBe(404); } }); it('renders finding detail with shared normalized diff markers for entitled members', function (): void { bindFailHardGraphClient(); [$user, $tenant] = createUserWithTenant(role: 'owner'); $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => hash('sha256', 'finding-rbac-shared-diff'), 'status' => 'success', 'finished_at' => now()->subDays(2), ]); $current = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $baseline->selection_hash, 'status' => 'success', 'finished_at' => now()->subDay(), ]); $policy = Policy::factory()->for($tenant)->create([ 'external_id' => 'policy-finding-rbac', 'policy_type' => 'deviceConfiguration', 'platform' => 'windows10', ]); $baselineVersion = PolicyVersion::factory()->for($tenant)->create([ 'policy_id' => $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'snapshot' => [ 'displayName' => 'RBAC Policy', 'customSettingFoo' => 'Old value', ], ]); $currentVersion = PolicyVersion::factory()->for($tenant)->create([ 'policy_id' => $policy->getKey(), 'version_number' => 2, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'snapshot' => [ 'displayName' => 'RBAC Policy', 'customSettingFoo' => 'New value', ], ]); $finding = Finding::factory()->for($tenant)->create([ 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'scope_key' => (string) $current->selection_hash, 'baseline_operation_run_id' => $baseline->getKey(), 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'policy', 'subject_external_id' => $policy->external_id, 'evidence_jsonb' => [ 'change_type' => 'modified', 'summary' => [ 'kind' => 'policy_snapshot', 'changed_fields' => ['snapshot_hash'], ], 'baseline' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $baselineVersion->getKey(), 'snapshot_hash' => 'baseline-hash', ], 'current' => [ 'policy_id' => $policy->external_id, 'policy_version_id' => $currentVersion->getKey(), 'snapshot_hash' => 'current-hash', ], ], ]); InventoryItem::factory()->for($tenant)->create([ 'external_id' => $finding->subject_external_id, 'display_name' => 'RBAC Policy', ]); $response = $this->actingAs($user) ->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)); $response->assertSuccessful()->assertSee('Normalized diff'); expect($response->getContent()) ->toContain('data-shared-detail-family="normalized-diff"') ->toContain('data-shared-normalized-diff-host="finding"'); });