From df14022c9a681af5a462487f71b9216681dec8c2 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:36:37 +0000 Subject: [PATCH] test(client): add auth and heartbeat coverage --- .gitignore | 2 + tests/state-auth-heartbeat.test.ts | 412 +++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 tests/state-auth-heartbeat.test.ts diff --git a/.gitignore b/.gitignore index b947077..e6d5efa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +coverage/ +*.log diff --git a/tests/state-auth-heartbeat.test.ts b/tests/state-auth-heartbeat.test.ts new file mode 100644 index 0000000..a59114c --- /dev/null +++ b/tests/state-auth-heartbeat.test.ts @@ -0,0 +1,412 @@ +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 + }; +}