Covers the case where authMode is 'password' and deviceIdentity is present. Asserts that buildDeviceAuthPayload is called with token:null (not the password value) and that the connect request uses auth.password, not auth.token. Addresses Copilot review comment on PR #22.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { GatewayClient } from '../gateway';
|
|
import type { DeviceIdentity } from '../deviceIdentity';
|
|
import * as deviceIdentityModule from '../deviceIdentity';
|
|
|
|
vi.mock('../deviceIdentity', () => ({
|
|
buildDeviceAuthPayload: vi.fn(),
|
|
signPayload: vi.fn(),
|
|
}));
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Minimal WebSocket mock */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type WSListener = (ev: { data: string } | { code: number; reason: string } | Event) => void;
|
|
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
static instances: MockWebSocket[] = [];
|
|
|
|
readyState = MockWebSocket.OPEN;
|
|
onopen: WSListener | null = null;
|
|
onmessage: WSListener | null = null;
|
|
onclose: WSListener | null = null;
|
|
onerror: WSListener | null = null;
|
|
sent: string[] = [];
|
|
url: string;
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
MockWebSocket.instances.push(this);
|
|
// Simulate async open
|
|
setTimeout(() => this.onopen?.({} as Event), 0);
|
|
}
|
|
|
|
send(data: string) { this.sent.push(data); }
|
|
|
|
close() {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
this.onclose?.({ code: 1000, reason: 'normal' } as never);
|
|
}
|
|
|
|
/** Helper: simulate server sending a message */
|
|
_receive(data: unknown) {
|
|
this.onmessage?.({ data: JSON.stringify(data) } as never);
|
|
}
|
|
|
|
static reset() { MockWebSocket.instances = []; }
|
|
}
|
|
|
|
// Patch global
|
|
const originalWS = globalThis.WebSocket;
|
|
|
|
beforeEach(() => {
|
|
MockWebSocket.reset();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).WebSocket = MockWebSocket;
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Close any lingering mock WebSockets to prevent leaked timers
|
|
for (const ws of MockWebSocket.instances) {
|
|
if (ws.readyState !== MockWebSocket.CLOSED) {
|
|
ws.readyState = MockWebSocket.CLOSED;
|
|
}
|
|
}
|
|
vi.clearAllTimers();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).WebSocket = originalWS;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Tests */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
describe('GatewayClient', () => {
|
|
it('initialises with default URL when none provided', () => {
|
|
// GatewayClient falls back to window.location.hostname — mock it for Node env
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).window = { location: { hostname: 'localhost' } };
|
|
const gw = new GatewayClient();
|
|
expect(gw.isConnected).toBe(false);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
delete (globalThis as any).window;
|
|
});
|
|
|
|
it('connects and handles challenge → connect flow', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok123');
|
|
const statuses: string[] = [];
|
|
gw.onStatus(s => statuses.push(s));
|
|
|
|
gw.connect();
|
|
expect(statuses).toContain('connecting');
|
|
|
|
// Let the setTimeout in MockWebSocket fire (onopen)
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
|
|
// Server sends challenge
|
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
|
|
|
// handleChallenge is async — flush microtasks
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// Client should have sent a connect request
|
|
expect(ws.sent.length).toBe(1);
|
|
const req = JSON.parse(ws.sent[0]!);
|
|
expect(req.method).toBe('connect');
|
|
expect(req.params.auth.token).toBe('tok123');
|
|
|
|
// Server responds ok
|
|
ws._receive({ type: 'res', id: req.id, ok: true, payload: { session: 'abc' } });
|
|
|
|
// Allow microtasks
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(gw.isConnected).toBe(true);
|
|
expect(statuses).toContain('connected');
|
|
});
|
|
|
|
it('disconnects cleanly', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
const statuses: string[] = [];
|
|
gw.onStatus(s => statuses.push(s));
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
gw.disconnect();
|
|
expect(gw.isConnected).toBe(false);
|
|
expect(statuses[statuses.length - 1]).toBe('disconnected');
|
|
});
|
|
|
|
it('routes events to registered handlers', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
gw.onEvent((event, payload) => events.push({ event, payload }));
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'chat.message', payload: { text: 'hello' } });
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.event).toBe('chat.message');
|
|
expect(events[0]!.payload.text).toBe('hello');
|
|
});
|
|
|
|
it('unsubscribes event handler when disposer is called', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
const events: string[] = [];
|
|
const unsub = gw.onEvent((event) => events.push(event));
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'first' });
|
|
unsub();
|
|
ws._receive({ type: 'event', event: 'second' });
|
|
|
|
expect(events).toEqual(['first']);
|
|
});
|
|
|
|
it('resolves send() promise on success response', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
// Complete the challenge first
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const connectReq = JSON.parse(ws.sent[0]!);
|
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const promise = gw.send('sessions.list', { limit: 10 });
|
|
const sendReq = JSON.parse(ws.sent[1]!);
|
|
ws._receive({ type: 'res', id: sendReq.id, ok: true, payload: { sessions: [] } });
|
|
|
|
const result = await promise;
|
|
expect(result).toEqual({ sessions: [] });
|
|
});
|
|
|
|
it('rejects send() promise on error response', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const connectReq = JSON.parse(ws.sent[0]!);
|
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const promise = gw.send('bad.method', {});
|
|
const sendReq = JSON.parse(ws.sent[1]!);
|
|
ws._receive({ type: 'res', id: sendReq.id, ok: false, error: 'not found' });
|
|
|
|
await expect(promise).rejects.toBe('not found');
|
|
});
|
|
|
|
it('rejects send() when not connected', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
await expect(gw.send('foo', {})).rejects.toThrow('not connected');
|
|
});
|
|
|
|
it('times out pending requests after 30s', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const connectReq = JSON.parse(ws.sent[0]!);
|
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const promise = gw.send('slow.method', {});
|
|
|
|
// Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
|
|
const rejection = promise.catch((e: Error) => e);
|
|
|
|
// Advance past the 30s timeout
|
|
await vi.advanceTimersByTimeAsync(31000);
|
|
|
|
const error = await rejection;
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toBe('timeout');
|
|
|
|
// Clean up: disconnect to prevent reconnect timers from firing after test
|
|
gw.disconnect();
|
|
});
|
|
|
|
it('schedules reconnect on unexpected close', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
const statuses: string[] = [];
|
|
gw.onStatus(s => statuses.push(s));
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
// Simulate unexpected close (not from disconnect())
|
|
ws.readyState = MockWebSocket.CLOSED;
|
|
ws.onclose?.({ code: 1006, reason: 'abnormal' } as never);
|
|
|
|
expect(statuses).toContain('disconnected');
|
|
|
|
// After reconnect delay, a new WebSocket should be created
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
|
|
|
// Clean up to prevent leaked timers
|
|
gw.disconnect();
|
|
});
|
|
|
|
it('does not reconnect after explicit disconnect()', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
gw.disconnect();
|
|
|
|
await vi.advanceTimersByTimeAsync(60000);
|
|
// Only the original WebSocket should exist
|
|
expect(MockWebSocket.instances).toHaveLength(1);
|
|
});
|
|
|
|
it('setCredentials updates URL and token', () => {
|
|
const gw = new GatewayClient('ws://old:1234', 'old-tok');
|
|
gw.setCredentials('ws://new:5678', 'new-tok');
|
|
|
|
// Connect with new credentials
|
|
gw.connect();
|
|
const ws = MockWebSocket.instances[0]!;
|
|
expect(ws.url).toBe('ws://new:5678');
|
|
});
|
|
|
|
it('extracts nonce from challenge payload', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
// Server sends challenge with nonce
|
|
ws._receive({ type: 'event', event: 'connect.challenge', payload: { nonce: 'test-nonce-123' } });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const req = JSON.parse(ws.sent[0]!);
|
|
expect(req.method).toBe('connect');
|
|
// Device object won't be set (no identity), but the connect should still work
|
|
expect(req.params.auth.token).toBe('tok');
|
|
|
|
// Clean up
|
|
gw.disconnect();
|
|
});
|
|
|
|
it('password mode with deviceIdentity: signs with token:null and sends auth.password', async () => {
|
|
const buildPayload = vi.mocked(deviceIdentityModule.buildDeviceAuthPayload);
|
|
const sign = vi.mocked(deviceIdentityModule.signPayload);
|
|
buildPayload.mockReturnValue('mock-device-payload');
|
|
sign.mockResolvedValue('mock-sig');
|
|
|
|
// In password mode authToken holds the password string, not a JWT/token.
|
|
// buildDeviceAuthPayload must receive token:null so the gateway signature
|
|
// verification matches (gateway sees no token segment in the connect request).
|
|
const gw = new GatewayClient('ws://test:1234', 'my-secret-password', 'password');
|
|
const mockIdentity: DeviceIdentity = {
|
|
id: 'device-id-abc',
|
|
publicKeyRaw: 'pubkey-raw-abc',
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
keyPair: { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey },
|
|
};
|
|
gw.setDeviceIdentity(mockIdentity);
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'connect.challenge', payload: { nonce: 'nonce-xyz' } });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// buildDeviceAuthPayload must be called with token: null (not the password)
|
|
expect(buildPayload).toHaveBeenCalledWith(
|
|
expect.objectContaining({ token: null }),
|
|
);
|
|
|
|
// The connect request must use auth.password, not auth.token
|
|
const req = JSON.parse(ws.sent[0]!);
|
|
expect(req.method).toBe('connect');
|
|
expect(req.params.auth.password).toBe('my-secret-password');
|
|
expect(req.params.auth.token).toBeUndefined();
|
|
|
|
gw.disconnect();
|
|
});
|
|
|
|
it('emits pairing status on NOT_PAIRED error', async () => {
|
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
|
const statuses: string[] = [];
|
|
gw.onStatus(s => statuses.push(s));
|
|
|
|
gw.connect();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const req = JSON.parse(ws.sent[0]!);
|
|
// Server rejects with NOT_PAIRED
|
|
ws._receive({ type: 'res', id: req.id, ok: false, payload: { code: 'NOT_PAIRED', message: 'Device not paired' } });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(statuses).toContain('pairing');
|
|
|
|
// Clean up
|
|
gw.disconnect();
|
|
});
|
|
});
|