Files
MindSift/frontend/src/lib/__tests__/realtime.test.ts
T
sandrodachandClaude Opus 5.5 f57a9884d1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
fix(security): stream tickets instead of session tokens in URLs (FLAW-22)
WebSocket and EventSource put the 30-day session token into the URL,
where proxies log it. The app now gets a one-time ticket right before
connecting (POST /auth/stream-ticket, 60 s, bound to the token version);
?token= is no longer accepted for streams, non-browser clients may use
the Authorization header.

With BASE_URL set, CORS allows only that origin and WebSocket upgrades
only it or the requested host; credentials are off (no cookies exist).
Logout, ending all sessions, a new password and deactivation close all
live streams of the account; clients with a valid session reconnect
with a fresh ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:46:58 +01:00

141 lines
4.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Minimal stand-ins: tests decide when connections open and which messages arrive.
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: ((e: { wasClean: boolean; code: number }) => void) | null = null;
onerror: ((e: unknown) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = FakeWebSocket.CLOSED;
}
}
class FakeEventSource {
static instances: FakeEventSource[] = [];
closed = false;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onerror: ((e: unknown) => void) | null = null;
constructor(public url: string) {
FakeEventSource.instances.push(this);
}
open() {
this.onopen?.();
}
emit(event: string, payload: unknown = {}) {
this.onmessage?.({ data: JSON.stringify({ event, payload }) });
}
close() {
this.closed = true;
}
}
async function loadApi() {
vi.resetModules(); // fresh module state (transport flags) for every test
return import('../api');
}
describe('realtime fallback', () => {
beforeEach(() => {
vi.useFakeTimers();
FakeWebSocket.instances = [];
FakeEventSource.instances = [];
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.stubGlobal('EventSource', FakeEventSource);
sessionStorage.clear();
localStorage.setItem('mindsift_jwt_token', 'test-token');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 })));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a WebSocket that stays silent is replaced by SSE', async () => {
const api = await loadApi();
const states: string[] = [];
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
await api.connectWebSocket();
FakeWebSocket.instances[0].open();
expect(api.getRealtimeTransport()).toBe('ws');
await vi.advanceTimersByTimeAsync(15_000);
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ event: 'PING' }));
await vi.advanceTimersByTimeAsync(40_000); // no message at all, not even PONG
expect(FakeEventSource.instances).toHaveLength(1);
FakeEventSource.instances[0].open();
FakeEventSource.instances[0].emit('SSE_CONNECTED', { sseId: 'sse_1' });
expect(api.getRealtimeTransport()).toBe('sse');
api.disconnectWebSocket();
});
it('connects with a one-time ticket, never with the session token in the URL', async () => {
const api = await loadApi();
await api.connectWebSocket();
expect(FakeWebSocket.instances[0].url).toContain('ticket=t1');
expect(FakeWebSocket.instances[0].url).not.toContain('test-token');
api.disconnectWebSocket();
});
it('a WebSocket answering PONG stays in use', async () => {
const api = await loadApi();
await api.connectWebSocket();
const ws = FakeWebSocket.instances[0];
ws.open();
for (let i = 0; i < 6; i++) {
await vi.advanceTimersByTimeAsync(15_000);
ws.onmessage?.({ data: JSON.stringify({ event: 'PONG' }) });
}
expect(FakeEventSource.instances).toHaveLength(0);
expect(api.getRealtimeTransport()).toBe('ws');
api.disconnectWebSocket();
});
it('SSE only counts as connected once the first event arrives', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
expect(api.isRealtimeConnected()).toBe(false);
es.emit('SSE_CONNECTED', { sseId: 'sse_1' });
expect(api.isRealtimeConnected()).toBe(true);
await vi.advanceTimersByTimeAsync(10_000);
expect(api.isRealtimeConnected()).toBe(true);
api.disconnectWebSocket();
});
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
await vi.advanceTimersByTimeAsync(5_500);
expect(es.closed).toBe(true);
expect(api.isRealtimeConnected()).toBe(false);
expect(sessionStorage.getItem('mindsift_sse_buffered')).toBe('true');
await vi.advanceTimersByTimeAsync(60_000);
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
api.disconnectWebSocket();
});
});