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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user