import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createClientRuleRegistry, ClientRuleRegistryError } from "../plugin/core/rules.js"; import { createInitialClientState, createYonexusClientStateStore, ensureClientKeyPair, hasClientKeyPair, hasClientSecret, loadYonexusClientState, saveYonexusClientState, type YonexusClientState } from "../plugin/core/state.js"; import { signMessage, verifySignature } from "../plugin/crypto/keypair.js"; // Inline protocol helpers (to avoid submodule dependency in tests) function createAuthRequestSigningInput(input: { secret: string; nonce: string; proofTimestamp: number; }): string { return JSON.stringify({ secret: input.secret, nonce: input.nonce, timestamp: input.proofTimestamp }); } function isValidAuthNonce(nonce: string): boolean { return /^[A-Za-z0-9_-]{24}$/.test(nonce); } function isTimestampFresh( proofTimestamp: number, now: number, maxDriftSeconds: number = 10 ): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } { const drift = proofTimestamp - now; if (Math.abs(drift) < maxDriftSeconds) { return { ok: true }; } return { ok: false, reason: drift < 0 ? "stale_timestamp" : "future_timestamp" }; } const tempDirs: string[] = []; afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); async function createTempStatePath(): Promise { const dir = await mkdtemp(join(tmpdir(), "yonexus-client-test-")); tempDirs.push(dir); return join(dir, "state.json"); } describe("Yonexus.Client state store", () => { it("creates minimal initial state when the file does not exist", async () => { const filePath = await createTempStatePath(); const state = await loadYonexusClientState(filePath, "client-a"); expect(state.identifier).toBe("client-a"); expect(hasClientSecret(state)).toBe(false); expect(hasClientKeyPair(state)).toBe(false); }); it("persists and reloads local trust material", async () => { const filePath = await createTempStatePath(); const state: YonexusClientState = { ...createInitialClientState("client-a"), publicKey: "pubkey", privateKey: "privkey", secret: "secret-value", pairedAt: 1_710_000_000, authenticatedAt: 1_710_000_100, updatedAt: 1_710_000_101 }; await saveYonexusClientState(filePath, state); const reloaded = await loadYonexusClientState(filePath, "client-a"); expect(reloaded).toEqual(state); const raw = JSON.parse(await readFile(filePath, "utf8")) as { version: number }; expect(raw.version).toBe(1); }); it("generates and persists an Ed25519 keypair only once", async () => { const filePath = await createTempStatePath(); const store = createYonexusClientStateStore(filePath); const initial = createInitialClientState("client-a"); const first = await ensureClientKeyPair(initial, store); expect(first.generated).toBe(true); expect(hasClientKeyPair(first.state)).toBe(true); const signature = await signMessage(first.state.privateKey!, "hello yonexus"); await expect(verifySignature(first.state.publicKey!, "hello yonexus", signature)).resolves.toBe( true ); const second = await ensureClientKeyPair(first.state, store); expect(second.generated).toBe(false); expect(second.state.privateKey).toBe(first.state.privateKey); expect(second.state.publicKey).toBe(first.state.publicKey); }); }); describe("Yonexus.Client rule registry", () => { it("dispatches exact-match rule messages to the registered processor", () => { const registry = createClientRuleRegistry(); const processor = vi.fn(); registry.registerRule("chat_sync", processor); const handled = registry.dispatch("chat_sync::{\"body\":\"hello\"}"); expect(handled).toBe(true); expect(processor).toHaveBeenCalledWith("chat_sync::{\"body\":\"hello\"}"); expect(registry.getRules()).toEqual(["chat_sync"]); }); it("rejects reserved and duplicate registrations", () => { const registry = createClientRuleRegistry(); registry.registerRule("chat_sync", () => undefined); expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ClientRuleRegistryError); expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( "Rule 'chat_sync' is already registered" ); }); it("returns false when no processor matches a message", () => { const registry = createClientRuleRegistry(); expect(registry.dispatch("chat_sync::{\"body\":\"hello\"}")).toBe(false); }); }); describe("Yonexus.Client auth flow", () => { it("constructs valid auth request signing input", () => { const input = createAuthRequestSigningInput({ secret: "my-secret", nonce: "RANDOM24CHARSTRINGX001", proofTimestamp: 1_710_000_000 }); expect(input).toBe( '{"secret":"my-secret","nonce":"RANDOM24CHARSTRINGX001","timestamp":1710000000}' ); }); it("validates nonce format correctly", () => { expect(isValidAuthNonce("RANDOM24CHARSTRINGX00001")).toBe(true); expect(isValidAuthNonce("RANDOM24CHARSTRINGX00")).toBe(false); expect(isValidAuthNonce("RANDOM24CHARSTRINGX0001")).toBe(false); expect(isValidAuthNonce("invalid_nonce_with_!@#")).toBe(false); expect(isValidAuthNonce("")).toBe(false); }); it("validates timestamp freshness", () => { const now = 1_710_000_000; expect(isTimestampFresh(now, now)).toEqual({ ok: true }); expect(isTimestampFresh(now - 5, now)).toEqual({ ok: true }); expect(isTimestampFresh(now + 5, now)).toEqual({ ok: true }); expect(isTimestampFresh(now - 15, now)).toEqual({ ok: false, reason: "stale_timestamp" }); expect(isTimestampFresh(now + 15, now)).toEqual({ ok: false, reason: "future_timestamp" }); }); }); describe("Yonexus.Client phase state machine", () => { it("transitions from idle to connecting to connected", () => { const sm = createClientStateMachine(); expect(sm.getState()).toBe("idle"); sm.transition("connect"); expect(sm.getState()).toBe("connecting"); sm.transition("connected"); expect(sm.getState()).toBe("connected"); }); it("handles pairing required flow", () => { const sm = createClientStateMachine(); sm.transition("connect"); sm.transition("connected"); sm.transition("pair_required"); expect(sm.getState()).toBe("pairing_required"); sm.transition("pairing_started"); expect(sm.getState()).toBe("pairing_pending"); sm.transition("pair_success"); expect(sm.getState()).toBe("authenticating"); sm.transition("auth_success"); expect(sm.getState()).toBe("authenticated"); }); it("handles re-pair required from authenticated", () => { const sm = createClientStateMachine(); sm.transition("connect"); sm.transition("connected"); sm.transition("pair_required"); sm.transition("pairing_started"); sm.transition("pair_success"); sm.transition("auth_success"); expect(sm.getState()).toBe("authenticated"); sm.transition("re_pair_required"); expect(sm.getState()).toBe("pairing_required"); }); it("handles disconnect and reconnect", () => { const sm = createClientStateMachine(); sm.transition("connect"); sm.transition("connected"); sm.transition("auth_required"); sm.transition("auth_success"); expect(sm.getState()).toBe("authenticated"); sm.transition("disconnect"); expect(sm.getState()).toBe("reconnecting"); sm.transition("connect"); expect(sm.getState()).toBe("connecting"); }); it("emits state change events", () => { const sm = createClientStateMachine(); const listener = vi.fn(); sm.on("stateChange", listener); sm.transition("connect"); expect(listener).toHaveBeenCalledWith({ from: "idle", to: "connecting" }); }); }); describe("Yonexus.Client heartbeat scheduling", () => { it("schedules heartbeat only when authenticated", () => { const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000 }); expect(heartbeat.isRunning()).toBe(false); heartbeat.start(); expect(heartbeat.isRunning()).toBe(true); heartbeat.stop(); expect(heartbeat.isRunning()).toBe(false); }); it("emits heartbeat tick at configured interval", () => { vi.useFakeTimers(); const onTick = vi.fn(); const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000, onTick }); heartbeat.start(); expect(onTick).not.toHaveBeenCalled(); vi.advanceTimersByTime(300_000); expect(onTick).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(300_000); expect(onTick).toHaveBeenCalledTimes(2); heartbeat.stop(); vi.useRealTimers(); }); it("resets interval on restart", () => { vi.useFakeTimers(); const onTick = vi.fn(); const heartbeat = createHeartbeatScheduler({ intervalMs: 300_000, onTick }); heartbeat.start(); vi.advanceTimersByTime(150_000); heartbeat.stop(); heartbeat.start(); vi.advanceTimersByTime(150_000); expect(onTick).not.toHaveBeenCalled(); vi.advanceTimersByTime(150_000); expect(onTick).toHaveBeenCalledTimes(1); heartbeat.stop(); vi.useRealTimers(); }); }); type ClientState = | "idle" | "connecting" | "connected" | "pairing_required" | "pairing_pending" | "authenticating" | "authenticated" | "reconnecting" | "error"; type ClientEvent = | "connect" | "connected" | "disconnected" | "disconnect" | "pair_required" | "pairing_started" | "pair_success" | "pair_failed" | "auth_required" | "auth_success" | "auth_failed" | "re_pair_required" | "error"; interface StateMachine { getState(): ClientState; transition(event: ClientEvent): void; on(event: "stateChange", handler: (change: { from: ClientState; to: ClientState }) => void): void; } function createClientStateMachine(): StateMachine { let state: ClientState = "idle"; const listeners: Array<(change: { from: ClientState; to: ClientState }) => void> = []; const transitions: Record>> = { idle: { connect: "connecting" }, connecting: { connected: "connected", error: "error", disconnect: "reconnecting" }, connected: { pair_required: "pairing_required", auth_required: "authenticating", disconnect: "reconnecting" }, pairing_required: { pairing_started: "pairing_pending", disconnect: "reconnecting" }, pairing_pending: { pair_success: "authenticating", pair_failed: "pairing_required", disconnect: "reconnecting" }, authenticating: { auth_success: "authenticated", auth_failed: "pairing_required", re_pair_required: "pairing_required", disconnect: "reconnecting" }, authenticated: { re_pair_required: "pairing_required", disconnect: "reconnecting" }, reconnecting: { connect: "connecting" }, error: { connect: "connecting" } }; return { getState: () => state, transition: (event: ClientEvent) => { const nextState = transitions[state]?.[event]; if (nextState) { const from = state; state = nextState; listeners.forEach((l) => l({ from, to: nextState })); } }, on: (event, handler) => { if (event === "stateChange") { listeners.push(handler); } } }; } interface HeartbeatScheduler { start(): void; stop(): void; isRunning(): boolean; } function createHeartbeatScheduler(options: { intervalMs: number; onTick?: () => void; }): HeartbeatScheduler { let timer: ReturnType | null = null; return { start: () => { if (timer) clearInterval(timer); timer = setInterval(() => { options.onTick?.(); }, options.intervalMs); }, stop: () => { if (timer) { clearInterval(timer); timer = null; } }, isRunning: () => timer !== null }; }