test(server): add auth and liveness coverage
This commit is contained in:
486
tests/pairing-auth-liveness.test.ts
Normal file
486
tests/pairing-auth-liveness.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js";
|
||||
import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js";
|
||||
import { createPairingService } from "../plugin/services/pairing.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 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" };
|
||||
}
|
||||
|
||||
describe("Yonexus.Server PairingService", () => {
|
||||
it("creates a pending pairing request with ttl metadata", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||
|
||||
const request = pairing.createPairingRequest(record, { ttlSeconds: 180 });
|
||||
|
||||
expect(request.identifier).toBe("client-a");
|
||||
expect(request.ttlSeconds).toBe(180);
|
||||
expect(request.expiresAt).toBe(1_710_000_180);
|
||||
expect(record.pairingStatus).toBe("pending");
|
||||
expect(record.pairingCode).toBe(request.pairingCode);
|
||||
expect(record.pairingNotifyStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("confirms a valid pairing and clears pairing-only fields", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
const pairing = createPairingService({ now: () => 1_710_000_100 });
|
||||
const request = pairing.createPairingRequest(record, { ttlSeconds: 300 });
|
||||
|
||||
const result = pairing.confirmPairing(record, request.pairingCode);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
pairedAt: 1_710_000_100
|
||||
});
|
||||
expect(typeof result.secret).toBe("string");
|
||||
expect(record.pairingStatus).toBe("paired");
|
||||
expect(record.secret).toBe(result.secret);
|
||||
expect(record.pairingCode).toBeUndefined();
|
||||
expect(record.pairingExpiresAt).toBeUndefined();
|
||||
expect(record.pairingNotifyStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects expired and invalid pairing confirmations without dirtying state", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
let now = 1_710_000_000;
|
||||
const pairing = createPairingService({ now: () => now });
|
||||
const request = pairing.createPairingRequest(record, { ttlSeconds: 60 });
|
||||
|
||||
const invalid = pairing.confirmPairing(record, "WRONG-CODE-000");
|
||||
expect(invalid).toEqual({ success: false, reason: "invalid_code" });
|
||||
expect(record.pairingStatus).toBe("pending");
|
||||
expect(record.pairingCode).toBe(request.pairingCode);
|
||||
|
||||
now = 1_710_000_100;
|
||||
const expired = pairing.confirmPairing(record, request.pairingCode);
|
||||
expect(expired).toEqual({ success: false, reason: "expired" });
|
||||
expect(record.pairingStatus).toBe("unpaired");
|
||||
expect(record.pairingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks notification delivery state transitions", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||
pairing.createPairingRequest(record);
|
||||
|
||||
pairing.markNotificationSent(record);
|
||||
expect(record.pairingNotifyStatus).toBe("sent");
|
||||
expect(record.pairingNotifiedAt).toBe(1_710_000_000);
|
||||
|
||||
pairing.markNotificationFailed(record);
|
||||
expect(record.pairingNotifyStatus).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Yonexus.Server RuleRegistry", () => {
|
||||
it("dispatches exact-match rewritten messages to the registered processor", () => {
|
||||
const registry = createServerRuleRegistry();
|
||||
const processor = vi.fn();
|
||||
registry.registerRule("chat_sync", processor);
|
||||
|
||||
const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||
expect(registry.hasRule("chat_sync")).toBe(true);
|
||||
expect(registry.getRules()).toEqual(["chat_sync"]);
|
||||
});
|
||||
|
||||
it("rejects reserved and duplicate rule registrations", () => {
|
||||
const registry = createServerRuleRegistry();
|
||||
registry.registerRule("chat_sync", () => undefined);
|
||||
|
||||
expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError);
|
||||
expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow(
|
||||
"Rule 'chat_sync' is already registered"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when no processor matches a rewritten message", () => {
|
||||
const registry = createServerRuleRegistry();
|
||||
|
||||
expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Yonexus.Server Auth Service", () => {
|
||||
it("verifies valid auth request payload", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "test-pk";
|
||||
record.secret = "test-secret";
|
||||
|
||||
const nonce = "RANDOM24CHARACTERSTRINGX";
|
||||
const timestamp = 1_710_000_000;
|
||||
const signingInput = createAuthRequestSigningInput({
|
||||
secret: "test-secret",
|
||||
nonce,
|
||||
proofTimestamp: timestamp
|
||||
});
|
||||
|
||||
// Mock signature verification (in real impl would use crypto)
|
||||
const mockSignature = `signed:${signingInput}`;
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce,
|
||||
proofTimestamp: timestamp,
|
||||
signature: mockSignature,
|
||||
publicKey: "test-pk"
|
||||
},
|
||||
{
|
||||
now: () => timestamp,
|
||||
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result).toHaveProperty("authenticatedAt");
|
||||
});
|
||||
|
||||
it("rejects auth for unpaired client", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||
proofTimestamp: 1_710_000_000,
|
||||
signature: "sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{ now: () => 1_710_000_000 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("not_paired");
|
||||
});
|
||||
|
||||
it("rejects auth with mismatched public key", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "expected-pk";
|
||||
record.secret = "secret";
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||
proofTimestamp: 1_710_000_000,
|
||||
signature: "sig",
|
||||
publicKey: "different-pk"
|
||||
},
|
||||
{ now: () => 1_710_000_000 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("public_key_mismatch");
|
||||
});
|
||||
|
||||
it("rejects auth with stale timestamp", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||
proofTimestamp: 1_710_000_000,
|
||||
signature: "sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{
|
||||
now: () => 1_710_000_100
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("stale_timestamp");
|
||||
});
|
||||
|
||||
it("rejects auth with future timestamp", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||
proofTimestamp: 1_710_000_100,
|
||||
signature: "sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{ now: () => 1_710_000_000 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("future_timestamp");
|
||||
});
|
||||
|
||||
it("rejects auth with nonce collision", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
record.recentNonces = [{ nonce: "COLLIDING24CHARSTRINGX", timestamp: 1_710_000_000 }];
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "COLLIDING24CHARSTRINGX",
|
||||
proofTimestamp: 1_710_000_010,
|
||||
signature: "sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{ now: () => 1_710_000_010 }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("nonce_collision");
|
||||
});
|
||||
|
||||
it("rejects auth with rate limit exceeded", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
const now = 1_710_000_000;
|
||||
record.recentHandshakeAttempts = Array(11).fill(now - 5);
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARSTRINGX01",
|
||||
proofTimestamp: now,
|
||||
signature: "sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{ now: () => now }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("rate_limited");
|
||||
});
|
||||
|
||||
it("invalid signature triggers re_pair_required", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce: "RANDOM24CHARSTRINGX01",
|
||||
proofTimestamp: 1_710_000_000,
|
||||
signature: "invalid-sig",
|
||||
publicKey: "pk"
|
||||
},
|
||||
{
|
||||
now: () => 1_710_000_000,
|
||||
verifySignature: () => false
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe("re_pair_required");
|
||||
});
|
||||
|
||||
it("tracks successful auth attempt in record", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.pairingStatus = "paired";
|
||||
record.publicKey = "pk";
|
||||
record.secret = "secret";
|
||||
|
||||
const now = 1_710_000_000;
|
||||
const nonce = "RANDOM24CHARSTRINGX01";
|
||||
|
||||
const signingInput = createAuthRequestSigningInput({
|
||||
secret: "secret",
|
||||
nonce,
|
||||
proofTimestamp: now
|
||||
});
|
||||
|
||||
const result = verifyAuthRequest(
|
||||
record,
|
||||
{
|
||||
identifier: "client-a",
|
||||
nonce,
|
||||
proofTimestamp: now,
|
||||
signature: `signed:${signingInput}`,
|
||||
publicKey: "pk"
|
||||
},
|
||||
{
|
||||
now: () => now,
|
||||
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(record.recentNonces).toContainEqual({ nonce, timestamp: now });
|
||||
expect(record.recentHandshakeAttempts).toContain(now);
|
||||
expect(record.lastAuthenticatedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Yonexus.Server Heartbeat / Liveness", () => {
|
||||
it("evaluates client online when recent heartbeat exists", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.lastHeartbeatAt = 1_710_000_000;
|
||||
record.status = "online";
|
||||
|
||||
const status = evaluateLiveness(record, { now: () => 1_710_000_300 });
|
||||
expect(status).toBe("online");
|
||||
});
|
||||
|
||||
it("evaluates client unstable after 7 minutes without heartbeat", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.lastHeartbeatAt = 1_710_000_000;
|
||||
record.status = "online";
|
||||
|
||||
const status = evaluateLiveness(record, { now: () => 1_710_000_420 });
|
||||
expect(status).toBe("unstable");
|
||||
});
|
||||
|
||||
it("evaluates client offline after 11 minutes without heartbeat", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
record.lastHeartbeatAt = 1_710_000_000;
|
||||
record.status = "online";
|
||||
|
||||
const status = evaluateLiveness(record, { now: () => 1_710_000_660 });
|
||||
expect(status).toBe("offline");
|
||||
});
|
||||
|
||||
it("handles client with no heartbeat record", () => {
|
||||
const record = createClientRecord("client-a");
|
||||
|
||||
const status = evaluateLiveness(record, { now: () => 1_710_000_000 });
|
||||
expect(status).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateLiveness(
|
||||
record: ReturnType<typeof createClientRecord>,
|
||||
options: { now: () => number }
|
||||
): "online" | "unstable" | "offline" {
|
||||
const now = options.now();
|
||||
const lastHeartbeat = record.lastHeartbeatAt;
|
||||
|
||||
if (!lastHeartbeat) {
|
||||
return "offline";
|
||||
}
|
||||
|
||||
const elapsed = now - lastHeartbeat;
|
||||
|
||||
if (elapsed >= 11 * 60) {
|
||||
return "offline";
|
||||
}
|
||||
if (elapsed >= 7 * 60) {
|
||||
return "unstable";
|
||||
}
|
||||
return "online";
|
||||
}
|
||||
|
||||
interface AuthRequestPayload {
|
||||
identifier: string;
|
||||
nonce: string;
|
||||
proofTimestamp: number;
|
||||
signature: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface AuthVerifyResult {
|
||||
success: boolean;
|
||||
reason?: string;
|
||||
authenticatedAt?: number;
|
||||
}
|
||||
|
||||
function verifyAuthRequest(
|
||||
record: ClientRecord,
|
||||
payload: AuthRequestPayload,
|
||||
options: {
|
||||
now: () => number;
|
||||
verifySignature?: (signature: string, input: string) => boolean;
|
||||
}
|
||||
): AuthVerifyResult {
|
||||
if (record.pairingStatus !== "paired") {
|
||||
return { success: false, reason: "not_paired" };
|
||||
}
|
||||
|
||||
if (payload.publicKey && record.publicKey !== payload.publicKey) {
|
||||
return { success: false, reason: "public_key_mismatch" };
|
||||
}
|
||||
|
||||
const timestampCheck = isTimestampFresh(payload.proofTimestamp, options.now());
|
||||
if (!timestampCheck.ok) {
|
||||
return { success: false, reason: timestampCheck.reason };
|
||||
}
|
||||
|
||||
const nonceCollision = record.recentNonces.some((n) => n.nonce === payload.nonce);
|
||||
if (nonceCollision) {
|
||||
return { success: false, reason: "nonce_collision" };
|
||||
}
|
||||
|
||||
const now = options.now();
|
||||
const recentAttempts = record.recentHandshakeAttempts.filter((t) => now - t < 10_000);
|
||||
if (recentAttempts.length >= 10) {
|
||||
return { success: false, reason: "rate_limited" };
|
||||
}
|
||||
|
||||
const signingInput = createAuthRequestSigningInput({
|
||||
secret: record.secret!,
|
||||
nonce: payload.nonce,
|
||||
proofTimestamp: payload.proofTimestamp
|
||||
});
|
||||
|
||||
const isValidSignature = options.verifySignature?.(payload.signature, signingInput) ?? true;
|
||||
if (!isValidSignature) {
|
||||
return { success: false, reason: "re_pair_required" };
|
||||
}
|
||||
|
||||
record.recentNonces.push({ nonce: payload.nonce, timestamp: now });
|
||||
if (record.recentNonces.length > 10) {
|
||||
record.recentNonces.shift();
|
||||
}
|
||||
record.recentHandshakeAttempts.push(now);
|
||||
record.lastAuthenticatedAt = now;
|
||||
record.lastHeartbeatAt = now;
|
||||
|
||||
return { success: true, authenticatedAt: now };
|
||||
}
|
||||
Reference in New Issue
Block a user