Files
ruvnet--RuView/ui/services/ws-ticket.test.mjs
T
Dragan Spiridonov 9b9754778f fix(ui): service worker cached /oauth/status, freezing browser sign-in
Browser sign-in worked, but the settings panel kept offering "Sign in with
Cognitum" afterwards; only a hard reload showed the true state. The server was
correct throughout.

Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path
outside `/api/` and `/health/`. `/oauth/status` therefore had its first
(signed-out) response stored in the Cache API and replayed to the page forever.
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the
`no-store, no-cache, must-revalidate` the server already sends could not prevent
it. A hard reload bypasses the service worker, which is why that alone appeared
to fix it, and why signing out re-poisoned the entry.

Fixes, in order of blast radius:

- `/oauth/` is never handled by the worker. Not network-first — that still
  writes a copy, which would be replayed the moment the server is briefly
  unreachable, silently reinstating a stale sign-in state.
- Cache-first is now an ALLOWLIST (navigations and static asset extensions)
  rather than the catch-all. This is the underlying defect: any endpoint added
  outside `/api/` was frozen on its first response. Unrecognised paths now go
  to the network untouched.
- `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned
  `ruview-v1` from browsers that already ran the old worker. Verified: the
  cache list went from `[ruview-v1]` to `[ruview-v2]` on update.
- Shell lookups use `ignoreSearch` and store a search-less key, so the
  `?signed_in=<ms>` the callback redirects to does not mint a fresh, never-hit
  cache entry per sign-in.

Also: re-check sign-in state on `pageshow` (bfcache restore, where no script
re-runs) and on `visibilitychange` (signed in or out in another tab). Opening
the panel alone is not sufficient — it may already be open.

Verification, in a real browser driven end-to-end:
- Reproduced with a valid session cookie: server returned `signed_in: true` to
  curl while the page's own fetch got a cached `signed_in: false`, and the
  request never appeared in the server log at all.
- Confirmed the cached entry existed: `caches.open('ruview-v1').match(
  '/oauth/status')` returned the signed-out body.
- After the fix, on a NORMAL (not hard) reload the panel reads
  "Signed in as <account> - sensing:read" with Sign out shown.

Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and
asserts the routing decision per path. 4 of its 10 tests fail against the
pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET,
websocket upgrade, cross-origin, API paths, static assets, navigation) and pass
in both, so they track behaviour rather than the rewrite.

CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this
suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is
the same blind spot that let this defect ship.

Removes the temporary `/oauth/status` cookie logging and the panel console
diagnostic added while tracking this down.

Co-Authored-By: Ruflo & AQE
2026-07-23 11:20:13 +02:00

114 lines
4.9 KiB
JavaScript

// Executed tests for the WebSocket ticket helper (ADR-272).
//
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
// (a directory argument does not work — Node resolves it as a module)
//
// WHAT THIS IS AND IS NOT.
// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
// is strictly more than the `node --check` syntax pass this file replaces, and
// strictly less than a browser: it does not exercise a real WebSocket upgrade,
// real cookie handling, or the page wiring. The claim "the UI JavaScript has
// never been run" is no longer true of this module; "browser-tested" still is
// not. Both statements matter and neither should be rounded up.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
const STORAGE_KEY = 'ruview-api-token';
// --- stubs installed before the module under test is imported ---------------
let stored = {};
let fetchCalls = [];
let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
globalThis.localStorage = {
getItem: (k) => (k in stored ? stored[k] : null),
setItem: (k, v) => { stored[k] = String(v); },
removeItem: (k) => { delete stored[k]; },
};
globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
const { withWsTicket } = await import('./ws-ticket.js');
beforeEach(() => {
stored = {};
fetchCalls = [];
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
});
test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
// Auth is off, so no ticket is needed. Minting one would be a pointless
// round-trip on every reconnect.
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing');
assert.equal(fetchCalls.length, 0);
});
test('a minted ticket is appended and the bearer is sent only in the header', async () => {
stored[STORAGE_KEY] = 'secret-bearer';
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing?ticket=T');
// The long-lived bearer must never reach a URL — only the bounded ticket does.
assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
const [path, init] = fetchCalls[0];
assert.equal(path, '/api/v1/ws-ticket');
assert.equal(init.method, 'POST');
assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
});
test('an existing query string gets & rather than a second ?', async () => {
stored[STORAGE_KEY] = 'b';
const url = await withWsTicket('ws://host/ws/sensing?foo=1');
assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
});
test('the ticket value is URL-encoded', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
const url = await withWsTicket('ws://host/ws/sensing');
assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
});
test('404 means a server predating ADR-272, so connect without a ticket', async () => {
// That server still exempts WebSockets, so an unticketed connect is correct.
// This is what lets one UI work against both old and new servers, which is
// what makes the legacy escape hatch removable rather than permanent.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 503 does not append a ticket and does not throw', async () => {
// Ticket store exhausted. The socket attempt will fail on its own and the
// caller's reconnect logic handles it; throwing here would duplicate that.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a network failure is swallowed rather than breaking the connect path', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => { throw new Error('offline'); };
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 200 with no ticket field yields no ticket', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a fresh ticket is minted per call and never reused', async () => {
// Tickets are single-use and expire in ~30s, so caching one across reconnects
// fails on the second attempt.
stored[STORAGE_KEY] = 'b';
let n = 0;
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
});