/** * DC-076 / DC-061: Tests for the dashboard WebSocket server * * DC-061 added: * - Real authVerifier injection (no string-presence-only check) * - Rejection of bare cookies / token query params * - close() detaches only OUR listeners (not shared SSE listeners) * - Message size cap (16 KB) * - parseCookieHeader unit coverage */ const http = require('http'); const WebSocket = require('ws'); const EventEmitter = require('events'); const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws'); function createMockServer() { return http.createServer((req, res) => { res.writeHead(404); res.end(); }); } /** * Build a stub verifier that mimics the production `session.isValid` * shape: takes an IncomingMessage-ish request, returns true iff the * session cookie value is a non-empty string. */ function cookieValueVerifier() { return (req) => { const parsed = parseCookieHeader(req && req.headers && req.headers.cookie); const raw = parsed.dashcaddy_session; return typeof raw === 'string' && raw.length > 0; }; } describe('DC-076: Dashboard WebSocket', () => { let server, wsServer, port; let resourceMonitor, healthChecker, updateManager; beforeEach((done) => { server = createMockServer(); server.listen(0, () => { port = server.address().port; resourceMonitor = new EventEmitter(); healthChecker = new EventEmitter(); updateManager = new EventEmitter(); wsServer = createDashboardWS(server, { resourceMonitor, healthChecker, updateManager, authVerifier: cookieValueVerifier(), log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, }); done(); }); }); afterEach((done) => { wsServer.close(); server.close(done); }); it('accepts connections at the upgrade path with a session cookie', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('open', () => ws.close()); ws.on('close', () => done()); ws.on('error', done); }); it('sends a connected event on join', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'connected') { expect(msg.data).toHaveProperty('clients'); ws.close(); done(); } }); ws.on('error', done); }); it('responds to ping with pong', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('open', () => { ws.send(JSON.stringify({ type: 'ping' })); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'pong') { ws.close(); done(); } }); ws.on('error', done); }); it('responds to subscribe with subscribed confirmation', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('open', () => { ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] })); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'subscribed') { expect(msg.events).toEqual(['resource-alert', 'incident']); ws.close(); done(); } }); ws.on('error', done); }); it('responds to client-count request', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('open', () => { ws.send(JSON.stringify({ type: 'client-count' })); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'client-count') { expect(msg.count).toBeGreaterThanOrEqual(1); ws.close(); done(); } }); ws.on('error', done); }); it('returns error for invalid JSON', (done) => { const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); ws.on('open', () => { ws.send('not json'); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'error') { expect(msg.error).toContain('Invalid JSON'); ws.close(); done(); } }); ws.on('error', done); }); it('tracks client count', () => { expect(wsServer.getClientCount()).toBe(0); }); it('broadcast method does not throw with no clients', () => { expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow(); }); }); // ───────────────────────────────────────────────────────────────────── // DC-061 auth gate tests // ───────────────────────────────────────────────────────────────────── describe('DC-061: WS upgrade auth gate', () => { let server, wsServer, port; beforeEach((done) => { server = createMockServer(); server.listen(0, () => { port = server.address().port; wsServer = createDashboardWS(server, { resourceMonitor: new EventEmitter(), healthChecker: new EventEmitter(), updateManager: new EventEmitter(), authVerifier: cookieValueVerifier(), log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, }); done(); }); }); afterEach((done) => { wsServer.close(); server.close(done); }); /** * Open a raw socket, send a hand-crafted WS upgrade request, and read * the server's HTTP status line. Avoids the ws library's auto-retry * behaviour so we get a deterministic single response. */ function probeUpgrade({ path, cookie, token } = {}) { return new Promise((resolve, reject) => { const net = require('net'); const sock = net.createConnection(port, '127.0.0.1'); let buf = ''; const headers = [ `GET ${path || '/api/v1/ws'} HTTP/1.1`, 'Host: 127.0.0.1', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', 'Sec-WebSocket-Version: 13', ]; if (cookie) headers.push(`Cookie: ${cookie}`); if (token) { const sep = path && path.includes('?') ? '&' : '?'; headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`); } sock.on('connect', () => { sock.write(headers.join('\r\n') + '\r\n\r\n'); }); sock.on('data', (chunk) => { buf += chunk.toString('utf8'); if (buf.includes('\r\n\r\n')) { sock.destroy(); const statusLine = buf.split('\r\n')[0]; const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10); resolve({ status, raw: buf }); } }); sock.on('error', (err) => { // Connection reset is fine — server destroys socket after 401. if (buf) resolve({ status: -1, raw: buf }); else reject(err); }); setTimeout(() => { if (!buf) { sock.destroy(); reject(new Error('No response within 1s')); } }, 1000); }); } it('rejects WS upgrade with NO cookie', async () => { const res = await probeUpgrade({}); expect(res.status).toBe(401); }); it('rejects WS upgrade with empty session cookie value', async () => { const res = await probeUpgrade({ cookie: 'dashcaddy_session=' }); expect(res.status).toBe(401); }); it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => { const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' }); expect(res.status).toBe(401); }); it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => { // Pre-DC-061: any 11+ char token in ?token=... granted WS access in // production. Post-fix: token query param is ignored entirely; only a // valid session cookie grants access. const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' }); expect(res.status).toBe(401); }); it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => { const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' }); expect(res.status).toBe(401); }); it('accepts upgrade when verifier returns true', async () => { const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' }); // 101 Switching Protocols for successful WS handshake expect(res.status).toBe(101); }); }); // ───────────────────────────────────────────────────────────────────── // DC-061 close() listener detach test (the SSE-poisoning regression) // ───────────────────────────────────────────────────────────────────── describe('DC-061: close() detaches only OUR listeners', () => { it('does NOT remove listeners attached by SSE route to shared emitters', () => { // Set up two "subscribers" on the same EventEmitter, simulating the // real-world shape: SSE route subscribes via `.on('alert', sseHandler)` // and dashboard-ws subscribes via `.on('alert', wsHandler)` to the // SAME resourceMonitor. Calling dashboard-ws.close() must remove // ONLY wsHandler — sseHandler must remain. const server = createMockServer(); const resourceMonitor = new EventEmitter(); // Pre-existing "SSE" listener (registered before dashboard-ws boots) const sseHandler = jest.fn(); resourceMonitor.on('alert', sseHandler); const wsServer = createDashboardWS(server, { resourceMonitor, healthChecker: new EventEmitter(), updateManager: new EventEmitter(), authVerifier: () => true, log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, }); // dashboard-ws added its own listener — verify it's there const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert'); expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler // Now close dashboard-ws — must not remove sseHandler wsServer.close(); const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert'); expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone // Confirm the surviving listener is the SSE one resourceMonitor.emit('alert', { test: true }); expect(sseHandler).toHaveBeenCalledWith({ test: true }); server.close(); }); it('is safe to call close() multiple times', () => { const server = createMockServer(); const wsServer = createDashboardWS(server, { resourceMonitor: new EventEmitter(), healthChecker: new EventEmitter(), updateManager: new EventEmitter(), authVerifier: () => true, log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, }); expect(() => { wsServer.close(); wsServer.close(); wsServer.close(); }).not.toThrow(); server.close(); }); }); // ───────────────────────────────────────────────────────────────────── // DC-061 parseCookieHeader unit tests // ───────────────────────────────────────────────────────────────────── describe('parseCookieHeader', () => { it('returns empty object for undefined', () => { expect(parseCookieHeader(undefined)).toEqual({}); }); it('returns empty object for empty string', () => { expect(parseCookieHeader('')).toEqual({}); }); it('parses a single cookie pair', () => { expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' }); }); it('parses multiple cookie pairs', () => { expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' }); }); it('trims whitespace around names and values', () => { expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' }); }); it('preserves dots/dashes in HMAC-shaped session cookie values', () => { // dashcaddy_session cookies are `.` — parseCookieHeader // must NOT url-decode (the HMAC verifier reads the raw value). expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({ dashcaddy_session: 'abc.def_123-XYZ', }); }); it('skips malformed pairs without `=`', () => { expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' }); }); it('skips empty name parts', () => { expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' }); }); });