diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 8c8dceb..207b7b8 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -261,7 +261,7 @@ export class YonexusServerRuntime { isAuthenticated: false, connectedAt: connection.connectedAt, lastActivityAt: this.now(), - publicKey: payload.publicKey + publicKey: payload.publicKey?.trim() || undefined }); const nextAction = this.determineNextAction(record); diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts new file mode 100644 index 0000000..76d13a3 --- /dev/null +++ b/tests/runtime-flow.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildAuthRequest, + buildHeartbeat, + buildHello, + buildPairConfirm, + createAuthRequestSigningInput, + decodeBuiltin, + encodeBuiltin, + type AuthRequestPayload, + type BuiltinEnvelope, + type PairRequestPayload, + YONEXUS_PROTOCOL_VERSION +} from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import type { ClientRecord } from "../plugin/core/persistence.js"; +import type { YonexusServerStore } from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; +import { generateKeyPair, signMessage, verifySignature } from "../../Yonexus.Client/plugin/crypto/keypair.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-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 client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +function createMockTransport() { + const sentToConnection: Array<{ connection: ClientConnection; message: string }> = []; + const sentByIdentifier: Array<{ identifier: string; message: string }> = []; + const assigned = new Map(); + const promoted: string[] = []; + const closed: Array<{ identifier: string; code?: number; reason?: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + send: vi.fn((identifier: string, message: string) => { + sentByIdentifier.push({ identifier, message }); + return true; + }), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sentToConnection.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => { + closed.push({ identifier, code, reason }); + return true; + }), + promoteToAuthenticated: vi.fn((identifier: string, _ws) => { + promoted.push(identifier); + return true; + }), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn((ws, identifier: string) => { + assigned.set(ws as object, identifier); + }) + }; + + return { + transport, + sentToConnection, + sentByIdentifier, + assigned, + promoted, + closed + }; +} + +describe("Yonexus.Server runtime flow", () => { + it("runs hello -> pair_request for an unpaired client", async () => { + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: false, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello", timestamp: 1_710_000_000 } + ) + ) + ); + + expect(transportState.assigned.get(connection.ws as object)).toBe("client-a"); + expect(transportState.sentToConnection).toHaveLength(2); + + const helloAck = decodeBuiltin(transportState.sentToConnection[0].message); + expect(helloAck.type).toBe("hello_ack"); + expect(helloAck.payload).toMatchObject({ + identifier: "client-a", + nextAction: "pair_required" + }); + + const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope< + "pair_request", + PairRequestPayload + >; + expect(pairRequest.type).toBe("pair_request"); + expect(pairRequest.payload).toMatchObject({ + identifier: "client-a", + adminNotification: "sent", + codeDelivery: "out_of_band" + }); + + const record = runtime.state.registry.clients.get("client-a"); + expect(record?.pairingStatus).toBe("pending"); + expect(record?.pairingCode).toBeTypeOf("string"); + }); + + it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => { + let now = 1_710_000_000; + const keyPair = await generateKeyPair(); + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: true, + publicKey: keyPair.publicKey, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello", timestamp: now } + ) + ) + ); + + const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope< + "pair_request", + PairRequestPayload + >; + const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + expect(pairingCode).toBeTypeOf("string"); + expect(pairRequest.payload?.identifier).toBe("client-a"); + + now += 2; + await runtime.handleMessage( + connection, + encodeBuiltin( + buildPairConfirm( + { + identifier: "client-a", + pairingCode: pairingCode! + }, + { requestId: "req-pair", timestamp: now } + ) + ) + ); + + const pairSuccess = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(pairSuccess.type).toBe("pair_success"); + + const recordAfterPair = runtime.state.registry.clients.get("client-a"); + expect(recordAfterPair?.pairingStatus).toBe("paired"); + expect(recordAfterPair?.publicKey).toBe(keyPair.publicKey.trim()); + expect(recordAfterPair?.secret).toBeTypeOf("string"); + + now += 2; + const nonce = "AUTHNONCESTRING000000001"; + const signingInput = createAuthRequestSigningInput({ + secret: recordAfterPair!.secret!, + nonce, + proofTimestamp: now + }); + const signature = await signMessage(keyPair.privateKey, signingInput); + await expect(verifySignature(keyPair.publicKey, signingInput, signature)).resolves.toBe(true); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildAuthRequest( + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature, + publicKey: keyPair.publicKey + }, + { requestId: "req-auth", timestamp: now } + ) + ) + ); + + const authSuccess = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(authSuccess.type).toBe("auth_success"); + expect(transportState.promoted).toContain("client-a"); + + const session = runtime.state.registry.sessions.get("client-a"); + expect(session?.isAuthenticated).toBe(true); + + now += 5; + await runtime.handleMessage( + { ...connection, identifier: "client-a", isAuthenticated: true }, + encodeBuiltin( + buildHeartbeat( + { + identifier: "client-a", + status: "alive" + }, + { requestId: "req-heartbeat", timestamp: now } + ) + ) + ); + + const heartbeatAck = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(heartbeatAck.type).toBe("heartbeat_ack"); + + const recordAfterHeartbeat = runtime.state.registry.clients.get("client-a"); + expect(recordAfterHeartbeat?.status).toBe("online"); + expect(recordAfterHeartbeat?.lastHeartbeatAt).toBe(now); + }); + + it("rejects unauthenticated rule messages by closing the connection", async () => { + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: connection.connectedAt, + lastActivityAt: 1_710_000_000 + }); + + await runtime.handleMessage(connection, 'chat_sync::{"body":"hello"}'); + + expect((connection.ws.close as ReturnType)).toHaveBeenCalledWith( + 1008, + "Not authenticated" + ); + }); + + it("marks stale authenticated clients unstable then offline during liveness sweep", async () => { + vi.useFakeTimers(); + try { + let now = 1_710_000_000; + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => now, + sweepIntervalMs: 1000 + }); + + await runtime.start(); + + runtime.state.registry.clients.set("client-a", { + identifier: "client-a", + pairingStatus: "paired", + publicKey: "pk", + secret: "secret", + status: "online", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now, + lastHeartbeatAt: now + }); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: createMockSocket(), + isAuthenticated: true, + connectedAt: now, + lastActivityAt: now + }); + + now += 7 * 60; + await vi.advanceTimersByTimeAsync(1000); + + expect(runtime.state.registry.clients.get("client-a")?.status).toBe("unstable"); + const unstableNotice = transportState.sentByIdentifier.at(-1); + expect(unstableNotice?.identifier).toBe("client-a"); + expect(decodeBuiltin(unstableNotice!.message).type).toBe("status_update"); + + now += 4 * 60; + await vi.advanceTimersByTimeAsync(1000); + + expect(runtime.state.registry.clients.get("client-a")?.status).toBe("offline"); + expect(transportState.closed).toContainEqual({ + identifier: "client-a", + code: 1001, + reason: "Heartbeat timeout" + }); + expect(runtime.state.registry.sessions.has("client-a")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +});