Files
Codeman/test/webview-lost-root-frame.test.ts
Codeman maintainer 1306f731cf fix(webview): recover a proxied dashboard that reloads on its landing page
The runtime shim masks `/webview/<cap>/` off a proxied page's URL so its router
boots on the path it expects, and the landing page masks to exactly `/`. A
`location.reload()` there (a Vite dev server on a config change or a failed HMR
update, the likeliest case in the feature's own motivating scenario) therefore
asks for Codeman's root as an iframe navigation. `serveLostWebviewFrame()`
returned early for `/`, so on a passwordless install the frame received Codeman's
own app shell and rendered it inside the web tab, and with a password it got a
401 in the frame. Either way no `codeman:webview-lost` message was posted, and
because the document loaded fine the load handler cleared the failed-frame panel,
so the Reload / Open in new tab affordances never appeared. Before masking the
frame's URL was the prefixed one, so a reload worked; this was a regression.

`/` is the one lost-frame path a registered route also serves, so the route
table cannot tell that reload from a real navigation. Credentials can: nothing
in Codeman frames its own root, and a sandboxed frame is opaque-origin with no
cookie and no Authorization header. `carriesAuthCredentials()` (pure, in
webview-proxy.ts) makes that test, and `/` is now admitted by the auth hook only
when it fails; a framed `/` that does carry credentials still gets the shell.
Without a password no auth hook runs at all, so the index route applies the
same test itself (`isLostWebviewRootFrame`) before rendering the shell, and the
three places that emitted the recovery page share `sendLostWebviewFramePage()`.

Tests: the password form in webview-auth-exemption (recovery page for a
credential-free framed `/`, shell with valid Basic auth, 401 with a stale cookie
or a top-level navigation), the passwordless form against a real WebServer in
webview-lost-root-frame (port 3198), and the credential predicate in
webview-proxy. All three fail without the fix. Verified against a live isolated
instance as well: a framed `GET /` with no credentials answers the 470-byte
recovery page, a top-level `GET /` and a framed one carrying a cookie answer the
shell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:38:54 +02:00

75 lines
3.3 KiB
TypeScript

/**
* @fileoverview A web-tab frame reloading on its dashboard's landing page, on an
* install with NO password.
*
* The proxy's runtime shim masks `/webview/<cap>/` off the page's URL, and the
* landing page masks to exactly `/`. A `location.reload()` there (a Vite dev
* server on a config change) therefore asks for Codeman's own root as an iframe
* navigation. Without a password no auth hook runs, so the request used to reach
* the index route and render Codeman's app shell INSIDE the web tab, with no
* recovery message and the failed-frame panel cleared because the document loaded
* fine. `test/webview-auth-exemption.test.ts` pins the password form; this boots a
* real WebServer in test mode for the passwordless one, where the index route
* itself has to answer.
*
* Port: 3198
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
const PORT = 3198;
const lostFrame = { 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', accept: 'text/html,*/*;q=0.8' };
describe('landing-page reload of a proxied dashboard, passwordless install', () => {
let server: WebServer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let app: any;
beforeAll(async () => {
server = new WebServer(PORT);
await server.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app = (server as any).app;
});
afterAll(async () => {
await server.stop();
});
it('answers a credential-free iframe navigation of / with the recovery page, not the shell', async () => {
for (const url of ['/', '/?tab=2']) {
const res = await app.inject({ method: 'GET', url, headers: lostFrame });
expect(res.statusCode, url).toBe(200);
expect(res.headers['content-type'], url).toContain('text/html');
expect(res.headers['content-security-policy'], url).toContain("default-src 'none'");
expect(res.headers['cache-control'], url).toBe('no-store');
expect(res.body, url).toContain('codeman:webview-lost');
expect(res.body, url).not.toContain('<base href');
}
});
it('still serves the shell to a top-level navigation, and to a framed / that carries credentials', async () => {
const top = await app.inject({ method: 'GET', url: '/' });
expect(top.statusCode).toBe(200);
expect(top.body).toContain('<base href');
expect(top.body).not.toContain('codeman:webview-lost');
for (const headers of [
{ ...lostFrame, cookie: 'codeman_session=abc' },
{ ...lostFrame, authorization: 'Basic YWRtaW46eA==' },
{ ...lostFrame, 'sec-fetch-dest': 'document' },
]) {
const res = await app.inject({ method: 'GET', url: '/', headers });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('<base href');
}
});
it('keeps answering any other lost path from the 404 handler', async () => {
const res = await app.inject({ method: 'GET', url: '/settings/users', headers: lostFrame });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('codeman:webview-lost');
// The API-shaped 404 is untouched by the recovery path.
const api = await app.inject({ method: 'GET', url: '/api/nope', headers: lostFrame });
expect(api.statusCode).toBe(404);
expect(JSON.parse(api.body).success).toBe(false);
});
});