Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: ad38d1a5297ff897b2f4b79c5e126ec215a28e48 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky
262 lines
7.6 KiB
TypeScript
262 lines
7.6 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { DeviceIdentity } from "../infra/device-identity.js";
|
|
|
|
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
|
|
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
|
|
const clearDevicePairingMock = vi.hoisted(() => vi.fn());
|
|
const logDebugMock = vi.hoisted(() => vi.fn());
|
|
|
|
type WsEvent = "open" | "message" | "close" | "error";
|
|
type WsEventHandlers = {
|
|
open: () => void;
|
|
message: (data: string | Buffer) => void;
|
|
close: (code: number, reason: Buffer) => void;
|
|
error: (err: unknown) => void;
|
|
};
|
|
|
|
class MockWebSocket {
|
|
private openHandlers: WsEventHandlers["open"][] = [];
|
|
private messageHandlers: WsEventHandlers["message"][] = [];
|
|
private closeHandlers: WsEventHandlers["close"][] = [];
|
|
private errorHandlers: WsEventHandlers["error"][] = [];
|
|
|
|
constructor(_url: string, _options?: unknown) {
|
|
wsInstances.push(this);
|
|
}
|
|
|
|
on(event: "open", handler: WsEventHandlers["open"]): void;
|
|
on(event: "message", handler: WsEventHandlers["message"]): void;
|
|
on(event: "close", handler: WsEventHandlers["close"]): void;
|
|
on(event: "error", handler: WsEventHandlers["error"]): void;
|
|
on(event: WsEvent, handler: WsEventHandlers[WsEvent]): void {
|
|
switch (event) {
|
|
case "open":
|
|
this.openHandlers.push(handler as WsEventHandlers["open"]);
|
|
return;
|
|
case "message":
|
|
this.messageHandlers.push(handler as WsEventHandlers["message"]);
|
|
return;
|
|
case "close":
|
|
this.closeHandlers.push(handler as WsEventHandlers["close"]);
|
|
return;
|
|
case "error":
|
|
this.errorHandlers.push(handler as WsEventHandlers["error"]);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
close(_code?: number, _reason?: string): void {}
|
|
|
|
emitClose(code: number, reason: string): void {
|
|
for (const handler of this.closeHandlers) {
|
|
handler(code, Buffer.from(reason));
|
|
}
|
|
}
|
|
}
|
|
|
|
vi.mock("ws", () => ({
|
|
WebSocket: MockWebSocket,
|
|
}));
|
|
|
|
vi.mock("../infra/device-auth-store.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../infra/device-auth-store.js")>();
|
|
return {
|
|
...actual,
|
|
clearDeviceAuthToken: (...args: unknown[]) => clearDeviceAuthTokenMock(...args),
|
|
};
|
|
});
|
|
|
|
vi.mock("../infra/device-pairing.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../infra/device-pairing.js")>();
|
|
return {
|
|
...actual,
|
|
clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args),
|
|
};
|
|
});
|
|
|
|
vi.mock("../logger.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../logger.js")>();
|
|
return {
|
|
...actual,
|
|
logDebug: (...args: unknown[]) => logDebugMock(...args),
|
|
};
|
|
});
|
|
|
|
const { GatewayClient } = await import("./client.js");
|
|
|
|
function getLatestWs(): MockWebSocket {
|
|
const ws = wsInstances.at(-1);
|
|
if (!ws) {
|
|
throw new Error("missing mock websocket instance");
|
|
}
|
|
return ws;
|
|
}
|
|
|
|
describe("GatewayClient security checks", () => {
|
|
beforeEach(() => {
|
|
wsInstances.length = 0;
|
|
});
|
|
|
|
it("blocks ws:// to non-loopback addresses (CWE-319)", () => {
|
|
const onConnectError = vi.fn();
|
|
const client = new GatewayClient({
|
|
url: "ws://remote.example.com:18789",
|
|
onConnectError,
|
|
});
|
|
|
|
client.start();
|
|
|
|
expect(onConnectError).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("SECURITY ERROR"),
|
|
}),
|
|
);
|
|
expect(wsInstances.length).toBe(0); // No WebSocket created
|
|
client.stop();
|
|
});
|
|
|
|
it("handles malformed URLs gracefully without crashing", () => {
|
|
const onConnectError = vi.fn();
|
|
const client = new GatewayClient({
|
|
url: "not-a-valid-url",
|
|
onConnectError,
|
|
});
|
|
|
|
// Should not throw
|
|
expect(() => client.start()).not.toThrow();
|
|
|
|
expect(onConnectError).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("SECURITY ERROR"),
|
|
}),
|
|
);
|
|
expect(wsInstances.length).toBe(0); // No WebSocket created
|
|
client.stop();
|
|
});
|
|
|
|
it("allows ws:// to loopback addresses", () => {
|
|
const onConnectError = vi.fn();
|
|
const client = new GatewayClient({
|
|
url: "ws://127.0.0.1:18789",
|
|
onConnectError,
|
|
});
|
|
|
|
client.start();
|
|
|
|
expect(onConnectError).not.toHaveBeenCalled();
|
|
expect(wsInstances.length).toBe(1); // WebSocket created
|
|
client.stop();
|
|
});
|
|
|
|
it("allows wss:// to any address", () => {
|
|
const onConnectError = vi.fn();
|
|
const client = new GatewayClient({
|
|
url: "wss://remote.example.com:18789",
|
|
onConnectError,
|
|
});
|
|
|
|
client.start();
|
|
|
|
expect(onConnectError).not.toHaveBeenCalled();
|
|
expect(wsInstances.length).toBe(1); // WebSocket created
|
|
client.stop();
|
|
});
|
|
});
|
|
|
|
describe("GatewayClient close handling", () => {
|
|
beforeEach(() => {
|
|
wsInstances.length = 0;
|
|
clearDeviceAuthTokenMock.mockReset();
|
|
clearDevicePairingMock.mockReset();
|
|
clearDevicePairingMock.mockResolvedValue(true);
|
|
logDebugMock.mockReset();
|
|
});
|
|
|
|
it("clears stale token on device token mismatch close", () => {
|
|
const onClose = vi.fn();
|
|
const identity: DeviceIdentity = {
|
|
deviceId: "dev-1",
|
|
privateKeyPem: "private-key",
|
|
publicKeyPem: "public-key",
|
|
};
|
|
const client = new GatewayClient({
|
|
url: "ws://127.0.0.1:18789",
|
|
deviceIdentity: identity,
|
|
onClose,
|
|
});
|
|
|
|
client.start();
|
|
getLatestWs().emitClose(
|
|
1008,
|
|
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
|
|
);
|
|
|
|
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" });
|
|
expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1");
|
|
expect(onClose).toHaveBeenCalledWith(
|
|
1008,
|
|
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
|
|
);
|
|
client.stop();
|
|
});
|
|
|
|
it("does not break close flow when token clear throws", () => {
|
|
clearDeviceAuthTokenMock.mockImplementation(() => {
|
|
throw new Error("disk unavailable");
|
|
});
|
|
const onClose = vi.fn();
|
|
const identity: DeviceIdentity = {
|
|
deviceId: "dev-2",
|
|
privateKeyPem: "private-key",
|
|
publicKeyPem: "public-key",
|
|
};
|
|
const client = new GatewayClient({
|
|
url: "ws://127.0.0.1:18789",
|
|
deviceIdentity: identity,
|
|
onClose,
|
|
});
|
|
|
|
client.start();
|
|
expect(() => {
|
|
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
|
|
}).not.toThrow();
|
|
|
|
expect(logDebugMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("failed clearing stale device-auth token"),
|
|
);
|
|
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
|
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
|
client.stop();
|
|
});
|
|
|
|
it("does not break close flow when pairing clear rejects", async () => {
|
|
clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable"));
|
|
const onClose = vi.fn();
|
|
const identity: DeviceIdentity = {
|
|
deviceId: "dev-3",
|
|
privateKeyPem: "private-key",
|
|
publicKeyPem: "public-key",
|
|
};
|
|
const client = new GatewayClient({
|
|
url: "ws://127.0.0.1:18789",
|
|
deviceIdentity: identity,
|
|
onClose,
|
|
});
|
|
|
|
client.start();
|
|
expect(() => {
|
|
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
|
|
}).not.toThrow();
|
|
|
|
await Promise.resolve();
|
|
expect(logDebugMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("failed clearing stale device pairing"),
|
|
);
|
|
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
|
client.stop();
|
|
});
|
|
});
|