feat: implement CookieBridge M1 — core protocol & relay server
- Protocol spec: encrypted envelope format, device identity (Ed25519 + X25519), LWW conflict resolution with Lamport clocks - E2E encryption: XChaCha20-Poly1305 via sodium-native, X25519 key exchange - WebSocket relay server: stateless message forwarding, device auth via challenge-response, offline message queuing, ping/pong keepalive - Device pairing: time-limited pairing codes, key exchange broker via HTTP - Sync protocol: envelope builder/opener, conflict-resolving cookie store - 31 tests passing (crypto, pairing, conflict resolution, full integration) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
138
tests/conflict.test.ts
Normal file
138
tests/conflict.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CookieStore } from "../src/sync/conflict.js";
|
||||
import type { CookieEntry, CookieSyncPayload } from "../src/protocol/spec.js";
|
||||
|
||||
function makeCookie(overrides: Partial<CookieEntry> = {}): CookieEntry {
|
||||
return {
|
||||
domain: "example.com",
|
||||
name: "session",
|
||||
value: "abc123",
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
expiresAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CookieStore — conflict resolution", () => {
|
||||
it("applies first write", () => {
|
||||
const store = new CookieStore();
|
||||
const payload: CookieSyncPayload = {
|
||||
action: "set",
|
||||
cookies: [makeCookie({ value: "v1" })],
|
||||
lamportTs: 1,
|
||||
};
|
||||
const applied = store.applyRemote(payload, "device-a");
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(store.getAll()).toHaveLength(1);
|
||||
expect(store.getAll()[0].value).toBe("v1");
|
||||
});
|
||||
|
||||
it("last-writer-wins: higher timestamp wins", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "old" })], lamportTs: 1 },
|
||||
"device-a",
|
||||
);
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "new" })], lamportTs: 5 },
|
||||
"device-b",
|
||||
);
|
||||
expect(store.getAll()[0].value).toBe("new");
|
||||
});
|
||||
|
||||
it("rejects stale update", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "newer" })], lamportTs: 10 },
|
||||
"device-a",
|
||||
);
|
||||
const applied = store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "older" })], lamportTs: 3 },
|
||||
"device-b",
|
||||
);
|
||||
expect(applied).toHaveLength(0);
|
||||
expect(store.getAll()[0].value).toBe("newer");
|
||||
});
|
||||
|
||||
it("breaks ties by deviceId (lexicographic)", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 },
|
||||
"aaa",
|
||||
);
|
||||
// Same timestamp, higher deviceId wins
|
||||
const applied = store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "from-b" })], lamportTs: 5 },
|
||||
"bbb",
|
||||
);
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(store.getAll()[0].value).toBe("from-b");
|
||||
});
|
||||
|
||||
it("tie-break: lower deviceId loses", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "from-z" })], lamportTs: 5 },
|
||||
"zzz",
|
||||
);
|
||||
const applied = store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 },
|
||||
"aaa",
|
||||
);
|
||||
expect(applied).toHaveLength(0);
|
||||
expect(store.getAll()[0].value).toBe("from-z");
|
||||
});
|
||||
|
||||
it("handles delete action", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie()], lamportTs: 1 },
|
||||
"device-a",
|
||||
);
|
||||
expect(store.getAll()).toHaveLength(1);
|
||||
|
||||
store.applyRemote(
|
||||
{ action: "delete", cookies: [makeCookie()], lamportTs: 5 },
|
||||
"device-a",
|
||||
);
|
||||
expect(store.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tracks local changes with Lamport clock", () => {
|
||||
const store = new CookieStore();
|
||||
const cookie = makeCookie({ value: "local" });
|
||||
const ts = store.setLocal(cookie, "my-device");
|
||||
expect(ts).toBe(1);
|
||||
expect(store.getAll()).toHaveLength(1);
|
||||
expect(store.currentTs).toBe(1);
|
||||
|
||||
const ts2 = store.setLocal(makeCookie({ name: "other", value: "x" }), "my-device");
|
||||
expect(ts2).toBe(2);
|
||||
});
|
||||
|
||||
it("advances Lamport clock on remote apply", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie()], lamportTs: 100 },
|
||||
"device-x",
|
||||
);
|
||||
// Clock should be max(0, 100) + 1 = 101
|
||||
expect(store.currentTs).toBe(101);
|
||||
});
|
||||
|
||||
it("tracks different cookies by domain|name|path", () => {
|
||||
const store = new CookieStore();
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ domain: "a.com", name: "s1" })], lamportTs: 1 },
|
||||
"d1",
|
||||
);
|
||||
store.applyRemote(
|
||||
{ action: "set", cookies: [makeCookie({ domain: "b.com", name: "s1" })], lamportTs: 1 },
|
||||
"d1",
|
||||
);
|
||||
expect(store.getAll()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
124
tests/crypto.test.ts
Normal file
124
tests/crypto.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateKeyPair,
|
||||
deviceIdFromKeys,
|
||||
serializeKeyPair,
|
||||
deserializeKeyPair,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
sign,
|
||||
verify,
|
||||
buildSignablePayload,
|
||||
} from "../src/crypto/index.js";
|
||||
|
||||
describe("Key generation", () => {
|
||||
it("generates unique keypairs", () => {
|
||||
const kp1 = generateKeyPair();
|
||||
const kp2 = generateKeyPair();
|
||||
expect(kp1.signPub).not.toEqual(kp2.signPub);
|
||||
expect(kp1.encPub).not.toEqual(kp2.encPub);
|
||||
});
|
||||
|
||||
it("derives deviceId from signing public key", () => {
|
||||
const kp = generateKeyPair();
|
||||
const id = deviceIdFromKeys(kp);
|
||||
expect(id).toBe(kp.signPub.toString("hex"));
|
||||
expect(id).toHaveLength(64); // 32 bytes hex
|
||||
});
|
||||
|
||||
it("serializes and deserializes keypair", () => {
|
||||
const kp = generateKeyPair();
|
||||
const serialized = serializeKeyPair(kp);
|
||||
const restored = deserializeKeyPair(serialized);
|
||||
expect(restored.signPub).toEqual(kp.signPub);
|
||||
expect(restored.signSec).toEqual(kp.signSec);
|
||||
expect(restored.encPub).toEqual(kp.encPub);
|
||||
expect(restored.encSec).toEqual(kp.encSec);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption", () => {
|
||||
it("encrypts and decrypts with shared key", () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
|
||||
const aliceShared = deriveSharedKey(alice.encSec, bob.encPub);
|
||||
const bobShared = deriveSharedKey(bob.encSec, alice.encPub);
|
||||
|
||||
// Both sides derive the same shared key
|
||||
expect(aliceShared).toEqual(bobShared);
|
||||
|
||||
const plaintext = Buffer.from("hello cookies");
|
||||
const { nonce, ciphertext } = encrypt(plaintext, aliceShared);
|
||||
|
||||
const decrypted = decrypt(ciphertext, nonce, bobShared);
|
||||
expect(decrypted.toString()).toBe("hello cookies");
|
||||
});
|
||||
|
||||
it("fails to decrypt with wrong key", () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
const eve = generateKeyPair();
|
||||
|
||||
const sharedKey = deriveSharedKey(alice.encSec, bob.encPub);
|
||||
const wrongKey = deriveSharedKey(eve.encSec, bob.encPub);
|
||||
|
||||
const plaintext = Buffer.from("secret");
|
||||
const { nonce, ciphertext } = encrypt(plaintext, sharedKey);
|
||||
|
||||
expect(() => decrypt(ciphertext, nonce, wrongKey)).toThrow();
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for same plaintext (random nonce)", () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
const shared = deriveSharedKey(alice.encSec, bob.encPub);
|
||||
|
||||
const plaintext = Buffer.from("same message");
|
||||
const r1 = encrypt(plaintext, shared);
|
||||
const r2 = encrypt(plaintext, shared);
|
||||
|
||||
expect(r1.nonce).not.toEqual(r2.nonce);
|
||||
expect(r1.ciphertext).not.toEqual(r2.ciphertext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Signing", () => {
|
||||
it("signs and verifies", () => {
|
||||
const kp = generateKeyPair();
|
||||
const msg = Buffer.from("test message");
|
||||
const sig = sign(msg, kp.signSec);
|
||||
expect(verify(msg, sig, kp.signPub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects tampered message", () => {
|
||||
const kp = generateKeyPair();
|
||||
const msg = Buffer.from("original");
|
||||
const sig = sign(msg, kp.signSec);
|
||||
const tampered = Buffer.from("tampered");
|
||||
expect(verify(tampered, sig, kp.signPub)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wrong signer", () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
const msg = Buffer.from("from alice");
|
||||
const sig = sign(msg, alice.signSec);
|
||||
expect(verify(msg, sig, bob.signPub)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds deterministic signable payload", () => {
|
||||
const fields = {
|
||||
type: "cookie_sync",
|
||||
from: "aaa",
|
||||
to: "bbb",
|
||||
nonce: "ccc",
|
||||
payload: "ddd",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
const p1 = buildSignablePayload(fields);
|
||||
const p2 = buildSignablePayload(fields);
|
||||
expect(p1).toEqual(p2);
|
||||
});
|
||||
});
|
||||
301
tests/integration.test.ts
Normal file
301
tests/integration.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
RelayServer,
|
||||
generateKeyPair,
|
||||
deviceIdFromKeys,
|
||||
deriveSharedKey,
|
||||
sign,
|
||||
MESSAGE_TYPES,
|
||||
} from "../src/index.js";
|
||||
import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js";
|
||||
import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js";
|
||||
|
||||
// Helper: connect and authenticate a device
|
||||
function connectDevice(
|
||||
port: number,
|
||||
keys: ReturnType<typeof generateKeyPair>,
|
||||
): Promise<{ ws: WebSocket; deviceId: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deviceId = deviceIdFromKeys(keys);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
|
||||
ws.on("error", reject);
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === "auth_challenge") {
|
||||
const challenge = Buffer.from(msg.challenge, "hex");
|
||||
const sig = sign(challenge, keys.signSec);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth_response",
|
||||
deviceId,
|
||||
sig: sig.toString("hex"),
|
||||
}),
|
||||
);
|
||||
} else if (msg.type === "auth_ok") {
|
||||
resolve({ ws, deviceId });
|
||||
} else if (msg.type === "error") {
|
||||
reject(new Error(msg.error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: wait for next message of a given type
|
||||
function waitForMessage(
|
||||
ws: WebSocket,
|
||||
type: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||
const handler = (data: Buffer) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === type) {
|
||||
clearTimeout(timer);
|
||||
ws.off("message", handler);
|
||||
resolve(msg);
|
||||
}
|
||||
};
|
||||
ws.on("message", handler);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Integration: relay server end-to-end", () => {
|
||||
let server: RelayServer;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new RelayServer({ port: 0 }); // random port
|
||||
await server.start();
|
||||
port = server.port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it("health check works", async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("pairing flow: create and accept", async () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
|
||||
// Alice initiates pairing
|
||||
const createRes = await fetch(`http://127.0.0.1:${port}/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: deviceIdFromKeys(alice),
|
||||
x25519PubKey: alice.encPub.toString("hex"),
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const { pairingCode } = (await createRes.json()) as { pairingCode: string };
|
||||
expect(pairingCode).toHaveLength(6);
|
||||
|
||||
// Bob accepts with the code
|
||||
const acceptRes = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: deviceIdFromKeys(bob),
|
||||
x25519PubKey: bob.encPub.toString("hex"),
|
||||
pairingCode,
|
||||
}),
|
||||
});
|
||||
expect(acceptRes.status).toBe(200);
|
||||
const result = (await acceptRes.json()) as {
|
||||
initiator: { deviceId: string; x25519PubKey: string };
|
||||
acceptor: { deviceId: string; x25519PubKey: string };
|
||||
};
|
||||
expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice));
|
||||
expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob));
|
||||
});
|
||||
|
||||
it("rejects invalid pairing code", async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: "fake",
|
||||
x25519PubKey: "fake",
|
||||
pairingCode: "000000",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("authenticates devices via WebSocket", async () => {
|
||||
const alice = generateKeyPair();
|
||||
const { ws, deviceId } = await connectDevice(port, alice);
|
||||
expect(deviceId).toBe(deviceIdFromKeys(alice));
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("rejects bad auth signatures", async () => {
|
||||
const alice = generateKeyPair();
|
||||
const eve = generateKeyPair(); // wrong keys
|
||||
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
ws.on("message", (data: Buffer) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === "auth_challenge") {
|
||||
// Sign with wrong key
|
||||
const challenge = Buffer.from(msg.challenge, "hex");
|
||||
const sig = sign(challenge, eve.signSec);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth_response",
|
||||
deviceId: deviceIdFromKeys(alice), // claim to be alice
|
||||
sig: sig.toString("hex"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
ws.on("close", (code: number) => {
|
||||
if (code === 4003) reject(new Error("Auth failed as expected"));
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow("Auth failed");
|
||||
});
|
||||
|
||||
it("relays encrypted cookie sync between two devices", async () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
|
||||
const aliceConn = await connectDevice(port, alice);
|
||||
const bobConn = await connectDevice(port, bob);
|
||||
|
||||
const aliceDeviceId = deviceIdFromKeys(alice);
|
||||
const bobDeviceId = deviceIdFromKeys(bob);
|
||||
|
||||
// Alice sends a cookie sync to Bob
|
||||
const payload: CookieSyncPayload = {
|
||||
action: "set",
|
||||
cookies: [
|
||||
{
|
||||
domain: "example.com",
|
||||
name: "session",
|
||||
value: "tok_abc123",
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
lamportTs: 1,
|
||||
};
|
||||
|
||||
const envelope = buildEnvelope(
|
||||
MESSAGE_TYPES.COOKIE_SYNC,
|
||||
payload,
|
||||
alice,
|
||||
bob.encPub,
|
||||
bobDeviceId,
|
||||
);
|
||||
|
||||
// Bob listens for the message
|
||||
const bobMessagePromise = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC);
|
||||
|
||||
// Alice sends
|
||||
aliceConn.ws.send(JSON.stringify(envelope));
|
||||
|
||||
// Alice gets ACK
|
||||
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
|
||||
expect(ack.delivered).toBe(true);
|
||||
|
||||
// Bob receives the encrypted envelope
|
||||
const received = (await bobMessagePromise) as unknown as Envelope;
|
||||
expect(received.from).toBe(aliceDeviceId);
|
||||
expect(received.to).toBe(bobDeviceId);
|
||||
|
||||
// Bob decrypts
|
||||
const decrypted = openEnvelope(received, bob, alice.encPub);
|
||||
expect(decrypted.action).toBe("set");
|
||||
expect(decrypted.cookies).toHaveLength(1);
|
||||
expect(decrypted.cookies[0].domain).toBe("example.com");
|
||||
expect(decrypted.cookies[0].value).toBe("tok_abc123");
|
||||
|
||||
aliceConn.ws.close();
|
||||
bobConn.ws.close();
|
||||
});
|
||||
|
||||
it("queues messages for offline devices and delivers on reconnect", async () => {
|
||||
const alice = generateKeyPair();
|
||||
const bob = generateKeyPair();
|
||||
const bobDeviceId = deviceIdFromKeys(bob);
|
||||
|
||||
// Alice connects, Bob is offline
|
||||
const aliceConn = await connectDevice(port, alice);
|
||||
|
||||
const payload: CookieSyncPayload = {
|
||||
action: "set",
|
||||
cookies: [
|
||||
{
|
||||
domain: "queued.com",
|
||||
name: "token",
|
||||
value: "queued_val",
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: "none",
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
lamportTs: 1,
|
||||
};
|
||||
|
||||
const envelope = buildEnvelope(
|
||||
MESSAGE_TYPES.COOKIE_SYNC,
|
||||
payload,
|
||||
alice,
|
||||
bob.encPub,
|
||||
bobDeviceId,
|
||||
);
|
||||
|
||||
// Send while Bob is offline
|
||||
aliceConn.ws.send(JSON.stringify(envelope));
|
||||
|
||||
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
|
||||
expect(ack.delivered).toBe(false); // queued, not delivered
|
||||
|
||||
// Bob comes online — should receive the queued message
|
||||
const bobMessagePromise = new Promise<Envelope>((resolve) => {
|
||||
const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
bobWs.on("message", (data: Buffer) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === "auth_challenge") {
|
||||
const challenge = Buffer.from(msg.challenge, "hex");
|
||||
const sig = sign(challenge, bob.signSec);
|
||||
bobWs.send(
|
||||
JSON.stringify({
|
||||
type: "auth_response",
|
||||
deviceId: bobDeviceId,
|
||||
sig: sig.toString("hex"),
|
||||
}),
|
||||
);
|
||||
} else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) {
|
||||
resolve(msg as unknown as Envelope);
|
||||
bobWs.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const received = await bobMessagePromise;
|
||||
const decrypted = openEnvelope(received, bob, alice.encPub);
|
||||
expect(decrypted.cookies[0].value).toBe("queued_val");
|
||||
|
||||
aliceConn.ws.close();
|
||||
});
|
||||
});
|
||||
57
tests/pairing.test.ts
Normal file
57
tests/pairing.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PairingStore, generatePairingCode } from "../src/pairing/index.js";
|
||||
|
||||
describe("PairingStore", () => {
|
||||
it("creates and finds a pairing session", () => {
|
||||
const store = new PairingStore();
|
||||
const session = store.create("device-a-id", "device-a-x25519-pub");
|
||||
expect(session.pairingCode).toHaveLength(6);
|
||||
expect(session.deviceId).toBe("device-a-id");
|
||||
|
||||
const found = store.find(session.pairingCode);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.deviceId).toBe("device-a-id");
|
||||
});
|
||||
|
||||
it("consumes a session (one-time use)", () => {
|
||||
const store = new PairingStore();
|
||||
const session = store.create("d1", "pub1");
|
||||
|
||||
const consumed = store.consume(session.pairingCode);
|
||||
expect(consumed).not.toBeNull();
|
||||
expect(consumed!.deviceId).toBe("d1");
|
||||
|
||||
// Second consume returns null
|
||||
const again = store.consume(session.pairingCode);
|
||||
expect(again).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown code", () => {
|
||||
const store = new PairingStore();
|
||||
expect(store.find("999999")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires sessions after TTL", () => {
|
||||
const store = new PairingStore();
|
||||
const session = store.create("d1", "pub1");
|
||||
|
||||
// Manually expire by setting expiresAt in the past
|
||||
// We access the internal session via find and mutate it
|
||||
const found = store.find(session.pairingCode);
|
||||
if (found) {
|
||||
(found as { expiresAt: number }).expiresAt = Date.now() - 1000;
|
||||
}
|
||||
|
||||
expect(store.find(session.pairingCode)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePairingCode", () => {
|
||||
it("generates 6-digit codes", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const code = generatePairingCode();
|
||||
expect(code).toHaveLength(6);
|
||||
expect(/^\d{6}$/.test(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user