// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from
"vitest" ;
import { createStorageMock } from
"../test-helpers/storage.ts" ;
import { loadDeviceAuthToken, storeDeviceAuthToken } from
"./device-auth.ts" ;
import type { DeviceIdentity } from
"./device-identity.ts" ;
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
vi.fn(
async (): Promise<DeviceIdentity> => ({
deviceId:
"device-1" ,
privateKey:
"private-key" ,
// pragma: allowlist secret
publicKey:
"public-key" ,
// pragma: allowlist secret
}),
),
);
const signDevicePayloadMock = vi.hoisted(() =>
vi.fn(async (_privateKeyBase64Url: string, _payload: string) =>
"signature" ),
);
type HandlerMap = {
close: MockWebSocketHandler[];
error: MockWebSocketHandler[];
message: MockWebSocketHandler[];
open: MockWebSocketHandler[];
};
type MockWebSocketHandler = (ev?: { code?: number; data?: string; reason?: string }) =>
void ;
class MockWebSocket {
static OPEN =
1 ;
readonly handlers: HandlerMap = {
close: [],
error: [],
message: [],
open: [],
};
readonly sent: string[] = [];
readyState = MockWebSocket.OPEN;
constructor(_url: string) {
wsInstances.push(
this );
}
addEventListener(type: keyof HandlerMap, handler: MockWebSocketHandler) {
this .handlers[type].push(handler);
}
send(data: string) {
this .sent.push(data);
}
close() {
this .readyState =
3 ;
}
emitClose(code =
1000 , reason =
"" ) {
for (
const handler of
this .handlers.close) {
handler({ code, reason });
}
}
emitOpen() {
for (
const handler of
this .handlers.open) {
handler();
}
}
emitMessage(data: unknown) {
const payload =
typeof data ===
"string" ? data : JSON.stringify(data);
for (
const handler of
this .handlers.message) {
handler({ data: payload });
}
}
}
vi.mock(
"./device-identity.ts" , () => ({
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
signDevicePayload: signDevicePayloadMock,
}));
const { CONTROL_UI_OPERATOR_SCOPES, GatewayBrowserClient, shouldRetryWithDeviceToken
} =
await import ("./gateway.ts" );
type ConnectFrame = {
id?: string;
method?: string;
params?: {
auth?: { token?: string; password?: string; deviceToken?: string };
scopes?: string[];
};
};
function stubWindowGlobals(storage?: ReturnType<typeof createStorageMock>) {
vi.stubGlobal("window" , {
location: { href: "http://127.0.0.1:18789/ " },
localStorage: storage,
setTimeout: (handler: (...args: unknown[]) => void , timeout?: number, ...args: unknown[]) => {
// Keep connect debounce behavior testable without paying real 750ms waits per handshake.
const effectiveTimeout = timeout === 750 ? 0 : timeout;
return globalThis.setTimeout(() => handler(...args), effectiveTimeout);
},
clearTimeout: (timeoutId: number | undefined) => globalThis.clearTimeout(timeoutId),
});
}
function getLatestWebSocket(): MockWebSocket {
const ws = wsInstances.at(-1 );
if (!ws) {
throw new Error("missing websocket instance" );
}
return ws;
}
function stubInsecureCrypto() {
vi.stubGlobal("crypto" , {
randomUUID: () => "req-insecure" ,
});
}
function parseLatestConnectFrame(ws: MockWebSocket): ConnectFrame {
return JSON.parse(ws.sent.at(-1 ) ?? "{}" ) as ConnectFrame;
}
async function continueConnect(ws: MockWebSocket, nonce = "nonce-1" ) {
ws.emitOpen();
ws.emitMessage({
type: "event" ,
event: "connect.challenge" ,
payload: { nonce },
});
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0 );
} else {
await new Promise((resolve) => setTimeout(resolve, 0 ));
}
expect(ws.sent.length).toBeGreaterThan(0 );
return { ws, connectFrame: parseLatestConnectFrame(ws) };
}
async function expectSocketClosed(ws: MockWebSocket) {
await vi.waitFor(() => expect(ws.readyState).toBe(3 ), { interval: 1 , timeout: 50 });
}
async function startConnect(client: InstanceType<typeof GatewayBrowserClient>, nonce = "nonce-1" ) {
client.start();
return await continueConnect(getLatestWebSocket(), nonce);
}
function emitRetryableTokenMismatch(ws: MockWebSocket, connectId: string | undefined) {
ws.emitMessage({
type: "res" ,
id: connectId,
ok: false ,
error: {
code: "INVALID_REQUEST" ,
message: "unauthorized" ,
details: { code: "AUTH_TOKEN_MISMATCH" , canRetryWithDeviceToken: true },
},
});
}
async function startRetriedDeviceTokenConnect(params: {
url: string;
token: string;
retryNonce?: string;
}) {
const client = new GatewayBrowserClient({
url: params.url,
token: params.token,
});
const { ws: firstWs, connectFrame: firstConnect } = await startConnect(client);
expect(firstConnect.params?.auth?.token).toBe(params.token);
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
emitRetryableTokenMismatch(firstWs, firstConnect.id);
await expectSocketClosed(firstWs);
firstWs.emitClose(4008 , "connect failed" );
await vi.advanceTimersByTimeAsync(800 );
const secondWs = getLatestWebSocket();
expect(secondWs).not.toBe(firstWs);
const { connectFrame: secondConnect } = await continueConnect(
secondWs,
params.retryNonce ?? "nonce-2" ,
);
expect(secondConnect.params?.auth?.token).toBe(params.token);
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token" );
return { client, firstWs, secondWs, firstConnect, secondConnect };
}
describe("GatewayBrowserClient" , () => {
beforeEach(() => {
const storage = createStorageMock();
wsInstances.length = 0 ;
loadOrCreateDeviceIdentityMock.mockReset();
signDevicePayloadMock.mockClear();
loadOrCreateDeviceIdentityMock.mockResolvedValue({
deviceId: "device-1" ,
privateKey: "private-key" , // pragma: allowlist secret
publicKey: "public-key" , // pragma: allowlist secret
});
vi.stubGlobal("localStorage" , storage);
stubWindowGlobals(storage);
localStorage.clear();
vi.stubGlobal("WebSocket" , MockWebSocket);
storeDeviceAuthToken({
deviceId: "device-1" ,
role: "operator" ,
token: "stored-device-token" ,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("requests the full control ui operator scope bundle on connect" , async () => {
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]);
});
it("prefers explicit shared auth over cached device tokens" , async () => {
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
const { connectFrame } = await startConnect(client);
expect(typeof connectFrame.id).toBe("string" );
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token" );
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key" , expect.any(String));
const signedPayload = signDevicePayloadMock.mock.calls[0 ]?.[1 ];
expect(signedPayload).toContain("|shared-auth-token|nonce-1" );
expect(signedPayload).not.toContain("stored-device-token" );
});
it("sends explicit shared token on insecure first connect without cached device fallback" , async () => {
stubInsecureCrypto();
const client = new GatewayBrowserClient({
url: "ws://gateway.example:18789",
token: "shared-auth-token" ,
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.id).toBe("req-insecure" );
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.auth).toEqual({
token: "shared-auth-token" ,
password: undefined,
deviceToken: undefined,
});
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
expect(signDevicePayloadMock).not.toHaveBeenCalled();
});
it("sends explicit shared password on insecure first connect without cached device fallback" , async () => {
stubInsecureCrypto();
const client = new GatewayBrowserClient({
url: "ws://gateway.example:18789",
password: "shared-password" , // pragma: allowlist secret
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.id).toBe("req-insecure" );
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.auth).toEqual({
token: undefined,
password: "shared-password" , // pragma: allowlist secret
deviceToken: undefined,
});
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
expect(signDevicePayloadMock).not.toHaveBeenCalled();
});
it("uses cached device tokens only when no explicit shared auth is provided" , async () => {
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
});
const { connectFrame } = await startConnect(client);
expect(typeof connectFrame.id).toBe("string" );
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.auth?.token).toBe("stored-device-token" );
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key" , expect.any(String));
const signedPayload = signDevicePayloadMock.mock.calls[0 ]?.[1 ];
expect(signedPayload).toContain("|stored-device-token|nonce-1" );
});
it("ignores cached operator device tokens that do not include read access" , async () => {
localStorage.clear();
storeDeviceAuthToken({
deviceId: "device-1" ,
role: "operator" ,
token: "under-scoped-device-token" ,
scopes: [],
});
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.method).toBe("connect" );
expect(connectFrame.params?.auth?.token).toBeUndefined();
const signedPayload = signDevicePayloadMock.mock.calls[0 ]?.[1 ];
expect(signedPayload).not.toContain("under-scoped-device-token" );
});
it("retries once with device token after token mismatch when shared token is explicit" , async () => {
vi.useFakeTimers();
const { secondWs, secondConnect } = await startRetriedDeviceTokenConnect({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
secondWs.emitMessage({
type: "res" ,
id: secondConnect.id,
ok: false ,
error: {
code: "INVALID_REQUEST" ,
message: "unauthorized" ,
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await expectSocketClosed(secondWs);
secondWs.emitClose(4008 , "connect failed" );
expect(loadDeviceAuthToken({ deviceId: "device-1" , role: "operator" })?.token).toBe(
"stored-device-token" ,
);
await vi.advanceTimersByTimeAsync(30 _000 );
expect(wsInstances).toHaveLength(2 );
vi.useRealTimers();
});
it("treats IPv6 loopback as trusted for bounded device-token retry" , async () => {
vi.useFakeTimers();
const { client } = await startRetriedDeviceTokenConnect({
url: "ws://[::1]:18789",
token: "shared-auth-token" ,
});
client.stop();
vi.useRealTimers();
});
it("continues reconnecting on first token mismatch when no retry was attempted" , async () => {
vi.useFakeTimers();
localStorage.clear();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
ws1.emitMessage({
type: "res" ,
id: firstConnect.id,
ok: false ,
error: {
code: "INVALID_REQUEST" ,
message: "unauthorized" ,
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await expectSocketClosed(ws1);
ws1.emitClose(4008 , "connect failed" );
await vi.advanceTimersByTimeAsync(800 );
expect(wsInstances).toHaveLength(2 );
client.stop();
vi.useRealTimers();
});
it("cancels a queued connect send when stopped before the timeout fires" , async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
client.start();
const ws = getLatestWebSocket();
ws.emitOpen();
client.stop();
await vi.advanceTimersByTimeAsync(750 );
expect(ws.sent).toHaveLength(0 );
vi.useRealTimers();
});
it("cancels a scheduled reconnect when stopped before the retry fires" , async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token" ,
});
client.start();
const ws = getLatestWebSocket();
ws.emitClose(1006 , "socket lost" );
client.stop();
await vi.advanceTimersByTimeAsync(30 _000 );
expect(wsInstances).toHaveLength(1 );
vi.useRealTimers();
});
it("does not auto-reconnect on AUTH_TOKEN_MISSING" , async () => {
vi.useFakeTimers();
localStorage.clear();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
});
const { ws: ws1, connectFrame: connect } = await startConnect(client);
ws1.emitMessage({
type: "res" ,
id: connect.id,
ok: false ,
error: {
code: "INVALID_REQUEST" ,
message: "unauthorized" ,
details: { code: "AUTH_TOKEN_MISSING" },
},
});
await expectSocketClosed(ws1);
ws1.emitClose(4008 , "connect failed" );
await vi.advanceTimersByTimeAsync(30 _000 );
expect(wsInstances).toHaveLength(1 );
vi.useRealTimers();
});
});
describe("shouldRetryWithDeviceToken" , () => {
beforeEach(() => {
stubWindowGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("allows a bounded retry for trusted loopback endpoints" , () => {
expect(
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: false ,
authDeviceToken: undefined,
explicitGatewayToken: "shared-auth-token" ,
deviceIdentity: {
deviceId: "device-1" ,
privateKey: "private-key" , // pragma: allowlist secret
publicKey: "public-key" , // pragma: allowlist secret
},
storedToken: "stored-device-token" ,
canRetryWithDeviceTokenHint: true ,
url: "ws://127.0.0.1:18789",
}),
).toBe(true );
});
it("blocks the retry after the one-shot budget is spent" , () => {
expect(
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: true ,
authDeviceToken: undefined,
explicitGatewayToken: "shared-auth-token" ,
deviceIdentity: {
deviceId: "device-1" ,
privateKey: "private-key" , // pragma: allowlist secret
publicKey: "public-key" , // pragma: allowlist secret
},
storedToken: "stored-device-token" ,
canRetryWithDeviceTokenHint: true ,
url: "ws://127.0.0.1:18789",
}),
).toBe(false );
});
});
Messung V0.5 in Prozent C=96 H=97 G=96
¤ Dauer der Verarbeitung: 0.21 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland