test: add regression test for password-mode device signing

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.
This commit is contained in:
Marcel Hild
2026-03-09 09:19:43 +01:00
parent 5a6179aa8b
commit daeac3d631

View File

@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GatewayClient } from '../gateway'; 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 */ /* Minimal WebSocket mock */
@@ -50,6 +57,7 @@ beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).WebSocket = MockWebSocket; (globalThis as any).WebSocket = MockWebSocket;
vi.useFakeTimers(); vi.useFakeTimers();
vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
@@ -297,6 +305,45 @@ describe('GatewayClient', () => {
gw.disconnect(); 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 () => { it('emits pairing status on NOT_PAIRED error', async () => {
const gw = new GatewayClient('ws://test:1234', 'tok'); const gw = new GatewayClient('ws://test:1234', 'tok');
const statuses: string[] = []; const statuses: string[] = [];