mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
9299a3b137
Three loose ends from the browser sign-in work. --- 1. The last mile: a button --- The server endpoints existed but nothing in ui/ linked to them, so the feature was unreachable. QuickSettings gains a "Cognitum Account" panel that renders from `GET /oauth/status` and offers Sign in / Sign out. `/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT browser cannot ask a gated API whether sign-in is available. It returns only capability flags and, when a session exists, who it belongs to. Never a credential. The panel distinguishes four states rather than showing a button that might 404: signed in; sign-in available; auth on but OAuth off (points at the existing static-token panel); no auth required. A 404 from /oauth/status means a server predating this work and says so plainly. Sign-in is a full-page navigation, not fetch(): the server replies 302 and the browser must follow it carrying the transaction cookie. An XHR would follow the redirect invisibly and land nowhere. --- 2. RUVIEW_SESSION_SECRET no longer required --- Previously `/oauth/start` returned 503 unless an operator invented a secret — a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503 naming an env var they have never heard of. Now resolved in order: env var, then `<data_dir>/session-secret`, then generate one and persist it 0600 (temp file, chmod before rename — the discipline used for the CLI's credentials). Persisted rather than in-memory so a restart does not silently sign everyone out. The env var still wins, which is what a multi-instance deployment needs: several servers must share a secret or a session issued by one is rejected by the next. If the file cannot be written we log the reason and continue with an in-memory secret rather than refusing sign-in outright. Verified with NO configuration at all: secret generated, file mode 0600, /oauth/start returns 302, /oauth/status reports browser_signin: true. --- 3. The JavaScript is now executed by tests --- `ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new dependency and no package.json needed. Run: `node --test ui/services/`. Covers: no token means no fetch and an unchanged URL; the bearer reaches the Authorization header and NEVER the URL; `?` vs `&` when a query already exists; ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that lets one UI work against old and new servers, and therefore lets the legacy escape hatch be removed later); 503 and network failure swallowed rather than breaking the connect path; and that a fresh ticket is minted per call, since tickets are single-use and caching one fails on the second reconnect. STATED PRECISELY, because the distinction matters: this EXECUTES the module in Node with stubbed fetch/localStorage. It is more than the `node --check` it replaces and less than a browser — no real WebSocket upgrade, no real cookies, no page wiring. "The UI JavaScript has never been run" is no longer true of this module. "Browser-tested" still is not. Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS. Co-Authored-By: Ruflo & AQE
113 lines
4.8 KiB
JavaScript
113 lines
4.8 KiB
JavaScript
// Executed tests for the WebSocket ticket helper (ADR-272).
|
|
//
|
|
// Run: node --test ui/services/
|
|
//
|
|
// 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');
|
|
});
|