## Summary - enforce the canonical workspace/environment scope contract for workspace hubs and environment-owned surfaces - replace first-party Operations deep links that leaked Filament `tableFilters[...]` internals with stable product-level query behavior - add the sidebar scope indicator and split environment-page navigation into explicit `Workspace-wide` and `Workspace admin` groups - remove redundant tenantless `All environments` scope badges from workspace-wide pages while preserving explicit environment filter affordances - include the Spec 338 artifacts, guard tests, and browser smoke coverage for the new contract ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php tests/Feature/Filament/PanelNavigationSegregationTest.php` - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec338ScopeContractSmokeTest.php --compact` ## Notes - Livewire v4 compliance unchanged - Filament provider registration remains in `bootstrap/providers.php` - no destructive action behavior changed - no migrations, env var changes, or new Filament asset registration Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #409
486 lines
20 KiB
PHP
486 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\User;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\ReviewPackStatus;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
|
|
pest()->browser()->timeout(60_000);
|
|
|
|
it('Spec329 smokes Evidence Overview proof-first disclosure and filter clearing', function (): void {
|
|
$fixture = spec329BrowserDisclosureFixture();
|
|
spec329AuthenticateDisclosureBrowser($this, $fixture['user'], $fixture['environmentA']);
|
|
|
|
$cleanPath = json_encode((string) parse_url(route('admin.evidence.overview'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
|
|
|
$page = visit(route('admin.evidence.overview'))
|
|
->resize(1440, 1100)
|
|
->waitForText('What proof is available for this scope?')
|
|
->assertDontSee(__('localization.shell.no_environment_selected'))
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Evidence proof workbench')
|
|
->assertSee('Primary proof path')
|
|
->assertSee('Evidence path')
|
|
->assertSee('Evidence snapshot')
|
|
->assertSee('Review pack')
|
|
->assertSee('Stored report / export')
|
|
->assertSee('Operation proof')
|
|
->assertSee('Proof incomplete')
|
|
->assertSee('A proof record exists, but no usable captured evidence is available yet.')
|
|
->assertSee('Primary evidence snapshot is empty.')
|
|
->assertSee('Supporting proof exists through the review pack, stored report, and operation record.')
|
|
->assertSee('Empty')
|
|
->assertSee('Ready')
|
|
->assertSee('Available')
|
|
->assertSourceHas('Search evidence or next step')
|
|
->assertSee('Evidence inventory')
|
|
->assertSee($fixture['environmentA']->name)
|
|
->assertSee($fixture['environmentB']->name)
|
|
->assertDontSee('The artifact row exists, but it does not contain usable captured content.')
|
|
->assertDontSee('artifact row exists')
|
|
->assertSourceMissing('Search tenant or next')
|
|
->assertDontSee('Empty...')
|
|
->assertDontSee('Re...')
|
|
->assertDontSee('raw payload should stay hidden')
|
|
->assertDontSee('provider secret should stay hidden')
|
|
->assertDontSee('stack trace should stay hidden')
|
|
->assertDontSee('debug metadata should stay hidden')
|
|
->assertDontSee('internal exception should stay hidden')
|
|
->assertDontSee('current tenant')
|
|
->assertDontSee('tenant filter')
|
|
->assertDontSee('entitled tenant')
|
|
->assertDontSee('all tenants')
|
|
->assertScript('document.querySelector("[data-testid=\"evidence-disclosure-diagnostics\"]")?.open === false', true)
|
|
->assertScript('(() => {
|
|
const action = document.querySelector("[data-testid=\"evidence-primary-proof-action\"]");
|
|
|
|
if (! action) {
|
|
return false;
|
|
}
|
|
|
|
const box = action.getBoundingClientRect();
|
|
|
|
return action.textContent.trim() === "Open evidence snapshot"
|
|
&& box.height > 0
|
|
&& box.height <= 44
|
|
&& action.scrollWidth <= Math.ceil(action.clientWidth) + 1
|
|
&& getComputedStyle(action).whiteSpace === "nowrap";
|
|
})()', true)
|
|
->assertScript('(() => {
|
|
const badges = Array.from(document.querySelectorAll("[data-testid=\"evidence-path-state-badge\"]"));
|
|
|
|
return badges.length >= 4
|
|
&& badges.every((badge) => {
|
|
const label = badge.textContent.trim();
|
|
const box = badge.getBoundingClientRect();
|
|
|
|
return ! ["Empty...", "Re..."].includes(label)
|
|
&& box.width > 0
|
|
&& badge.scrollWidth <= Math.ceil(badge.clientWidth) + 1;
|
|
});
|
|
})()', true)
|
|
->assertScript('(() => {
|
|
const grid = document.querySelector("[data-testid=\"evidence-disclosure-workbench\"]");
|
|
const main = document.querySelector("[data-testid=\"evidence-proof-primary\"]");
|
|
const aside = document.querySelector("[data-testid=\"evidence-proof-aside\"]");
|
|
|
|
if (! grid || ! main || ! aside) {
|
|
return false;
|
|
}
|
|
|
|
const children = Array.from(grid.children);
|
|
const mainBox = main.getBoundingClientRect();
|
|
const asideBox = aside.getBoundingClientRect();
|
|
|
|
return window.innerWidth >= 1024
|
|
&& grid.classList.contains("lg:grid-cols-[minmax(0,1fr)_22rem]")
|
|
&& aside.tagName === "ASIDE"
|
|
&& children.indexOf(main) !== -1
|
|
&& children.indexOf(aside) > children.indexOf(main)
|
|
&& asideBox.left > mainBox.right
|
|
&& Math.abs(asideBox.top - mainBox.top) <= 8;
|
|
})()', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('evidence-overview--clean'));
|
|
|
|
spec329CopyDisclosureScreenshot('evidence-overview--clean');
|
|
|
|
$page = visit(route('admin.evidence.overview', [
|
|
'environment_id' => (int) $fixture['environmentA']->getKey(),
|
|
]))
|
|
->waitForText('Environment filter:')
|
|
->assertSee('Environment filter: '.$fixture['environmentA']->name)
|
|
->assertSee('What proof is available for this scope?')
|
|
->assertSee($fixture['environmentA']->name)
|
|
->assertSee('Proof incomplete')
|
|
->assertSee('Primary evidence snapshot is empty.')
|
|
->assertDontSee('The artifact row exists, but it does not contain usable captured content.')
|
|
->assertDontSee('Empty...')
|
|
->assertDontSee('Re...')
|
|
->assertDontSee($fixture['environmentB']->name)
|
|
->assertScript('document.querySelector("[data-testid=\"evidence-disclosure-diagnostics\"]")?.open === false', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('evidence-overview--filtered'));
|
|
|
|
spec329CopyDisclosureScreenshot('evidence-overview--filtered');
|
|
|
|
spec329ClearDisclosureEnvironmentFilter($page)
|
|
->waitForText($fixture['environmentB']->name)
|
|
->assertDontSee('Environment filter:')
|
|
->waitForText($fixture['environmentB']->name)
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('evidence-overview--after-clear'));
|
|
|
|
spec329CopyDisclosureScreenshot('evidence-overview--after-clear');
|
|
|
|
$page->script('window.location.reload();');
|
|
|
|
$page
|
|
->waitForText($fixture['environmentB']->name)
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee($fixture['environmentB']->name)
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('evidence-overview--after-reload'));
|
|
|
|
spec329CopyDisclosureScreenshot('evidence-overview--after-reload');
|
|
});
|
|
|
|
it('Spec329 smokes Audit Log event-proof disclosure and filter clearing', function (): void {
|
|
$fixture = spec329BrowserDisclosureFixture();
|
|
spec329AuthenticateDisclosureBrowser($this, $fixture['user'], $fixture['environmentA']);
|
|
|
|
$cleanPath = json_encode((string) parse_url(route('admin.monitoring.audit-log'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
|
|
|
$page = visit(route('admin.monitoring.audit-log', [
|
|
'event' => (int) $fixture['auditA']->getKey(),
|
|
]))
|
|
->resize(1440, 1100)
|
|
->waitForText('Which event proves what happened?')
|
|
->assertDontSee(__('localization.shell.no_environment_selected'))
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Audit proof workbench')
|
|
->assertSee('Selected event proof')
|
|
->assertSee('Event proof')
|
|
->assertSee('Actor')
|
|
->assertSee('Action')
|
|
->assertSee('Target')
|
|
->assertSee('Outcome')
|
|
->assertSee('Time')
|
|
->assertSee('Related proof')
|
|
->assertSee('Operation proof')
|
|
->assertSee('Readable context')
|
|
->assertSee('Audit event history')
|
|
->assertSee('Spec329 Browser Operator A')
|
|
->assertSee('Permission posture checked')
|
|
->assertSee('Permission posture report')
|
|
->assertDontSee('raw payload should stay hidden')
|
|
->assertDontSee('provider secret should stay hidden')
|
|
->assertDontSee('stack trace should stay hidden')
|
|
->assertDontSee('debug metadata should stay hidden')
|
|
->assertDontSee('internal exception should stay hidden')
|
|
->assertDontSee('provider response should stay hidden')
|
|
->assertDontSee('current tenant')
|
|
->assertDontSee('tenant filter')
|
|
->assertDontSee('entitled tenant')
|
|
->assertDontSee('all tenants')
|
|
->assertScript('document.querySelector("[data-testid=\"audit-disclosure-diagnostics\"]")?.open === false', true)
|
|
->assertScript('document.querySelector("[data-testid=\"audit-event-diagnostics\"]")?.open === false', true)
|
|
->assertScript('(() => {
|
|
const grid = document.querySelector("[data-testid=\"audit-disclosure-workbench\"]");
|
|
const main = document.querySelector("[data-testid=\"audit-proof-primary\"]");
|
|
const aside = document.querySelector("[data-testid=\"audit-proof-aside\"]");
|
|
|
|
if (! grid || ! main || ! aside) {
|
|
return false;
|
|
}
|
|
|
|
const children = Array.from(grid.children);
|
|
const mainBox = main.getBoundingClientRect();
|
|
const asideBox = aside.getBoundingClientRect();
|
|
|
|
return window.innerWidth >= 1024
|
|
&& grid.classList.contains("lg:grid-cols-[minmax(0,1fr)_22rem]")
|
|
&& aside.tagName === "ASIDE"
|
|
&& children.indexOf(main) !== -1
|
|
&& children.indexOf(aside) > children.indexOf(main)
|
|
&& asideBox.left > mainBox.right
|
|
&& Math.abs(asideBox.top - mainBox.top) <= 8;
|
|
})()', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('audit-log--clean'));
|
|
|
|
spec329CopyDisclosureScreenshot('audit-log--clean');
|
|
|
|
$page = visit(route('admin.monitoring.audit-log', [
|
|
'environment_id' => (int) $fixture['environmentA']->getKey(),
|
|
'event' => (int) $fixture['auditA']->getKey(),
|
|
]))
|
|
->waitForText('Environment filter:')
|
|
->assertSee('Environment filter: '.$fixture['environmentA']->name)
|
|
->assertSee('Which event proves what happened?')
|
|
->assertSee('Permission posture checked')
|
|
->assertDontSee('Workspace selected by browser proof B')
|
|
->assertScript('document.querySelector("[data-testid=\"audit-disclosure-diagnostics\"]")?.open === false', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('audit-log--filtered'));
|
|
|
|
spec329CopyDisclosureScreenshot('audit-log--filtered');
|
|
|
|
spec329ClearDisclosureEnvironmentFilter($page)
|
|
->waitForText('Workspace selected by browser proof B')
|
|
->assertDontSee('Environment filter:')
|
|
->waitForText('Workspace selected by browser proof B')
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('audit-log--after-clear'));
|
|
|
|
spec329CopyDisclosureScreenshot('audit-log--after-clear');
|
|
|
|
$page->script('window.location.reload();');
|
|
|
|
$page
|
|
->waitForText('Workspace selected by browser proof B')
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Workspace selected by browser proof B')
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec329DisclosureScreenshot('audit-log--after-reload'));
|
|
|
|
spec329CopyDisclosureScreenshot('audit-log--after-reload');
|
|
});
|
|
|
|
/**
|
|
* @return array{
|
|
* user: User,
|
|
* environmentA: ManagedEnvironment,
|
|
* environmentB: ManagedEnvironment,
|
|
* auditA: AuditLog,
|
|
* auditB: AuditLog
|
|
* }
|
|
*/
|
|
function spec329BrowserDisclosureFixture(): array
|
|
{
|
|
$environmentA = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec329 Browser Tenant Environment A',
|
|
'external_id' => 'spec329-browser-environment-a',
|
|
]);
|
|
|
|
[$user, $environmentA] = createUserWithTenant(
|
|
tenant: $environmentA,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
$environmentB = ManagedEnvironment::factory()->active()->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'name' => 'Spec329 Browser Environment B',
|
|
'external_id' => 'spec329-browser-environment-b',
|
|
]);
|
|
|
|
createUserWithTenant(
|
|
tenant: $environmentB,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
$runA = OperationRun::factory()->forTenant($environmentA)->create([
|
|
'type' => 'tenant.evidence.snapshot.generate',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'context' => [
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
'provider_secret' => 'provider secret should stay hidden',
|
|
'stack_trace' => 'stack trace should stay hidden',
|
|
'debug_metadata' => 'debug metadata should stay hidden',
|
|
'internal_exception' => 'internal exception should stay hidden',
|
|
],
|
|
'completed_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$runB = OperationRun::factory()->forTenant($environmentB)->create([
|
|
'type' => 'policy.sync',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
$snapshotA = EvidenceSnapshot::query()->create([
|
|
'managed_environment_id' => (int) $environmentA->getKey(),
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'operation_run_id' => (int) $runA->getKey(),
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
'summary' => [
|
|
'missing_dimensions' => 0,
|
|
'stale_dimensions' => 0,
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
],
|
|
'generated_at' => now()->subMinutes(4),
|
|
'expires_at' => now()->addDays(30),
|
|
]);
|
|
|
|
EvidenceSnapshot::query()->create([
|
|
'managed_environment_id' => (int) $environmentB->getKey(),
|
|
'workspace_id' => (int) $environmentB->workspace_id,
|
|
'operation_run_id' => (int) $runB->getKey(),
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
|
'summary' => [
|
|
'missing_dimensions' => 1,
|
|
'stale_dimensions' => 0,
|
|
],
|
|
'generated_at' => now()->subMinutes(9),
|
|
'expires_at' => now()->addDays(30),
|
|
]);
|
|
|
|
ReviewPack::factory()->ready()->create([
|
|
'managed_environment_id' => (int) $environmentA->getKey(),
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'evidence_snapshot_id' => (int) $snapshotA->getKey(),
|
|
'operation_run_id' => (int) $runA->getKey(),
|
|
'status' => ReviewPackStatus::Ready->value,
|
|
]);
|
|
|
|
$storedReport = StoredReport::factory()->permissionPosture([
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
])->create([
|
|
'managed_environment_id' => (int) $environmentA->getKey(),
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'fingerprint' => 'spec329-browser-report',
|
|
]);
|
|
|
|
$auditA = AuditLog::query()->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'managed_environment_id' => (int) $environmentA->getKey(),
|
|
'operation_run_id' => (int) $runA->getKey(),
|
|
'actor_email' => 'spec329-a@example.test',
|
|
'actor_name' => 'Spec329 Browser Operator A',
|
|
'actor_type' => 'human',
|
|
'action' => 'permission_posture.checked',
|
|
'status' => 'success',
|
|
'resource_type' => 'stored_report',
|
|
'resource_id' => (string) $storedReport->getKey(),
|
|
'target_label' => 'Permission posture report',
|
|
'summary' => 'Permission posture checked',
|
|
'metadata' => [
|
|
'reason' => 'Evidence review',
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
'provider_secret' => 'provider secret should stay hidden',
|
|
'stack_trace' => 'stack trace should stay hidden',
|
|
'debug_metadata' => 'debug metadata should stay hidden',
|
|
'internal_exception' => 'internal exception should stay hidden',
|
|
'provider_response' => 'provider response should stay hidden',
|
|
],
|
|
'recorded_at' => now()->subMinutes(2),
|
|
]);
|
|
|
|
$auditB = AuditLog::query()->create([
|
|
'workspace_id' => (int) $environmentB->workspace_id,
|
|
'managed_environment_id' => (int) $environmentB->getKey(),
|
|
'actor_email' => 'spec329-b@example.test',
|
|
'actor_name' => 'Spec329 Browser Operator B',
|
|
'actor_type' => 'human',
|
|
'action' => 'workspace.selected',
|
|
'status' => 'success',
|
|
'resource_type' => 'workspace',
|
|
'resource_id' => (string) $environmentB->workspace_id,
|
|
'target_label' => 'Workspace '.$environmentB->workspace_id,
|
|
'summary' => 'Workspace selected by browser proof B',
|
|
'recorded_at' => now()->subMinute(),
|
|
]);
|
|
|
|
return [
|
|
'user' => $user,
|
|
'environmentA' => $environmentA,
|
|
'environmentB' => $environmentB,
|
|
'auditA' => $auditA,
|
|
'auditB' => $auditB,
|
|
];
|
|
}
|
|
|
|
function spec329AuthenticateDisclosureBrowser(
|
|
mixed $test,
|
|
User $user,
|
|
ManagedEnvironment $rememberedEnvironment,
|
|
): void {
|
|
$workspaceId = (int) $rememberedEnvironment->workspace_id;
|
|
|
|
$session = [
|
|
WorkspaceContext::SESSION_KEY => $workspaceId,
|
|
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
|
(string) $workspaceId => (int) $rememberedEnvironment->getKey(),
|
|
],
|
|
];
|
|
|
|
$test->actingAs($user)->withSession($session);
|
|
|
|
foreach ($session as $key => $value) {
|
|
session()->put($key, $value);
|
|
}
|
|
|
|
setAdminPanelContext($rememberedEnvironment);
|
|
}
|
|
|
|
function spec329ClearDisclosureEnvironmentFilter(mixed $page): mixed
|
|
{
|
|
$page->assertScript('document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\') instanceof HTMLAnchorElement', true);
|
|
$page->script('window.location.assign(document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\').href);');
|
|
|
|
return $page;
|
|
}
|
|
|
|
function spec329DisclosureScreenshot(string $name): string
|
|
{
|
|
return 'spec329-'.$name;
|
|
}
|
|
|
|
function spec329CopyDisclosureScreenshot(string $name, ?string $targetFilename = null): void
|
|
{
|
|
$filename = spec329DisclosureScreenshot($name).'.png';
|
|
$source = base_path('tests/Browser/Screenshots/'.$filename);
|
|
$targetDirectory = repo_path('specs/329-evidence-audit-log-disclosure-productization/artifacts/screenshots');
|
|
$targetFilename ??= $filename;
|
|
|
|
if (! is_dir($targetDirectory)) {
|
|
@mkdir($targetDirectory, 0755, true);
|
|
}
|
|
|
|
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
|
|
return;
|
|
}
|
|
|
|
if (! is_file($source)) {
|
|
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
|
}
|
|
|
|
if (is_file($source)) {
|
|
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename);
|
|
}
|
|
}
|