import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { decodeBuiltin, encodeBuiltin, buildHello, buildHelloAck, buildPairRequest, buildPairConfirm, buildPairFailed, buildPairSuccess, type PairConfirmPayload, type PairFailedPayload, YONEXUS_PROTOCOL_VERSION, ProtocolErrorCode } from "../../Yonexus.Protocol/src/index.js"; import { createYonexusServerRuntime } from "../../Yonexus.Server/plugin/core/runtime.js"; import type { ClientRecord } from "../../Yonexus.Server/plugin/core/persistence.js"; import type { YonexusServerStore } from "../../Yonexus.Server/plugin/core/store.js"; import type { ClientConnection, ServerTransport } from "../../Yonexus.Server/plugin/core/transport.js"; /** * YNX-1105b: Pairing Failure Path Tests * * Covers: * - PF-01: Invalid pairing code * - PF-02: Expired pairing code * - PF-03: Identifier not in allowlist * - PF-04: Admin notification failed (partial - notification stub) * - PF-05: Empty pairing code * - PF-06: Malformed pair_confirm payload * - PF-07: Double pairing attempt */ // ============================================================================ // Test Utilities // ============================================================================ 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((r) => [r.identifier, r])); return { filePath: "/tmp/test.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 c of clients) persisted.set(c.identifier, c); }) }; } function createMockTransport() { const sent: Array<{ connection: ClientConnection; message: string }> = []; const closed: Array<{ identifier: string; code?: number; reason?: string }> = []; const transport: ServerTransport = { isRunning: false, connections: new Map(), start: vi.fn(), stop: vi.fn(), send: vi.fn((id: string, msg: string) => { sent.push({ connection: { identifier: id } as ClientConnection, message: msg }); return true; }), sendToConnection: vi.fn((conn: ClientConnection, msg: string) => { sent.push({ connection: conn, message: msg }); return true; }), broadcast: vi.fn(), closeConnection: vi.fn((id: string, code?: number, reason?: string) => { closed.push({ identifier: id, code, reason }); return true; }), promoteToAuthenticated: vi.fn(), removeTempConnection: vi.fn(), assignIdentifierToTemp: vi.fn() }; return { transport, sent, closed }; } // ============================================================================ // Pairing Failure Path Tests // ============================================================================ describe("YNX-1105b: Pairing Failure Paths", () => { let now = 1_710_000_000; beforeEach(() => { now = 1_710_000_000; vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("PF-01: Invalid pairing code", () => { it("returns pair_failed(invalid_code) when wrong code submitted", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); // Start pairing flow const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; expect(pairingCode).toBeDefined(); // Submit wrong code await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: "WRONG-CODE-999" }, { timestamp: now + 10 } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code"); // Client remains in pending state, can retry expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("pending"); }); it("allows retry after invalid code failure", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); const correctCode = runtime.state.registry.clients.get("client-a")?.pairingCode; // First attempt: wrong code await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: "WRONG" }, { timestamp: now + 10 } ))); expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_failed"); // Second attempt: correct code await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: correctCode! }, { timestamp: now + 20 } ))); expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_success"); }); }); describe("PF-02: Expired pairing code", () => { it("returns pair_failed(expired) when code submitted after expiry", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; const expiresAt = runtime.state.registry.clients.get("client-a")?.pairingExpiresAt; expect(expiresAt).toBeDefined(); // Advance time past expiry now = expiresAt! + 1; await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: pairingCode! }, { timestamp: now } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired"); // Pairing state reset to allow new pairing expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("unpaired"); expect(runtime.state.registry.clients.get("client-a")?.pairingCode).toBeUndefined(); }); }); describe("PF-03: Identifier not in allowlist", () => { it("rejects hello from unknown identifier", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["allowed-client"], // Only this one is allowed notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "unknown-client", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Should receive hello_ack with rejected or an error const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("error"); // Identifier should not be registered expect(runtime.state.registry.clients.has("unknown-client")).toBe(false); }); it("rejects pair_confirm from unknown identifier even if somehow received", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["allowed-client"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); // Try to send pair_confirm for unknown client const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "unknown-client", pairingCode: "SOME-CODE" }, { timestamp: now } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); expect((lastMessage.payload as PairFailedPayload).reason).toBe("identifier_not_allowed"); }); }); describe("PF-04: Admin notification failure", () => { it("fails pairing when notification cannot be sent", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "", // Empty token should cause notification failure adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Check the pair_request indicates notification failure const pairRequest = sent.find(m => decodeBuiltin(m.message).type === "pair_request"); expect(pairRequest).toBeDefined(); // Should not have created a valid pending pairing const record = runtime.state.registry.clients.get("client-a"); if (record?.pairingStatus === "pending") { // If notification failed, pairing should indicate this expect(record.pairingNotifyStatus).toBe("failed"); } }); }); describe("PF-05: Empty pairing code", () => { it("rejects empty pairing code", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Submit empty code await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: "" }, { timestamp: now + 10 } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code"); }); it("rejects whitespace-only pairing code", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Submit whitespace code await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: " \t\n " }, { timestamp: now + 10 } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); }); }); describe("PF-06: Malformed pair_confirm payload", () => { it("handles missing identifier in pair_confirm", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Send malformed payload (missing fields) await runtime.handleMessage(conn, encodeBuiltin({ type: "pair_confirm", timestamp: now, payload: { pairingCode: "SOME-CODE" } // Missing identifier })); // Should receive an error response const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("error"); }); it("handles missing pairingCode in pair_confirm", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Send malformed payload (missing pairingCode) await runtime.handleMessage(conn, encodeBuiltin({ type: "pair_confirm", timestamp: now, payload: { identifier: "client-a" } // Missing pairingCode })); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); }); }); describe("PF-07: Double pairing attempt", () => { it("rejects pair_confirm for already paired client", async () => { const store = createMockStore([{ identifier: "client-a", pairingStatus: "paired", publicKey: "existing-key", secret: "existing-secret", status: "offline", recentNonces: [], recentHandshakeAttempts: [], createdAt: now - 1000, updatedAt: now - 500, pairedAt: now - 500 }]); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); // Try to pair an already paired client const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: "SOME-CODE" }, { timestamp: now } ))); // Should reject since already paired const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); // Existing trust material preserved const record = runtime.state.registry.clients.get("client-a"); expect(record?.pairingStatus).toBe("paired"); expect(record?.secret).toBe("existing-secret"); }); }); describe("Edge Cases", () => { it("handles concurrent pair_confirm from different connections with same identifier", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); // First connection starts pairing const conn1 = createConnection(); await runtime.handleMessage(conn1, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; // Second connection tries to pair with same identifier const conn2 = createConnection(); await runtime.handleMessage(conn2, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: pairingCode! }, { timestamp: now + 10 } ))); // Should succeed - pairing is identifier-based, not connection-based const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_success"); }); it("cleans up pending pairing state on expiry", async () => { const store = createMockStore(); const { transport, sent } = createMockTransport(); const runtime = createYonexusServerRuntime({ config: { followerIdentifiers: ["client-a"], notifyBotToken: "test-token", adminUserId: "admin", listenHost: "127.0.0.1", listenPort: 8787 }, store, transport, now: () => now }); await runtime.start(); const conn = createConnection(); await runtime.handleMessage(conn, encodeBuiltin(buildHello( { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: now } ))); // Verify pending state exists const recordBefore = runtime.state.registry.clients.get("client-a"); expect(recordBefore?.pairingStatus).toBe("pending"); expect(recordBefore?.pairingCode).toBeDefined(); // Expire and try to use old code now += 400; // Past default TTL await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( { identifier: "client-a", pairingCode: recordBefore?.pairingCode! }, { timestamp: now } ))); const lastMessage = decodeBuiltin(sent.at(-1)!.message); expect(lastMessage.type).toBe("pair_failed"); expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired"); // State cleaned up const recordAfter = runtime.state.registry.clients.get("client-a"); expect(recordAfter?.pairingStatus).toBe("unpaired"); expect(recordAfter?.pairingCode).toBeUndefined(); }); }); });