Files
ruvnet--RuView/ui/sw.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

165 lines
6.1 KiB
JavaScript

// Executed tests for the service worker's request routing (ADR-271/272).
//
// WHY THIS EXISTS.
// Browser sign-in appeared to fail: after a successful OAuth round-trip the
// settings panel still offered "Sign in with Cognitum", and only a hard reload
// showed the truth. The server was correct throughout — `/oauth/status` fell
// through to the service worker's cache-first catch-all, so the first
// (signed-out) response was stored in the Cache API and replayed forever.
//
// The Cache API is not the HTTP cache. It ignores `Cache-Control` entirely, so
// the `no-store` the server already sent could not prevent this. Nothing in the
// Rust suite, the UI unit tests, or a curl probe could observe it: curl has no
// service worker, and a hard reload bypasses one. It was only visible by
// driving a real browser.
//
// These tests load the REAL `sw.js` with stubbed worker globals and assert on
// which strategy each path is routed to.
//
// 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)
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const SW_SOURCE = readFileSync(fileURLToPath(new URL('./sw.js', import.meta.url)), 'utf8');
const ORIGIN = 'http://127.0.0.1:8099';
/** Load sw.js in a fresh context and return its registered `fetch` listener. */
function loadServiceWorker() {
const listeners = {};
const cachePuts = [];
const cacheStub = {
addAll: async () => {},
put: async (req, res) => { cachePuts.push(String(req.url ?? req)); return undefined; },
keys: async () => [],
match: async () => undefined,
};
const sandbox = {
self: {
addEventListener: (name, fn) => { listeners[name] = fn; },
location: { origin: ORIGIN },
skipWaiting: () => {},
clients: { claim: () => {} },
},
caches: {
open: async () => cacheStub,
keys: async () => [],
delete: async () => true,
match: async () => undefined,
},
// Never actually reached: every assertion below inspects the routing
// decision, not the network.
fetch: async () => ({ ok: true, clone: () => ({}) }),
URL,
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
console,
};
vm.createContext(sandbox);
vm.runInContext(SW_SOURCE, sandbox);
return { listeners, cachePuts, sandbox };
}
/**
* Route one request and report the decision.
* `handled: false` means the SW called neither respondWith nor cache — the
* request goes to the network untouched, which is the only safe outcome for a
* credentialed endpoint.
*/
function route(path, { method = 'GET', mode = 'cors', headers = {} } = {}) {
const { listeners } = loadServiceWorker();
let handled = false;
const request = {
url: `${ORIGIN}${path}`,
method,
mode,
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
};
listeners.fetch({ request, respondWith: () => { handled = true; } });
return handled;
}
let sw;
beforeEach(() => { sw = loadServiceWorker(); });
// --- the defect this file exists for ----------------------------------------
test('/oauth/status is never handled by the service worker', () => {
// The exact request whose cached signed-out copy made sign-in look broken.
assert.equal(route('/oauth/status'), false);
});
test('every /oauth/ path bypasses the worker, not just status', () => {
// start/callback/logout all carry or clear credentials. A cached redirect or
// Set-Cookie replayed later is worse than a cached status.
for (const p of ['/oauth/start', '/oauth/callback?code=x&state=y', '/oauth/logout']) {
assert.equal(route(p), false, `${p} must go straight to the network`);
}
});
// --- the underlying defect: cache-first was the catch-all -------------------
test('an unrecognised path is left to the network rather than cached', () => {
// This is what made the OAuth bug possible in the first place: any endpoint
// added outside /api/ was silently frozen on its first response. An
// allowlist means the next such endpoint is safe by default.
assert.equal(route('/some/future/endpoint'), false);
});
test('static assets are still served cache-first', () => {
// The offline shell is the point of the worker; the fix must not disable it.
for (const p of ['/app.js', '/style.css', '/components/TabManager.js', '/icons/logo.svg']) {
assert.equal(route(p), true, `${p} should be cache-first`);
}
});
test('a navigation request is still served cache-first', () => {
assert.equal(route('/ui/', { mode: 'navigate' }), true);
});
test('API paths are still handled, so offline fallback survives', () => {
assert.equal(route('/api/v1/models'), true);
assert.equal(route('/health/live'), true);
});
// --- pre-existing guards, pinned so the rewrite did not drop them -----------
test('non-GET requests are ignored', () => {
assert.equal(route('/api/v1/models', { method: 'POST' }), false);
});
test('websocket upgrades are ignored', () => {
assert.equal(route('/ws/sensing', { headers: { Upgrade: 'websocket' } }), false);
});
test('cross-origin requests are ignored', () => {
const { listeners } = loadServiceWorker();
let handled = false;
listeners.fetch({
request: {
url: 'https://auth.cognitum.one/oauth/authorize',
method: 'GET',
mode: 'cors',
headers: { get: () => null },
},
respondWith: () => { handled = true; },
});
assert.equal(handled, false);
});
// --- cache hygiene -----------------------------------------------------------
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {
// `activate` deletes every cache whose name !== CACHE_NAME. Browsers that
// already ran the old worker hold a signed-out /oauth/status in `ruview-v1`;
// only a name change removes it for them.
assert.ok(!/['"]ruview-v1['"]/.test(SW_SOURCE), 'CACHE_NAME must not still be ruview-v1');
assert.ok(/CACHE_NAME\s*=\s*['"]ruview-v[2-9]/.test(SW_SOURCE));
});