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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245 | /**
* The peer roster (Phase 5c) — the fourth UI golden vector, and the one over
* the surface that reads TRUST.
*
* What it replaces is `render-header!`'s `mk-row`: `innerHTML = ""` on the
* container, then per peer a `createElement`, an `innerHTML` template with a
* hand-rolled `esc` around the one translated string in it, a `querySelector`
* to set the one field that could not go through the template safely, and an
* `appendChild`. Two raw-HTML sinks and one `esc` call — which is what
* test/source-hygiene.test.mjs's budget records this step taking away.
*
* THE PART THAT IS NOT ROUTINE is at the bottom. Before Phase 5c, ticking
* "verified" MUTATED a `{verified, keyChanged}` JS object hanging off the
* peer's roster entry. The roster picked it up on its next render and nothing
* else could, because every already-rendered message held its own copy of that
* verdict — so a verified author's EXISTING messages kept a stale badge until
* the room was reopened. That is a real bug that shipped, not a hypothetical.
* Phase 5b moved the message badges onto one derived `_trust` map; this step
* moved the roster's onto one derived `:verdicts` map, and deleted the
* mutation. `trustPair` is the honest test of that: two app/msgs states
* differing ONLY in `:verified?`, over an author with TWO messages, asserting
* the tick count goes 0 -> 2. "The newest message updated" is exactly what the
* broken code also did, so 0 -> 1 would have passed while proving nothing.
*
* NEVER REGENERATE test/vectors/roster-view.json. There is no generator script
* and there must not be one. See the fixture's own `_` block.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { installDom } from "./harness/dom.mjs";
import { readVector } from "./helpers/load.mjs";
installDom();
const V = await import("../view-dist/viewlib.js");
const VEC = readVector("roster-view");
const S = VEC.states;
// ---- the fixture -----------------------------------------------------------
for (const [name, c] of Object.entries(VEC.cases)) {
test(`project: ${name}`, () => {
assert.equal(V.rosterProject(c.peers, c.ctx), S[name]);
});
}
for (const [name, state] of Object.entries(S)) {
test(`hiccup snapshot: ${name}`, () => {
assert.equal(V.rosterView(state), VEC.hiccup[name]);
});
}
for (const [name, html] of Object.entries(VEC.html)) {
test(`markup snapshot: ${name}`, () => {
assert.equal(V.rosterHtml(S[name]), html);
});
}
test("the header pill", () => {
// Written to `#peer-count` by hand — the node lives in app/ui's still
// imperative header template — but derived here, which is what makes the
// n-vs-n+1 off-by-one (`:count` is peers OTHER than you) testable at all.
for (const [name, label] of Object.entries(VEC.peersLabel)) {
assert.equal(V.rosterPeersLabel(S[name]), label, name);
}
assert.notEqual(VEC.peersLabel.alone, VEC.peersLabel.one, "0 peers and 1 peer must not read the same");
assert.notEqual(VEC.peersLabel.one, VEC.peersLabel.crowd);
});
// ---- discrimination: the snapshot must be able to SEE the state ------------
const differ = (a, b, why) => assert.notEqual(V.rosterView(S[a]), V.rosterView(S[b]), why);
test("the snapshot distinguishes a lost signalling connection", () => {
// Your own row is the one that reports it: green dot + "online" becomes red +
// "reconnecting…". It is the only thing on screen that says the tab is adrift.
differ("alone", "offline", "losing the relay must move your own row");
});
test("the snapshot distinguishes an empty room from one with a peer in it", () => {
differ("alone", "one", "a peer arriving must add a row");
});
test("the snapshot distinguishes a VERIFIED peer", () => {
// The badge the whole out-of-band verification flow exists to produce.
differ("one", "one-verified", "a verified tick must move the snapshot");
});
test("the snapshot distinguishes a key change from a verification", () => {
// TOFU beats the tick: an identity you once verified whose KEY then changed
// must read as an alarm, never as a reassurance.
differ("one-verified", "one-key-changed", "the TOFU alarm must move the snapshot");
differ("one", "one-key-changed", "…and must not be confused with an unverified peer");
});
test("the snapshot distinguishes a peer with no identity at all", () => {
// Identity is optional (no Ed25519, or a peer that never announced): the row
// must render with an EMPTY badge, not with "undefined".
differ("one", "one-anon", "an identity-less peer must render without a fingerprint");
assert.ok(V.rosterView(S["one-anon"]).includes('[:span.roster-badge ""]'));
});
test("the snapshot distinguishes each connection state", () => {
// Four states, three dot colours, four labels — and `crowd` is the only case
// that carries all of them at once.
const out = V.rosterView(S.crowd);
for (const bit of ['"direct"', '"via relay"', '"connecting"', '"disconnected"']) {
assert.ok(out.includes(bit), `missing state label ${bit}`);
}
for (const cls of ['{:class "ok"}', '{:class "idle"}', '{:class "down"}']) {
assert.ok(out.includes(cls), `missing dot class ${cls}`);
}
differ("one", "crowd", "the roster must show every peer, not the first");
});
test("the snapshot distinguishes a peer who has not said its name", () => {
// The head of its PeerId stands in, which is what stops a nameless row from
// being a blank one you cannot click on with intent.
assert.ok(V.rosterView(S.crowd).includes('[:span.roster-name "12D3Ko"]'));
});
test("the status dot is derived, and sees all three of its inputs", () => {
// `#status-dot`'s class is written by hand from `:status`, so the derivation
// is what is testable. Green needs somebody to be connected to; amber is a
// working connection to an empty room; red is no transport at all.
const status = (n) => /:status "([^"]+)"/.exec(S[n])[1];
assert.equal(status("alone"), "idle", "signalling up, empty room");
assert.equal(status("offline"), "down", "no signalling, empty room");
assert.equal(status("one"), "ok", "signalling up, a peer here");
assert.equal(status("connecting-only"), "down", "no signalling and no live peer yet");
});
test("the recorded fixture is itself discriminating", () => {
// Not a tautology: this reads the COMMITTED snapshots, so a fixture recorded
// from a view that ignored `:verdicts` would fail here even though the checks
// above (which re-render) still passed.
const pairs = [
["alone", "offline"],
["alone", "one"],
["one", "one-verified"],
["one-verified", "one-key-changed"],
["one", "one-key-changed"],
["one", "one-anon"],
["one", "crowd"],
];
for (const [a, b] of pairs) assert.notEqual(VEC.hiccup[a], VEC.hiccup[b], `${a} vs ${b}`);
});
// ---- structural properties -------------------------------------------------
test("no snapshot contains an opaque function", () => {
const all = [...Object.values(S).map((s) => V.rosterView(s)), ...Object.values(VEC.hiccup)];
for (const out of all) assert.ok(!out.includes("#object"), `an opaque function: ${out.slice(0, 140)}`);
});
test("every row is keyed, you first, then the peers in arrival order", () => {
const keys = (edn) => [...edn.matchAll(/:replicant\/key "([^"]+)"/g)].map((m) => m[1]);
// a check on the CODE…
assert.deepEqual(keys(V.rosterView(S.alone)), ["self"]);
assert.deepEqual(keys(V.rosterView(S.crowd)), ["self", "12D3KooWA", "12D3KooWB", "12D3KooWC", "12D3KooWD"]);
// …and on the FIXTURE, which a re-render cannot make
assert.deepEqual(keys(VEC.hiccup.crowd), ["self", "12D3KooWA", "12D3KooWB", "12D3KooWC", "12D3KooWD"]);
});
test("every row is clickable, and opens the sheet it belongs to", () => {
// e2e/mesh.e2e.mjs clicks `.roster-row.clickable` for the identity sheet, and
// the peer sheet — where #verify-btn lives — is only reachable this way.
const out = V.rosterView(S.crowd);
assert.equal((out.match(/:div\.roster-row\.clickable/g) ?? []).length, 5);
assert.ok(out.includes(":on {:click [:identity-sheet]}"), "your own row opens the identity sheet");
assert.ok(out.includes(':on {:click [:peer-sheet "12D3KooWA"]}'), "a peer's row opens that peer's sheet");
assert.equal((out.match(/:peer-sheet/g) ?? []).length, 4, "exactly one sheet action per peer");
});
test("the snapshot is not vacuous", () => {
const out = V.rosterView(S.crowd);
for (const fragment of [
'[:span.roster-name "ana"]', // content reached the tree
'[:span.roster-badge "🦊🌊🎈✓"]', // the fingerprint and its mark
"[:span.dot ", // the connection colour
"[:span.roster-state ", // the connection label
]) {
assert.ok(out.includes(fragment), `missing from the snapshot: ${fragment}`);
}
});
// ---- XSS is structural, not diligent ---------------------------------------
test("a hostile display name reaches the roster as TEXT", () => {
// A peer's `name` comes off the wire; app/chat clamps its LENGTH, not its
// content. `esc` plus a `querySelector(".roster-name").textContent` was what
// made that safe here, and both are gone from this path.
const hiccup = V.rosterView(S.hostile);
assert.ok(hiccup.includes(`[:span.roster-name ${JSON.stringify(VEC.cases.hostile.peers[0][1].name)}]`));
assert.ok(!hiccup.includes(":innerHTML"), "no hiccup node may carry raw HTML");
const html = V.rosterHtml(S.hostile);
assert.ok(!html.includes("<img src=x"), "the name was rendered as markup");
assert.ok(html.includes("<img src=x onerror=alert(1)>"));
});
// ---- the bug this step existed to close ------------------------------------
const ticks = (edn) => (edn.match(/✓/g) ?? []).length;
test("verifying an author moves EVERY one of their messages' badges", () => {
// THE regression this phase owed, stated as a discrimination pair over the
// one trust map. `trustPair` is two app/msgs states differing only in
// `:verified?`, over an author with two messages.
//
// The count matters more than the inequality. The code this replaced also
// produced a different render — for the NEXT message. What it could not do
// was move the badge on messages already on screen, because each of them held
// its own copy of a verdict object that nothing re-read. So: 0 -> 2.
const before = V.msgsView(VEC.trustPair.unverified);
const after = V.msgsView(VEC.trustPair.verified);
assert.notEqual(before, after, "verifying must move the message snapshot at all");
assert.equal(ticks(before), 0, "nothing is verified to begin with");
assert.equal(ticks(after), 2, "BOTH of ana's messages must get the tick, not just the newest");
// and the fingerprint itself never moved: it is a function of the key
assert.equal((before.match(/🦊🌊🎈/g) ?? []).length, 2);
assert.equal((after.match(/🦊🌊🎈/g) ?? []).length, 2);
});
test("…and the same flip moves the roster's badge, from the same kind of map", () => {
// The other half of "one update, both surfaces". app/ui holds ONE
// `_verdicts` map for the roster and app/chat holds ONE `_trust` map for the
// messages, and the verify button now writes to both instead of mutating a
// shared object that only one of them pointed at.
assert.equal(ticks(V.rosterView(S.one)), 0);
assert.equal(ticks(V.rosterView(S["one-verified"])), 1);
// …and the alarm replaces the tick rather than joining it
assert.equal(ticks(V.rosterView(S["one-key-changed"])), 0);
assert.ok(V.rosterView(S["one-key-changed"]).includes("⚠️"));
});
test("nothing in either view can be reached by mutating a verdict", () => {
// The structural claim: `:verified?` and `:key-changed?` are read out of a
// map keyed by identity pub, in both files, through one shared `mark`. If a
// future change puts the verdict back on the peer record, the roster
// projection would stop taking `:verdicts` and THIS is what would go red.
const anon = V.rosterProject(VEC.cases.one.peers, VEC.cases["one-verified"].ctx);
const withoutVerdicts = V.rosterProject(VEC.cases.one.peers, VEC.cases.one.ctx);
assert.notEqual(anon, withoutVerdicts, "the verdict map, and only it, decides the badge");
assert.equal(anon, S["one-verified"]);
});
|