diff --git a/tests/connection-heartbeat-failures.test.ts b/tests/connection-heartbeat-failures.test.ts new file mode 100644 index 0000000..659e16f --- /dev/null +++ b/tests/connection-heartbeat-failures.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { buildHeartbeat, decodeBuiltin, encodeBuiltin } from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js"; +import type { YonexusServerStore } from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; + +function createMockSocket() { + return { close: vi.fn() } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-connection-heartbeat-failures.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +function createMockTransport() { + const sent: Array<{ connection: ClientConnection; message: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(), + stop: vi.fn(), + send: vi.fn(), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sent.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn(), + promoteToAuthenticated: vi.fn(), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn() + }; + + return { transport, sent }; +} + +describe("YNX-1105d: Connection & Heartbeat Failure Paths", () => { + let now = 1_710_000_000; + + beforeEach(() => { + now = 1_710_000_000; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("CF-06: unauthenticated rule message closes connection", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: undefined + }); + + await runtime.handleMessage(connection, "chat::hello"); + + expect(connection.ws.close).toHaveBeenCalledWith(1008, "Not authenticated"); + }); + + it("HF-03: heartbeat before auth returns error", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport, sent } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: undefined + }); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHeartbeat( + { identifier: "client-a", status: "alive" }, + { requestId: "req-hb-early", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "AUTH_FAILED" + }); + }); + + it("HF-04: heartbeat without session returns error", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport, sent } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHeartbeat( + { identifier: "client-a", status: "alive" }, + { requestId: "req-hb-unauth", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "AUTH_FAILED" + }); + }); +});