refactor: share gateway client auth retry helpers

This commit is contained in:
Peter Steinberger 2026-03-13 18:49:24 +00:00
parent 60dc46ad10
commit 5f34391f75
1 changed files with 99 additions and 106 deletions

View File

@ -344,6 +344,20 @@ describe("GatewayClient connect auth payload", () => {
return parsed.params?.auth ?? {}; return parsed.params?.auth ?? {};
} }
function connectRequestFrom(ws: MockWebSocket) {
const raw = ws.sent.find((frame) => frame.includes('"method":"connect"'));
expect(raw).toBeTruthy();
return JSON.parse(raw ?? "{}") as {
id?: string;
params?: {
auth?: {
token?: string;
deviceToken?: string;
};
};
};
}
function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") { function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") {
ws.emitMessage( ws.emitMessage(
JSON.stringify({ JSON.stringify({
@ -354,6 +368,63 @@ describe("GatewayClient connect auth payload", () => {
); );
} }
function startClientAndConnect(params: { client: GatewayClient; nonce?: string }) {
params.client.start();
const ws = getLatestWs();
ws.emitOpen();
emitConnectChallenge(ws, params.nonce);
return { ws, connect: connectRequestFrom(ws) };
}
function emitConnectFailure(
ws: MockWebSocket,
connectId: string | undefined,
details: Record<string, unknown>,
) {
ws.emitMessage(
JSON.stringify({
type: "res",
id: connectId,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details,
},
}),
);
}
async function expectRetriedConnectAuth(params: {
firstWs: MockWebSocket;
connectId: string | undefined;
failureDetails: Record<string, unknown>;
}) {
emitConnectFailure(params.firstWs, params.connectId, params.failureDetails);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws = getLatestWs();
ws.emitOpen();
emitConnectChallenge(ws, "nonce-2");
return connectFrameFrom(ws);
}
async function expectNoReconnectAfterConnectFailure(params: {
client: GatewayClient;
firstWs: MockWebSocket;
connectId: string | undefined;
failureDetails: Record<string, unknown>;
}) {
vi.useFakeTimers();
try {
emitConnectFailure(params.firstWs, params.connectId, params.failureDetails);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
} finally {
params.client.stop();
vi.useRealTimers();
}
}
it("uses explicit shared token and does not inject stored device token", () => { it("uses explicit shared token and does not inject stored device token", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({ const client = new GatewayClient({
@ -457,37 +528,16 @@ describe("GatewayClient connect auth payload", () => {
token: "shared-token", token: "shared-token",
}); });
client.start(); const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
const ws1 = getLatestWs();
ws1.emitOpen();
emitConnectChallenge(ws1);
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
expect(firstConnectRaw).toBeTruthy();
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as {
id?: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-token"); expect(firstConnect.params?.auth?.token).toBe("shared-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
ws1.emitMessage( const retriedAuth = await expectRetriedConnectAuth({
JSON.stringify({ firstWs: ws1,
type: "res", connectId: firstConnect.id,
id: firstConnect.id, failureDetails: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
ok: false, });
error: { expect(retriedAuth).toMatchObject({
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
}),
);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws2 = getLatestWs();
ws2.emitOpen();
emitConnectChallenge(ws2, "nonce-2");
expect(connectFrameFrom(ws2)).toMatchObject({
token: "shared-token", token: "shared-token",
deviceToken: "stored-device-token", deviceToken: "stored-device-token",
}); });
@ -501,32 +551,13 @@ describe("GatewayClient connect auth payload", () => {
token: "shared-token", token: "shared-token",
}); });
client.start(); const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
const ws1 = getLatestWs(); const retriedAuth = await expectRetriedConnectAuth({
ws1.emitOpen(); firstWs: ws1,
emitConnectChallenge(ws1); connectId: firstConnect.id,
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); failureDetails: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" },
expect(firstConnectRaw).toBeTruthy(); });
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; expect(retriedAuth).toMatchObject({
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" },
},
}),
);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws2 = getLatestWs();
ws2.emitOpen();
emitConnectChallenge(ws2, "nonce-2");
expect(connectFrameFrom(ws2)).toMatchObject({
token: "shared-token", token: "shared-token",
deviceToken: "stored-device-token", deviceToken: "stored-device-token",
}); });
@ -534,71 +565,33 @@ describe("GatewayClient connect auth payload", () => {
}); });
it("does not auto-reconnect on AUTH_TOKEN_MISSING connect failures", async () => { it("does not auto-reconnect on AUTH_TOKEN_MISSING connect failures", async () => {
vi.useFakeTimers();
const client = new GatewayClient({ const client = new GatewayClient({
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",
token: "shared-token", token: "shared-token",
}); });
client.start(); const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
const ws1 = getLatestWs(); await expectNoReconnectAfterConnectFailure({
ws1.emitOpen(); client,
emitConnectChallenge(ws1); firstWs: ws1,
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); connectId: firstConnect.id,
expect(firstConnectRaw).toBeTruthy(); failureDetails: { code: "AUTH_TOKEN_MISSING" },
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; });
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISSING" },
},
}),
);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
client.stop();
vi.useRealTimers();
}); });
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => { it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
vi.useFakeTimers();
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({ const client = new GatewayClient({
url: "wss://gateway.example.com:18789", url: "wss://gateway.example.com:18789",
token: "shared-token", token: "shared-token",
}); });
client.start(); const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
const ws1 = getLatestWs(); await expectNoReconnectAfterConnectFailure({
ws1.emitOpen(); client,
emitConnectChallenge(ws1); firstWs: ws1,
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); connectId: firstConnect.id,
expect(firstConnectRaw).toBeTruthy(); failureDetails: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; });
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
}),
);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
client.stop();
vi.useRealTimers();
}); });
}); });