413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
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<string> {
|
|
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<ClientState, Partial<Record<ClientEvent, ClientState>>> = {
|
|
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<typeof setInterval> | null = null;
|
|
|
|
return {
|
|
start: () => {
|
|
if (timer) clearInterval(timer);
|
|
timer = setInterval(() => {
|
|
options.onTick?.();
|
|
}, options.intervalMs);
|
|
},
|
|
stop: () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
},
|
|
isRunning: () => timer !== null
|
|
};
|
|
}
|