mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
9b9754778f
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
114 lines
4.9 KiB
JavaScript
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');
|
|
});
|