1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160 | // Allow-list access-layer e2e: strict boards are unreadable to URL holders,
// the knock → approve flow admits them, and removal re-keys future content.
// Self-contained: spawns a static server over client/dist (e2e/serve.mjs) and
// the dev relay itself (or reuses an external one). BUILD FIRST
// (`npm run build`) — there is no vite dev server in this repo any more.
// Run: node e2e/access.e2e.mjs (node >= 22)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import { serve } from "./serve.mjs";
import { buildForE2E } from "./build-for-e2e.mjs";
import { startDevRelay } from "./dev-relay.mjs";
const PORT = Number(process.env.PORT ?? 5183);
const BASE = process.env.BASE_URL ?? `http://localhost:${PORT}`;
const HEADED = !!process.env.HEADED;
const RELAY_PATH = fileURLToPath(new URL("../../deploy/relay/relay.mjs", import.meta.url));
// A plain `npm run build` points the app at the PRODUCTION relay (config.cljs:
// or-default(VITE_RELAY_MULTIADDR, goog.DEBUG ? DEV : PROD), and goog.DEBUG is
// false in a release build), so the relay this script spawns would be bypassed.
if (!process.env.E2E_NO_BUILD) buildForE2E();
let relayProc = null;
{
const started = await startDevRelay(RELAY_PATH, { banner: "relay up" });
const up = !started.external;
const proc = started.proc;
if (up) relayProc = proc;
else console.log("⚠️ dev relay already running externally");
}
// ---- the app under test: the SHIPPED dist, served statically ----------------
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
if (!existsSync(`${CLIENT_DIR}/dist/index.html`)) {
console.error("❌ client/dist is not built — run `npm run build` first");
process.exit(1);
}
const staticServer = process.env.BASE_URL ? null : await serve(PORT);
// Retire the relay on EVERY exit path, not just the happy tail below. `fail()`
// and any uncaught error call process.exit, which used to leave it listening on
// :9090 — and the next run then read that orphan as "an external relay is
// already running", so one failed run poisoned every run after it.
process.on("exit", () => {
staticServer?.close();
relayProc?.kill("SIGKILL");
});
const fail = (msg) => {
console.error("❌ " + msg);
process.exit(1);
};
const ok = (msg) => console.log("✅ " + msg);
const browser = await chromium.launch({
headless: !HEADED,
args: ["--disable-features=WebRtcHideLocalIpsWithMdns"],
});
const drawStroke = async (page, x0, y0, x1, y1) => {
await page.mouse.move(x0, y0);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(x0 + ((x1 - x0) * i) / 10, y0 + ((y1 - y0) * i) / 10);
await page.mouse.up();
};
const elCount = (page) => page.evaluate(() => window.__board.store.elements().length);
// ---- alice creates, draws pre-switch, flips strict ---------------------------
const ctxA = await browser.newContext();
const alice = await ctxA.newPage();
alice.on("pageerror", (e) => console.log("[alice] pageerror:", e.message));
await alice.goto(BASE);
await alice.click("#new-room");
await alice.waitForFunction(() => location.hash.length > 40);
const boardURL = await alice.evaluate(() => location.href);
await alice.fill("#name-in", "alice");
await alice.click("#name-ok");
await alice.waitForFunction(() => !!window.__board);
console.log("board:", boardURL);
await drawStroke(alice, 200, 200, 380, 280); // pre-switch stroke (base log)
await alice.waitForFunction(() => window.__board.store.elements().length === 1, { timeout: 5000 });
await alice.evaluate(() => window.__board.access.switchStrict());
await alice.waitForFunction(() => window.__board.access.fold.state.mode === "strict", { timeout: 10000 });
ok("alice switched the board to invite-only");
await drawStroke(alice, 250, 320, 430, 380); // post-switch stroke → vault
await alice.waitForFunction(() => window.__board.store.elements().length === 2, { timeout: 5000 });
const sources = await alice.evaluate(() => window.__board.store.elements().map((r) => r.source));
if (!sources.includes("vault")) fail(`post-switch stroke did not go to the vault (sources: ${sources})`);
ok("post-switch content routes to the vault log");
// ---- carol: a stranger holding the URL ---------------------------------------
const ctxC = await browser.newContext();
const carol = await ctxC.newPage();
carol.on("pageerror", (e) => console.log("[carol] pageerror:", e.message));
await carol.goto(boardURL);
await carol.fill("#name-in", "carol");
await carol.click("#name-ok");
await carol.waitForFunction(() => !!window.__board);
// wait until the control plane replicated and the lockout is up
await carol
.waitForSelector(".lockout", { timeout: 30000 })
.catch(() => fail("carol (stranger) never saw the invite-only lockout"));
ok("stranger sees the invite-only screen");
// THE privacy property: carol must not see the vault stroke, ever
await new Promise((r) => setTimeout(r, 3000)); // give replication every chance to leak
const carolSees = await elCount(carol);
if (carolSees > 1) fail(`stranger sees ${carolSees} elements — vault leaked!`);
ok(`stranger sees only pre-switch history (${carolSees} element)`);
// ---- knock → approve ---------------------------------------------------------
await carol.fill("#req-name", "carol");
await carol.fill("#req-msg", "hi, it's carol from the meeting");
await carol.click("#req-send");
ok("carol sent a join request");
await alice
.waitForFunction(() => window.__board.access.fold.state.pending.length === 1, { timeout: 20000 })
.catch(() => fail("alice never saw carol's request"));
const req = await alice.evaluate(() => window.__board.access.fold.state.pending[0]);
if (req.name !== "carol" || !req.msg.includes("meeting")) fail(`request garbled: ${JSON.stringify(req)}`);
ok("alice sees the request with name + message");
await alice.evaluate(() => window.__board.access.approve(window.__board.access.fold.state.pending[0]));
await carol
.waitForFunction(() => window.__board.store.elements().length === 2, { timeout: 30000 })
.catch(() => fail("carol never unlocked the vault after approval"));
await carol.waitForFunction(() => !document.querySelector(".lockout"), { timeout: 5000 });
ok("approved: carol unlocked the full board (incl. vault history)");
// carol can now draw; alice must receive it through the vault
await drawStroke(carol, 500, 200, 600, 350);
await alice
.waitForFunction(() => window.__board.store.elements().length === 3, { timeout: 15000 })
.catch(() => fail("alice never received carol's vault stroke"));
ok("member's strokes flow through the vault");
// ---- removal re-keys ---------------------------------------------------------
const carolPub = await carol.evaluate(() => window.__board.access.myIdPub);
await alice.evaluate((pub) => window.__board.access.remove(pub), carolPub);
await alice.waitForFunction(() => window.__board.access.fold.state.removed.size === 1, { timeout: 10000 });
await drawStroke(alice, 150, 150, 300, 160); // sealed under the NEW epoch
await alice.waitForFunction(() => window.__board.store.elements().length === 4, { timeout: 5000 });
await new Promise((r) => setTimeout(r, 4000)); // give the sealed entry time to replicate to carol
const carolAfter = await elCount(carol);
if (carolAfter !== 3) fail(`removed member sees ${carolAfter} elements — expected 3 (no new-epoch content)`);
ok("removed member cannot read post-removal content (epoch re-key works)");
console.log("\n🎉 access-layer scenarios passed");
await browser.close();
process.exit(0);
|