diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index dd6267a70..b2587c3e4 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2524,3 +2524,8 @@ REST active-alert recovery may complete before a resource snapshot. Neither active incidents nor alert hydration completion establishes an empty resource estate or replaces platform navigation admission. Incident access must remain available during socket reconnect; this does not assert notification delivery. + +Qualification additionally injects admission HTTP 503 on reconnect with +populated inventory and checks incident access on desktop and the 390px mobile +rail. Enabled acknowledgement controls prove access only, not persisted +acknowledgement or external notification receipt. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2aed47751..4bedecd99 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3457,3 +3457,8 @@ snapshot evidence arrives; alert-only recovery is not resource evidence. Resource snapshot receipt survives a transport reconnect but resets when the WebSocket organisation URL changes. The no-op pre-auth store reports no resource snapshot. Outgoing tenant admission must not authorise incoming tenant tabs. + +A failed reconnect admission request retains the current tenant's last valid +facet. The synchronous organisation-switch reset still clears outgoing +admission before requesting the new tenant, including when that request fails. +This is navigation continuity, not an authorization or entitlement decision. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f4f268985..b838e5b89 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3028,3 +3028,6 @@ Runtime resource resolution uses a store signal recording resource snapshot receipt, or an already populated resource array, rather than alert payload presence. Reconnect must not add an estate fetch or scan to recover navigation. The signal survives transport loss and resets on organisation URL changes. + +On admission-request failure retain the existing facet without extra retries, +polling or full-estate reads. A later successful refresh replaces that facet. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8a5276ed5..da742ea31 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5026,3 +5026,7 @@ initialDataReceived. Only a state frame with a resources field sets this signal; an explicit empty resources array is authoritative, while an alert-only frame is not. Preserve the signal across transport reconnect and clear it with organisation URL state reset. The global no-op store reports false. + +A failed admission HTTP refresh is not an empty estate either: retain the last +valid facet on request failure. A successful response still replaces it, +including all-false admission; first-load failure remains unresolved. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 39aaeb883..77affab02 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,22 +1,19 @@ { "version": 1, - "base_sha": "193ead50fd9559b92ee242f34b8e9718fd7a3e3e", - "verified_at": "2026-09-05T03:43:42Z", + "base_sha": "deac5e7750a79052170ba4396b700a3d6b855e3f", + "verified_at": "2026-09-05T04:03:13Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/stores/websocket-global.ts", - "frontend-modern/src/stores/websocket.ts", "frontend-modern/src/useAppRuntimeState.ts" ], "content_sha256": { - "frontend-modern/src/stores/websocket-global.ts": "abacee8810729f0dbded90f0b8be99ad09936134160b631b74967e0751f2919f", - "frontend-modern/src/stores/websocket.ts": "253b45b6c9694ad39c881cf8b592398962ae5fa6849a1590812fbb634649fb9b", - "frontend-modern/src/useAppRuntimeState.ts": "e879a23baf48caf99359c63cf4c0c637156d27a28ab823f62c19e14052fd1643" + "frontend-modern/src/useAppRuntimeState.ts": "01c0eebf2d99994d730c8add6604d1851f89f8a6532cabbc159b44f030b8124b" }, "routes": [ "/proxmox", "/docker", - "/alerts" + "/alerts", + "/settings" ], "viewports": [ { @@ -33,14 +30,15 @@ } ], "states": [ - "Synthetic populated Proxmox and Docker inventory in isolated real Go backend with production embedded frontend", - "Connected socket closed with 1013; reconnect attempts blocked while HTTP health remains available", - "Platform destinations retained during interruption and automatic recovery without reload; desktop navigation intentionally hidden below xl", - "Desktop active incidents retain enabled acknowledgement control; no acknowledgement persistence or notification receipt claim" + "Isolated real Go backend and production embedded frontend; synthetic Proxmox and Docker inventory; Chromium 141.0.7390.37, fresh contexts, 100% zoom", + "Real connected socket closed with 1013; reconnect attempts blocked while HTTP health stays available", + "Six checks passed at 1440, 1100 and 390 by 900, with and without admission HTTP 503 on recovery; each injected case observed exactly one failed admission request", + "Platform destinations retained through interruption and recovery without document reload; no acknowledgement persistence or notification delivery claim" ], "interactions": [ "pulse-heavy-run -- env PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MOCK_MODE=true PULSE_E2E_NAVIGATION_RECOVERY=1 PULSE_E2E_LOCAL_BACKEND_PORT=18765 npm --prefix tests/integration test -- tests/96-navigation-socket-recovery.spec.ts --project=chromium", - "Three Chromium checks passed; fresh contexts at 100% zoom; desktop and 390px reconnect screenshots inspected. Mobile menu interaction not exercised.", - "Open Alerts during interruption at 1440px; unblock socket and verify recovery without reload" + "390px before, during and after interruption: open More, Escape, reopen and follow Settings; switch Proxmox and Docker; open Alerts with enabled Acknowledge control and return to Docker", + "1440px: open active incidents during interruption with enabled Acknowledge control; recovered desktop and 390px screenshots inspected", + "Baseline failed at 1440 and 1100 when admission HTTP refresh failed; baseline 390px passed. Focused retention test failed before repair. Repaired run: six browser checks and 107 focused tests pass" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 55c5f1512..b6a3c7163 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -152,6 +152,31 @@ describe('App platform navigation admission', () => { expect(admission.visibility).toMatchObject({ docker: true, truenas: true, standalone: true }); }); + it('does not let retained reconnect admission override an explicit empty resource snapshot', () => { + const retainedFacet = { + proxmox: true, + docker: true, + kubernetes: false, + truenas: false, + vmware: false, + standalone: false, + }; + expect(resolvePlatformNavigationAdmission([], false, retainedFacet).visibility.docker).toBe( + true, + ); + const authoritativeEmpty = resolvePlatformNavigationAdmission([], true, retainedFacet); + expect(authoritativeEmpty.resolved).toBe(true); + expect(Object.values(authoritativeEmpty.visibility)).toEqual([ + false, + false, + false, + false, + false, + false, + ]); + expect(getDefaultWorkspaceRoute(authoritativeEmpty.visibility, false)).toBe('/alerts'); + }); + it('stays unresolved when neither the facet nor runtime state is available', () => { const admission = resolvePlatformNavigationAdmission([], false, null); @@ -869,7 +894,8 @@ describe('App architecture', () => { it('keeps alert hydration separate from tenant resource admission', () => { const start = appRuntimeStateSource.indexOf('const runtimeStateResolved'); const predicate = appRuntimeStateSource.slice( - start, appRuntimeStateSource.indexOf('const state =', start), + start, + appRuntimeStateSource.indexOf('const state =', start), ); expect(start).toBeGreaterThan(-1); expect(predicate).toContain('resourceSnapshotReceived()'); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 7b65aee58..0fe0e3e05 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -697,7 +697,9 @@ describe('useAppRuntimeState', () => { }); it('does not mistake alert-only reconnect recovery for an empty resource snapshot', async () => { - websocketState = makeWebSocketState({ activeAlerts: [{ id: 'recovered-alert' } as State['activeAlerts'][number]] }); + websocketState = makeWebSocketState({ + activeAlerts: [{ id: 'recovered-alert' } as State['activeAlerts'][number]], + }); websocketConnected = false; websocketReconnecting = true; websocketInitialDataReceived = false; @@ -920,6 +922,57 @@ describe('useAppRuntimeState', () => { dispose(); }); + it('retains valid admission after a failed reconnect refresh and accepts later empty admission', async () => { + const eventsModule = await import('@/stores/events'); + const { dispose, hookState } = mountHook(); + await waitFor(() => expect(hookState.platformAdmission()).not.toBeNull()); + const admitted = hookState.platformAdmission(); + const reconnect = (eventsModule.eventBus.on as ReturnType).mock.calls.find( + ([event]) => event === 'websocket_reconnected', + )![1] as () => void; + apiFetchJSONMock.mockRejectedValue(new Error('503 admission unavailable')); + reconnect(); + await flushAsync(); + expect(hookState.platformAdmission()).toEqual(admitted); + + const empty = { + proxmox: false, + docker: false, + kubernetes: false, + truenas: false, + vmware: false, + standalone: false, + }; + apiFetchJSONMock.mockResolvedValue({ aggregations: { platformAdmission: empty } }); + reconnect(); + await waitFor(() => expect(hookState.platformAdmission()).toEqual(empty)); + dispose(); + }); + + it('does not retain outgoing tenant admission if the new tenant request fails', async () => { + isMultiTenantEnabledMock.mockReturnValue(true); + const { dispose, hookState } = mountHook(); + await waitFor(() => { + expect(hookState.platformAdmission()).not.toBeNull(); + expect(hookState.organizations().some((org) => org.id === 'acme')).toBe(true); + }); + apiFetchJSONMock.mockRejectedValue(new Error('503 admission unavailable')); + hookState.handleOrgSwitch(hookState.activeOrgID() === 'acme' ? 'default' : 'acme'); + expect(hookState.platformAdmission()).toBeNull(); + await flushAsync(); + expect(hookState.platformAdmission()).toBeNull(); + dispose(); + }); + + it('leaves admission unresolved when the first request fails', async () => { + apiFetchJSONMock.mockRejectedValue(new Error('503 admission unavailable')); + const { dispose, hookState } = mountHook(); + await waitFor(() => expect(apiFetchJSONMock).toHaveBeenCalled()); + await flushAsync(); + expect(hookState.platformAdmission()).toBeNull(); + dispose(); + }); + it('refreshes admission when the websocket reconnects', async () => { const eventsModule = await import('@/stores/events'); const { dispose } = mountHook(); diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index a23209686..5adc9d99f 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -306,10 +306,11 @@ export const useAppRuntimeState = () => { ); setPlatformAdmission(normalizePlatformAdmission(payload?.aggregations?.platformAdmission)); } catch (error) { - // Older servers do not report the facet; navigation keeps its previous - // behaviour rather than hiding platforms the estate really has. + // A failed refresh says nothing about which platforms still exist. + // Keep the last valid facet until a successful response replaces it. + // First-load failures remain unresolved; org switches clear it before + // requesting the new tenant, so this cannot retain outgoing admission. logger.debug('[useAppRuntimeState] platform admission unavailable', error); - setPlatformAdmission(null); } }; diff --git a/tests/integration/tests/96-navigation-socket-recovery.spec.ts b/tests/integration/tests/96-navigation-socket-recovery.spec.ts index e73cd6d93..e3d467a1b 100644 --- a/tests/integration/tests/96-navigation-socket-recovery.spec.ts +++ b/tests/integration/tests/96-navigation-socket-recovery.spec.ts @@ -22,57 +22,107 @@ async function navigation(page: Page) { ); } -for (const width of [1440, 1100, 390]) { - test(`populated navigation survives socket loss at ${width}px`, async ({ page, browser }, testInfo) => { - test.skip(!enabled, 'Requires isolated mock backend and explicit qualification opt-in'); - test.setTimeout(120_000); - await page.setViewportSize({ width, height: 900 }); - let blocked = false; - const sockets: WebSocketRoute[] = []; - await page.routeWebSocket('**/ws*', async socket => { - if (blocked) { - await socket.close({ code: 1013, reason: 'qualification interruption' }); - return; - } - socket.connectToServer(); - sockets.push(socket); - }); - await ensureAuthenticated(page); - await page.goto('/proxmox'); - await expect(page.locator('[data-proxmox-host-row]').first()).toBeVisible({ timeout: 60_000 }); - await expect(page.locator('tr[data-guest-id]').first()).toBeVisible(); - await expect(page.getByRole('status', { name: healthy })).toBeVisible(); - // Verify both platform inventories before interrupting the live connection. - await page.goto('/docker'); - await expect(page.locator('tr[data-docker-host-row]').first()).toBeVisible(); - await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); - await expect(page.getByRole('status', { name: healthy })).toBeVisible(); - const before = await navigation(page); - expect(before).toHaveLength(1); - expect(before[0].visible).toBe(width >= 1280); - expect(before[0].tabs.map(tab => tab.label)).toEqual(expect.arrayContaining(['Proxmox', 'Docker'])); - const capture = async (stage: string) => { - await testInfo.attach(stage, { body: await page.screenshot({ path: testInfo.outputPath(`${stage}.png`) }), contentType: 'image/png' }); - }; - await capture('healthy'); - const documentIdentity = await page.evaluate(() => performance.timeOrigin); - blocked = true; - for (const socket of sockets) await socket.close({ code: 1013, reason: 'qualification interruption' }); - await expect(page.getByRole('status', { name: 'Backend is healthy. Live updates are reconnecting.' })).toBeVisible({ timeout: 30_000 }); - expect((await page.request.get('/api/health')).ok()).toBeTruthy(); - await capture('reconnecting'); - expect.soft(await navigation(page)).toEqual(before); - await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); - if (width >= 1280) { - await page.locator('[aria-label="Primary navigation"]').getByRole('link', { name: /Alerts/ }).click(); - await expect(page.getByRole('button', { name: 'Acknowledge', exact: true }).first()).toBeEnabled(); - await capture('active-incidents-during-reconnect'); - } - blocked = false; - await expect(page.getByRole('status', { name: healthy })).toBeVisible({ timeout: 45_000 }); - expect.soft(await navigation(page)).toEqual(before); - expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentIdentity); - await capture('recovered'); - await testInfo.attach('environment', { body: JSON.stringify({ browser: browser.version(), width, height: 900, zoom: 1, before }), contentType: 'application/json' }); - }); +async function mobileDestinations(page: Page) { + const nav = page.getByRole('navigation', { name: 'Mobile navigation' }); + await nav.getByRole('button', { name: 'More navigation', exact: true }).click(); + const more = page.getByRole('menu', { name: 'More navigation destinations' }); + await expect(more).toBeVisible(); + await expect(more.getByRole('menuitem', { name: /Settings/ })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(more).toBeHidden(); + await nav.getByRole('button', { name: 'More navigation', exact: true }).click(); + await more.getByRole('menuitem', { name: /Settings/ }).click(); + await expect(page).toHaveURL(/\/settings/); + await expect(more).toBeHidden(); + for (const platform of ['Proxmox', 'Docker']) { + await nav.locator('[data-tab-id="platform-switcher"]').click(); + const menu = page.getByRole('menu', { name: 'Switch platform', exact: true }); + await menu.getByRole('menuitem', { name: platform, exact: true }).click(); + await expect(menu).toBeHidden(); + await expect(page).toHaveURL(new RegExp('/' + platform.toLowerCase())); + await expect(page.locator(platform === 'Docker' + ? 'tr[data-docker-container-row]' : '[data-proxmox-host-row]').first()).toBeVisible(); + } + await nav.locator('[data-tab-id="alerts"]').click(); + await expect(page.getByRole('button', { name: 'Acknowledge', exact: true }).first()).toBeEnabled(); + await nav.locator('[data-tab-id="platform-switcher"]').click(); + await page.getByRole('menu', { name: 'Switch platform', exact: true }) + .getByRole('menuitem', { name: 'Docker', exact: true }).click(); +} + +for (const admissionFailure of [false, true]) { + for (const width of [1440, 1100, 390]) { + test(`populated navigation survives socket loss at ${width}px (admission failure: ${admissionFailure})`, async ({ page, browser }, testInfo) => { + test.skip(!enabled, 'Requires isolated mock backend and explicit qualification opt-in'); + test.setTimeout(120_000); + await page.setViewportSize({ width, height: 900 }); + let blocked = false; + let failAdmission = false; + let failedAdmissions = 0; + await page.route('**/api/resources?*', async route => { + const url = new URL(route.request().url()); + if (failAdmission && url.searchParams.get('page') === '1' && url.searchParams.get('limit') === '1') { + failedAdmissions++; + await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"qualification admission unavailable"}' }); + } else { + await route.continue(); + } + }); + const sockets: WebSocketRoute[] = []; + await page.routeWebSocket('**/ws*', async socket => { + if (blocked) { + await socket.close({ code: 1013, reason: 'qualification interruption' }); + return; + } + socket.connectToServer(); + sockets.push(socket); + }); + await ensureAuthenticated(page); + await page.goto('/proxmox'); + await expect(page.locator('[data-proxmox-host-row]').first()).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('tr[data-guest-id]').first()).toBeVisible(); + await expect(page.getByRole('status', { name: healthy })).toBeVisible(); + // Verify both platform inventories before interrupting the live connection. + await page.goto('/docker'); + await expect(page.locator('tr[data-docker-host-row]').first()).toBeVisible(); + await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); + await expect(page.getByRole('status', { name: healthy })).toBeVisible(); + const before = await navigation(page); + expect(before).toHaveLength(1); + expect(before[0].visible).toBe(width >= 1280); + expect(before[0].tabs.map(tab => tab.label)).toEqual(expect.arrayContaining(['Proxmox', 'Docker'])); + const capture = async (stage: string) => { + await testInfo.attach(stage, { body: await page.screenshot({ path: testInfo.outputPath(`${stage}.png`) }), contentType: 'image/png' }); + }; + if (width === 390) await mobileDestinations(page); + await capture('healthy'); + const documentIdentity = await page.evaluate(() => performance.timeOrigin); + blocked = true; + failAdmission = admissionFailure; + for (const socket of sockets) await socket.close({ code: 1013, reason: 'qualification interruption' }); + await expect(page.getByRole('status', { name: 'Backend is healthy. Live updates are reconnecting.' })).toBeVisible({ timeout: 30_000 }); + expect((await page.request.get('/api/health')).ok()).toBeTruthy(); + await capture('reconnecting'); + expect.soft(await navigation(page)).toEqual(before); + await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); + if (width >= 1280) { + await page.locator('[aria-label="Primary navigation"]').getByRole('link', { name: /Alerts/ }).click(); + await expect(page.getByRole('button', { name: 'Acknowledge', exact: true }).first()).toBeEnabled(); + await capture('active-incidents-during-reconnect'); + } + if (width === 390) await mobileDestinations(page); + const admissionResponse = admissionFailure + ? page.waitForResponse(response => response.url().includes('/api/resources?page=1&limit=1') && response.status() === 503) + : null; + blocked = false; + if (admissionResponse) await (await admissionResponse).finished(); + await expect(page.getByRole('status', { name: healthy })).toBeVisible({ timeout: 45_000 }); + if (admissionFailure) await expect.poll(() => failedAdmissions).toBeGreaterThan(0); + expect.soft(await navigation(page)).toEqual(before); + if (width === 390) await mobileDestinations(page); + expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentIdentity); + await capture('recovered'); + await testInfo.attach('environment', { body: JSON.stringify({ browser: browser.version(), width, height: 900, zoom: 1, admissionFailure, failedAdmissions, before }), contentType: 'application/json' }); + }); + } } diff --git a/tests/qualification/navigation-reconnect/README.md b/tests/qualification/navigation-reconnect/README.md index f34b9badc..31ee19b8f 100644 --- a/tests/qualification/navigation-reconnect/README.md +++ b/tests/qualification/navigation-reconnect/README.md @@ -68,3 +68,45 @@ checks pass at 1440, 1100 and 390×900. Desktop and narrow reconnect screenshots were inspected. At 390px the Docker inventory and bottom navigation remain visible; this does not exercise the More menu or mobile platform switching. The matching browser receipt records the exact runtime source hashes. + +## Admission HTTP failure and mobile interaction — 5 September 2026 + +The expanded experiment on integrated main +`deac5e7750a79052170ba4396b700a3d6b855e3f` found a second failure: +after a real socket reconnect, returning HTTP 503 specifically for +`/api/resources?page=1&limit=1` removed the platform destinations at 1440 +and 1100px. The populated inventory was not sufficient to establish canonical +resource-snapshot receipt. System destinations and incident controls remained. +This is a reproduced source-build path consistent with #1899, not confirmation +of the reporter's installation or a newly qualified v6.4.1 artifact. + +Baseline: four checks passed, two failed. All three no-HTTP-failure checks +passed; the 390px failure-injection check also passed. Do not describe this as +an unconditional defect at every width: resource snapshot arrival affects it. +The new focused retention test independently failed before the repair. + +The repair retains the last valid admission facet on request failure. No new +fetches, retries or navigation surface. Successful responses still replace it, +including an all-false facet. First-load failures remain unresolved, and an +organisation switch still clears outgoing admission before the new request; +focused tests cover that request failing as well. + +The same opt-in command above now runs six checks: all three widths with and +without admission HTTP failure. At 390px it opens and dismisses More, follows +Settings from More, switches to Proxmox and Docker, and opens active incidents +with an enabled acknowledgement control before, during and after interruption. +Failure injection must observe a completed 503 response, not merely install a +route handler. The test checks document identity through recovery without reload. + +This supersedes the earlier admission-failure/mobile-interaction exclusions, +not the published-artifact, candidate-branch, reporter-browser, reverse-proxy or +long-outage exclusions. Release qualification and soak remain separate. + +Repair validation: all six browser checks pass (Chromium 141.0.7390.37, +100% zoom, 1440/1100/390×900), each injected case recording one failed admission +request. Desktop and narrow repaired screenshots were inspected. 107 focused +runtime, architecture and websocket tests pass. The browser receipt binds +the repaired runtime source bytes; baseline artifacts are retained locally in +`tmp/navigation-admission-baseline/`, repaired artifacts in +`tests/integration/test-results/` and `tests/integration/playwright-report/`. +These local artifacts are not release qualification or automatic CI admission.