@bongos/core 1.19.425 → 1.19.427
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +66 -31
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +14 -6
- package/docs/copy-registry.json +91 -9
- package/docs/file-map.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/profile-connections.js +304 -0
- package/modules/hall-ui/public/profile.html +16 -1
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- package/modules/hall-ui/public/style.css +38 -0
- package/modules/platform-identity/account-visibility.js +47 -1
- package/modules/platform-identity/platform-identity.js +14 -11
- package/modules/platform-identity/routes/scouting.js +61 -21
- package/modules/platform-identity/routes/sso.js +6 -0
- package/modules/platform-identity/scouting-authz.js +139 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/auth.js +59 -3
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +23 -13
- package/tests/effective_visibility_predicate.mjs +49 -4
- package/tests/profile_connections_ui.mjs +874 -0
- package/tests/scouting_owner_access_and_reach.mjs +544 -0
- package/tests/scouting_page_gate.mjs +185 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
// tests/profile_connections_ui.mjs — the profile page's connections section
|
|
2
|
+
// (R20, task 1002296 / goal 1000045 criterion C6; ADR 0202, ADR 0207).
|
|
3
|
+
//
|
|
4
|
+
// The whole stack, end to end, with nothing mocked in the middle: an in-memory
|
|
5
|
+
// accounts + connections table whose filter is built FROM EACH STATEMENT'S OWN
|
|
6
|
+
// WHERE clause, the REAL modules/platform-identity/routes/connections.js above
|
|
7
|
+
// it on a REAL loopback HTTP server, and the REAL
|
|
8
|
+
// modules/hall-ui/public/profile-connections.js above THAT in a DOM stub,
|
|
9
|
+
// talking to the server through a real socket. So every assertion below is a
|
|
10
|
+
// fact about what a browser would paint, not about a canned response.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT PINS, and why each is a rule rather than a behaviour:
|
|
13
|
+
//
|
|
14
|
+
// 1. `degree: null` RENDERS AS "OUT OF NETWORK", NEVER "NOT CONNECTED"
|
|
15
|
+
// (ADR 0202 Consequences). Past the caps or past three hops the query
|
|
16
|
+
// stopped searching; painting that as an absence of a relationship states
|
|
17
|
+
// something it never established. And the copy is BYTE-IDENTICAL whether
|
|
18
|
+
// the answer was truncated or not — the target's truncation is
|
|
19
|
+
// deliberately unreported (ADR 0207 L2), so copy that varied with
|
|
20
|
+
// `truncated` would rebuild that side channel in words.
|
|
21
|
+
//
|
|
22
|
+
// 2. A PRIVATE ACCOUNT IS ADDRESSABLE TO THE PEOPLE IT CONNECTED WITH AND TO
|
|
23
|
+
// NOBODY ELSE (ADR 0202 D4 §2). The same handle answers a degree label to
|
|
24
|
+
// one session and 404 to another, and on the 404 the section renders
|
|
25
|
+
// NOTHING — no label, no button, not even the handle.
|
|
26
|
+
//
|
|
27
|
+
// 3. THE CONNECT BUTTON CLAIMS EXACTLY `201 { status: 'pending' }` AND NO
|
|
28
|
+
// MORE (ADR 0207 D6). Including the case that matters: a re-ask swallowed
|
|
29
|
+
// by the 90-day decline cooldown answers identically, and when the page is
|
|
30
|
+
// loaded again the button is simply back to "Connect" with no explanation
|
|
31
|
+
// anywhere — because the API deliberately has none to give (ADR 0207 L1).
|
|
32
|
+
//
|
|
33
|
+
// 4. EVERY COUNT IS THE LENGTH OF THE LIST BESIDE IT, never a total the
|
|
34
|
+
// response carried (ADR 0171 D4 / ADR 0202 D4) — proved by handing the
|
|
35
|
+
// client a response whose `mutual_count` lies.
|
|
36
|
+
//
|
|
37
|
+
// 5. `seen_at` AND `truncated` NEVER REACH THE SCREEN.
|
|
38
|
+
//
|
|
39
|
+
// DB-free by construction — no Postgres, no network beyond loopback. The last
|
|
40
|
+
// test proves this file runs in the lane that actually executes on a PR; three
|
|
41
|
+
// tests once declared a lane they were in neither of and asserted nothing for
|
|
42
|
+
// months (task 1002806 / learning 1000218).
|
|
43
|
+
//
|
|
44
|
+
// Run: node --test tests/profile_connections_ui.mjs
|
|
45
|
+
|
|
46
|
+
import { strict as assert } from 'node:assert';
|
|
47
|
+
import { test, after } from 'node:test';
|
|
48
|
+
import { createRequire } from 'node:module';
|
|
49
|
+
import http from 'node:http';
|
|
50
|
+
import vm from 'node:vm';
|
|
51
|
+
import { readFileSync } from 'node:fs';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
53
|
+
import { dirname, join } from 'node:path';
|
|
54
|
+
|
|
55
|
+
const require = createRequire(import.meta.url);
|
|
56
|
+
const express = require('express');
|
|
57
|
+
|
|
58
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
59
|
+
const CLIENT_SRC = readFileSync(join(ROOT, 'modules/hall-ui/public/profile-connections.js'), 'utf8');
|
|
60
|
+
const PROFILE_JS = readFileSync(join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
61
|
+
const PROFILE_HTML = readFileSync(join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
|
|
62
|
+
|
|
63
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
// The cast. ME is the session; every other account exists to make one rule
|
|
66
|
+
// observable from the outside.
|
|
67
|
+
const ME = 4242; // the viewer
|
|
68
|
+
const ADA = 9001; // connected to ME → 1st
|
|
69
|
+
const BO = 9002; // connected to ADA → 2nd (renamed: handle 'bo', was 'bo-was')
|
|
70
|
+
const CY = 9003; // connected to BO → 3rd
|
|
71
|
+
const ZED = 9004; // connected to nobody → null, "out of network"
|
|
72
|
+
const VIC = 9005; // private, no edge with ME → not addressable at all
|
|
73
|
+
const PROV = 9006; // provisional → not addressable at all
|
|
74
|
+
const SEC = 9007; // private AND connected to ME → addressable to ME only
|
|
75
|
+
const NEW = 9008; // never asked → the Connect button's home
|
|
76
|
+
const COOL = 9009; // declined ME 10 days ago → the swallowed re-ask
|
|
77
|
+
const OUTSIDER = 7777; // a second session, to prove "and to nobody else"
|
|
78
|
+
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
// The in-memory tables + their statement interpreter
|
|
81
|
+
// ===========================================================================
|
|
82
|
+
|
|
83
|
+
let accounts = [];
|
|
84
|
+
let edges = [];
|
|
85
|
+
let nextEdgeId = 1;
|
|
86
|
+
|
|
87
|
+
const acct = (githubId, handle, name, over = {}) => ({
|
|
88
|
+
id: githubId, github_id: String(githubId), github_login: handle, display_name: name,
|
|
89
|
+
avatar_url: null, profile_state: 'active', hide_stats: false, account_visibility: 'public',
|
|
90
|
+
recruiter_discoverable: null, handle, previous_handle: null, handle_changed_at: null,
|
|
91
|
+
bio: null, links: null, display_name_source: 'github',
|
|
92
|
+
created_at: new Date(0).toISOString(), updated_at: new Date(0).toISOString(), ...over,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function edge(a, b, status, over = {}) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const row = {
|
|
98
|
+
id: nextEdgeId++, requester_github_id: a, addressee_github_id: b, status,
|
|
99
|
+
note: null, seen_at: null, created_at: now - DAY, resolved_at: status === 'pending' ? null : now - DAY,
|
|
100
|
+
...over,
|
|
101
|
+
};
|
|
102
|
+
edges.push(row);
|
|
103
|
+
return row;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function seed() {
|
|
107
|
+
accounts = [
|
|
108
|
+
acct(ME, 'octo', 'Octo'),
|
|
109
|
+
acct(ADA, 'ada', 'Ada Lovelace'),
|
|
110
|
+
acct(BO, 'bo', 'Bo', { github_login: 'bo-was', previous_handle: 'bo-was' }),
|
|
111
|
+
acct(CY, 'cy', 'Cy'),
|
|
112
|
+
acct(ZED, 'zed', 'Zed'),
|
|
113
|
+
acct(VIC, 'vic', 'Vic', { account_visibility: 'private' }),
|
|
114
|
+
acct(PROV, 'prov', 'Prov', { profile_state: 'provisional' }),
|
|
115
|
+
acct(SEC, 'sec', 'Sec', { account_visibility: 'private' }),
|
|
116
|
+
acct(NEW, 'newbie', 'Newbie'),
|
|
117
|
+
acct(COOL, 'cooled', 'Cooled'),
|
|
118
|
+
acct(OUTSIDER, 'outsider', 'Outsider'),
|
|
119
|
+
];
|
|
120
|
+
edges = [];
|
|
121
|
+
nextEdgeId = 1;
|
|
122
|
+
edge(ME, ADA, 'accepted');
|
|
123
|
+
edge(ADA, BO, 'accepted');
|
|
124
|
+
edge(BO, CY, 'accepted');
|
|
125
|
+
edge(ME, SEC, 'accepted');
|
|
126
|
+
edge(VIC, BO, 'accepted');
|
|
127
|
+
// COOL declined ME 10 days ago — inside the 90-day cooldown, so a re-ask is
|
|
128
|
+
// swallowed and must be indistinguishable from a real one.
|
|
129
|
+
edge(ME, COOL, 'rejected', { resolved_at: Date.now() - 10 * DAY });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const findAccount = (gid) => accounts.find((a) => String(a.github_id) === String(gid)) || null;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a connections-row filter from a statement's own WHERE text. Every
|
|
136
|
+
* placeholder the clause mentions must be consumed by a fragment below; one
|
|
137
|
+
* that is not throws, so a predicate this cannot read can never quietly
|
|
138
|
+
* degrade into "no constraint". (The tests/connections_ownership.mjs pattern.)
|
|
139
|
+
*/
|
|
140
|
+
function predicateFor(rawWhere, params) {
|
|
141
|
+
let where = rawWhere;
|
|
142
|
+
const tests = [];
|
|
143
|
+
const consumed = new Set();
|
|
144
|
+
const p = (n) => { consumed.add(String(n)); return params[Number(n) - 1]; };
|
|
145
|
+
|
|
146
|
+
// The either-side clause is matched and STRIPPED first, so the single-column
|
|
147
|
+
// matchers below cannot re-read half of it as a one-sided filter.
|
|
148
|
+
let m = /\(requester_github_id = \$(\d+)::bigint OR addressee_github_id = \$(\d+)::bigint\)/.exec(where);
|
|
149
|
+
if (m) {
|
|
150
|
+
const a = p(m[1]); const b = p(m[2]);
|
|
151
|
+
tests.push((r) => String(r.requester_github_id) === String(a) || String(r.addressee_github_id) === String(b));
|
|
152
|
+
where = where.replace(m[0], ' TRUE ');
|
|
153
|
+
}
|
|
154
|
+
// The unordered pair, likewise stripped before the one-sided matchers.
|
|
155
|
+
m = /pair_lo = LEAST\(\$(\d+)::bigint, \$(\d+)::bigint\)\s*AND\s*(?:c\.)?pair_hi = GREATEST\(\$(\d+)::bigint, \$(\d+)::bigint\)/.exec(where);
|
|
156
|
+
if (m) {
|
|
157
|
+
const a = p(m[1]); const b = p(m[2]); p(m[3]); p(m[4]);
|
|
158
|
+
const lo = Math.min(Number(a), Number(b)); const hi = Math.max(Number(a), Number(b));
|
|
159
|
+
tests.push((r) => Math.min(r.requester_github_id, r.addressee_github_id) === lo
|
|
160
|
+
&& Math.max(r.requester_github_id, r.addressee_github_id) === hi);
|
|
161
|
+
where = where.replace(m[0], ' TRUE ');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if ((m = /\bid = ANY\(\$(\d+)::bigint\[\]\)/.exec(where))) {
|
|
165
|
+
const ids = p(m[1]).map(String);
|
|
166
|
+
tests.push((r) => ids.includes(String(r.id)));
|
|
167
|
+
}
|
|
168
|
+
if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); }
|
|
169
|
+
if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); }
|
|
170
|
+
if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); }
|
|
171
|
+
if (/\bseen_at IS NULL\b/.test(where)) tests.push((r) => !r.seen_at);
|
|
172
|
+
if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); }
|
|
173
|
+
if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
174
|
+
const days = Number(p(m[1]));
|
|
175
|
+
tests.push((r) => r.created_at > Date.now() - days * DAY);
|
|
176
|
+
}
|
|
177
|
+
if ((m = /created_at <= now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
178
|
+
const days = Number(p(m[1]));
|
|
179
|
+
tests.push((r) => r.created_at <= Date.now() - days * DAY);
|
|
180
|
+
}
|
|
181
|
+
if ((m = /resolved_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
182
|
+
const days = Number(p(m[1]));
|
|
183
|
+
tests.push((r) => r.resolved_at != null && r.resolved_at > Date.now() - days * DAY);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
187
|
+
if (!consumed.has(ph[1])) {
|
|
188
|
+
throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return (r) => tests.every((fn) => fn(r));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The account-side floors, read OUT OF the statement rather than assumed. A
|
|
195
|
+
// read that stopped joining one of them stops matching here and throws, which
|
|
196
|
+
// is the point: the pool must not be more permissive than the SQL.
|
|
197
|
+
function accountFloor(sql, { alias = 'pa', axis }) {
|
|
198
|
+
const visible = `(${alias}.profile_state = 'active' AND ${alias}.account_visibility = 'public')`;
|
|
199
|
+
const active = `(${alias}.profile_state = 'active')`;
|
|
200
|
+
if (axis === 'visible') {
|
|
201
|
+
if (!sql.includes(visible)) throw new Error(`fake pool: expected the public visibility floor ${visible} in — ${sql}`);
|
|
202
|
+
return (a) => !!a && a.profile_state === 'active' && a.account_visibility === 'public';
|
|
203
|
+
}
|
|
204
|
+
if (!sql.includes(active)) throw new Error(`fake pool: expected the consent floor ${active} in — ${sql}`);
|
|
205
|
+
return (a) => !!a && a.profile_state === 'active';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
|
|
209
|
+
const counterparty = (gid) => {
|
|
210
|
+
const a = findAccount(gid);
|
|
211
|
+
return { handle: a.handle, display_name: a.display_name, avatar_url: a.avatar_url };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// The accepted 1-hop of one account, as the union in neighboursSql reads it.
|
|
215
|
+
const neighbours = (gid) => edges
|
|
216
|
+
.filter((r) => r.status === 'accepted' && (String(r.requester_github_id) === String(gid) || String(r.addressee_github_id) === String(gid)))
|
|
217
|
+
.map((r) => (String(r.requester_github_id) === String(gid) ? r.addressee_github_id : r.requester_github_id));
|
|
218
|
+
|
|
219
|
+
const pool = {
|
|
220
|
+
query: async (text, params = []) => {
|
|
221
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
222
|
+
|
|
223
|
+
// ---- accounts ---------------------------------------------------------
|
|
224
|
+
if (/FROM platform_identity_accounts WHERE github_id = \$1$/.test(sql)) {
|
|
225
|
+
const a = findAccount(params[0]);
|
|
226
|
+
return { rows: a ? [{ ...a }] : [], rowCount: a ? 1 : 0 };
|
|
227
|
+
}
|
|
228
|
+
if (/FROM platform_identity_accounts WHERE lower\(handle\) = \$1 OR lower\(previous_handle\) = \$1/.test(sql)) {
|
|
229
|
+
const want = String(params[0]);
|
|
230
|
+
const hit = accounts
|
|
231
|
+
.filter((a) => String(a.handle).toLowerCase() === want || String(a.previous_handle).toLowerCase() === want)
|
|
232
|
+
.sort((x, y) => Number(String(y.handle).toLowerCase() === want) - Number(String(x.handle).toLowerCase() === want));
|
|
233
|
+
return { rows: hit.slice(0, 1).map((a) => ({
|
|
234
|
+
github_id: a.github_id, handle: a.handle, display_name: a.display_name,
|
|
235
|
+
avatar_url: a.avatar_url, profile_state: a.profile_state, account_visibility: a.account_visibility,
|
|
236
|
+
})), rowCount: Math.min(hit.length, 1) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- is this pair already connected? ----------------------------------
|
|
240
|
+
if (/^SELECT 1 FROM platform_identity_connections WHERE/.test(sql)) {
|
|
241
|
+
const where = /WHERE (.+?) LIMIT 1$/.exec(sql)[1];
|
|
242
|
+
const hit = edges.filter(predicateFor(where, params));
|
|
243
|
+
return { rows: hit.slice(0, 1).map(() => ({ '?column?': 1 })), rowCount: Math.min(hit.length, 1) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- my accepted connections -----------------------------------------
|
|
247
|
+
if (/^WITH edge AS \(/.test(sql)) {
|
|
248
|
+
const floor = accountFloor(sql, { axis: 'active' });
|
|
249
|
+
const gid = params[0]; const limit = Number(params[1]); const offset = Number(params[2]);
|
|
250
|
+
const mine = edges
|
|
251
|
+
.filter((r) => r.status === 'accepted'
|
|
252
|
+
&& (String(r.requester_github_id) === String(gid) || String(r.addressee_github_id) === String(gid)))
|
|
253
|
+
.map((r) => ({ id: r.id, gid: String(r.requester_github_id) === String(gid) ? r.addressee_github_id : r.requester_github_id, resolved_at: r.resolved_at }))
|
|
254
|
+
.filter((e) => floor(findAccount(e.gid)))
|
|
255
|
+
.sort((a, b) => (b.resolved_at || 0) - (a.resolved_at || 0) || b.id - a.id)
|
|
256
|
+
.slice(offset, offset + limit);
|
|
257
|
+
return { rows: mine.map((e) => ({ id: String(e.id), connected_at: iso(e.resolved_at), ...counterparty(e.gid) })), rowCount: mine.length };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- my inbox + outbox ------------------------------------------------
|
|
261
|
+
if (/^WITH mine AS \(/.test(sql)) {
|
|
262
|
+
const floor = accountFloor(sql, { axis: 'active' });
|
|
263
|
+
const gid = params[0]; const days = Number(params[1]); const cap = Number(params[2]);
|
|
264
|
+
const fresh = (r) => r.status === 'pending' && r.created_at > Date.now() - days * DAY;
|
|
265
|
+
const pick = (side, other) => edges
|
|
266
|
+
.filter((r) => fresh(r) && String(r[side]) === String(gid))
|
|
267
|
+
.sort((a, b) => b.created_at - a.created_at || b.id - a.id)
|
|
268
|
+
.slice(0, cap)
|
|
269
|
+
.map((r) => ({ ...r, other: r[other] }));
|
|
270
|
+
const all = [
|
|
271
|
+
...pick('addressee_github_id', 'requester_github_id').map((r) => ({ ...r, incoming: true })),
|
|
272
|
+
...pick('requester_github_id', 'addressee_github_id').map((r) => ({ ...r, incoming: false })),
|
|
273
|
+
].filter((r) => floor(findAccount(r.other)));
|
|
274
|
+
all.sort((a, b) => b.created_at - a.created_at || b.id - a.id);
|
|
275
|
+
return {
|
|
276
|
+
rows: all.map((r) => ({
|
|
277
|
+
id: String(r.id), note: r.note, created_at: iso(r.created_at), seen_at: iso(r.seen_at),
|
|
278
|
+
incoming: r.incoming, ...counterparty(r.other),
|
|
279
|
+
})),
|
|
280
|
+
rowCount: all.length,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (/^UPDATE platform_identity_connections SET seen_at = now\(\)/.test(sql)) {
|
|
284
|
+
const hit = edges.filter(predicateFor(/WHERE (.+)$/.exec(sql)[1], params));
|
|
285
|
+
for (const r of hit) r.seen_at = Date.now();
|
|
286
|
+
return { rows: [], rowCount: hit.length };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- the degree label -------------------------------------------------
|
|
290
|
+
if (/AS is_first/.test(sql)) {
|
|
291
|
+
const visitorFloor = accountFloor(sql, { axis: 'active' });
|
|
292
|
+
const [viewer, target, fanout1] = params;
|
|
293
|
+
const v1 = neighbours(viewer).filter((g) => visitorFloor(findAccount(g)))
|
|
294
|
+
.sort((a, b) => a - b).slice(0, Number(fanout1));
|
|
295
|
+
const t1 = neighbours(target).sort((a, b) => a - b).slice(0, Number(fanout1));
|
|
296
|
+
const mutuals = v1.filter((g) => t1.some((x) => String(x) === String(g)))
|
|
297
|
+
.sort((a, b) => a - b).map(counterparty);
|
|
298
|
+
const isFirst = edges.some((r) => r.status === 'accepted'
|
|
299
|
+
&& Math.min(r.requester_github_id, r.addressee_github_id) === Math.min(Number(viewer), Number(target))
|
|
300
|
+
&& Math.max(r.requester_github_id, r.addressee_github_id) === Math.max(Number(viewer), Number(target)));
|
|
301
|
+
return { rows: [{ is_first: isFirst, v1_count: v1.length, mutuals }], rowCount: 1 };
|
|
302
|
+
}
|
|
303
|
+
if (/AS is_third/.test(sql)) {
|
|
304
|
+
const nearFloor = accountFloor(sql, { axis: 'active' });
|
|
305
|
+
const farFloor = accountFloor(sql, { axis: 'visible' });
|
|
306
|
+
const [viewer, target, fanout1, fanout2] = params;
|
|
307
|
+
const v1 = neighbours(viewer).filter((g) => nearFloor(findAccount(g)))
|
|
308
|
+
.sort((a, b) => a - b).slice(0, Number(fanout1));
|
|
309
|
+
const v2 = [...new Set(v1.flatMap((g) => neighbours(g)))]
|
|
310
|
+
.filter((g) => farFloor(findAccount(g)))
|
|
311
|
+
.filter((g) => String(g) !== String(viewer) && !v1.some((x) => String(x) === String(g)))
|
|
312
|
+
.sort((a, b) => a - b).slice(0, Number(fanout2));
|
|
313
|
+
const t1 = neighbours(target).sort((a, b) => a - b).slice(0, Number(fanout1));
|
|
314
|
+
return { rows: [{ is_third: v2.some((g) => t1.some((x) => String(x) === String(g))), v2_count: v2.length }], rowCount: 1 };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- ask --------------------------------------------------------------
|
|
318
|
+
if (/^WITH expired AS \(/.test(sql)) {
|
|
319
|
+
const expWhere = /UPDATE platform_identity_connections c SET status = 'expired'.*? WHERE (.+?) RETURNING/.exec(sql)[1];
|
|
320
|
+
for (const r of edges.filter(predicateFor(expWhere, params))) { r.status = 'expired'; r.resolved_at = Date.now(); }
|
|
321
|
+
const coolWhere = /SELECT 1 FROM platform_identity_connections c WHERE (.+?) \)/.exec(sql)[1];
|
|
322
|
+
if (edges.some(predicateFor(coolWhere, params))) return { rows: [], rowCount: 0 }; // swallowed
|
|
323
|
+
const open = edges.some((r) => ['pending', 'accepted'].includes(r.status)
|
|
324
|
+
&& Math.min(r.requester_github_id, r.addressee_github_id) === Math.min(Number(params[0]), Number(params[1]))
|
|
325
|
+
&& Math.max(r.requester_github_id, r.addressee_github_id) === Math.max(Number(params[0]), Number(params[1])));
|
|
326
|
+
if (open) { const e = new Error('duplicate key'); e.code = '23505'; throw e; }
|
|
327
|
+
const row = edge(Number(params[0]), Number(params[1]), 'pending', { note: params[4], created_at: Date.now(), resolved_at: null });
|
|
328
|
+
return { rows: [{ id: String(row.id) }], rowCount: 1 };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---- accept / decline -------------------------------------------------
|
|
332
|
+
let m = /^UPDATE platform_identity_connections SET status = '(accepted|rejected)'.*? WHERE (.+?) RETURNING/.exec(sql);
|
|
333
|
+
if (m) {
|
|
334
|
+
const hit = edges.filter(predicateFor(m[2], params));
|
|
335
|
+
for (const r of hit) { r.status = m[1]; r.resolved_at = Date.now(); }
|
|
336
|
+
return { rows: hit.map((r) => ({ id: String(r.id) })), rowCount: hit.length };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- remove / cancel --------------------------------------------------
|
|
340
|
+
if (/^WITH removed AS \( DELETE FROM platform_identity_connections/.test(sql)) {
|
|
341
|
+
const del = /DELETE FROM platform_identity_connections WHERE (.+?) RETURNING/.exec(sql)[1];
|
|
342
|
+
const upd = /UPDATE platform_identity_connections SET status = 'cancelled'.*? WHERE (.+?) RETURNING/.exec(sql)[1];
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const r of edges.filter(predicateFor(del, params))) { edges = edges.filter((x) => x !== r); out.push({ outcome: 'removed' }); }
|
|
345
|
+
for (const r of edges.filter(predicateFor(upd, params))) { r.status = 'cancelled'; r.resolved_at = Date.now(); out.push({ outcome: 'cancelled' }); }
|
|
346
|
+
return { rows: out, rowCount: out.length };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
throw new Error(`fake pool: unsupported statement — ${sql}`);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ===========================================================================
|
|
354
|
+
// The REAL route over that table, on a REAL socket
|
|
355
|
+
// ===========================================================================
|
|
356
|
+
|
|
357
|
+
let session = { github_id: ME, github_login: 'octo' };
|
|
358
|
+
|
|
359
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
360
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
361
|
+
require.cache[moduleApiPath] = {
|
|
362
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
363
|
+
exports: {
|
|
364
|
+
pool,
|
|
365
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
366
|
+
parsePagination: helpers.parsePagination,
|
|
367
|
+
logger: () => ({ error() {}, warn() {}, info() {} }),
|
|
368
|
+
requireBuilder: (req, res, next) => {
|
|
369
|
+
if (!session) return res.fail('no_session', 401);
|
|
370
|
+
req.builder = { ...session };
|
|
371
|
+
next();
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
const buildConnectionRoutes = require('../modules/platform-identity/routes/connections.js');
|
|
376
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
377
|
+
|
|
378
|
+
const app = express();
|
|
379
|
+
app.use(express.json());
|
|
380
|
+
app.use(attachFail);
|
|
381
|
+
app.use('/api/bongos', buildConnectionRoutes());
|
|
382
|
+
const server = app.listen(0, '127.0.0.1');
|
|
383
|
+
await new Promise((r) => server.once('listening', r));
|
|
384
|
+
const port = server.address().port;
|
|
385
|
+
after(() => server.close());
|
|
386
|
+
|
|
387
|
+
// Every path the client asked for, in order — the evidence for "this page never
|
|
388
|
+
// asks a question about someone else's connections".
|
|
389
|
+
let asked = [];
|
|
390
|
+
|
|
391
|
+
function call(method, path, body) {
|
|
392
|
+
asked.push(path);
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
395
|
+
const req = http.request({
|
|
396
|
+
host: '127.0.0.1', port, method, path,
|
|
397
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
398
|
+
}, (res) => {
|
|
399
|
+
const chunks = [];
|
|
400
|
+
res.on('data', (c) => chunks.push(c));
|
|
401
|
+
res.on('end', () => {
|
|
402
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
403
|
+
let data = null; try { data = JSON.parse(text); } catch { /* 204 */ }
|
|
404
|
+
resolve({ status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300, data });
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
req.on('error', reject);
|
|
408
|
+
req.end(payload);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ===========================================================================
|
|
413
|
+
// The REAL client module in a DOM stub above that socket
|
|
414
|
+
// ===========================================================================
|
|
415
|
+
|
|
416
|
+
const escapeHtml = (s) => String(s)
|
|
417
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
418
|
+
|
|
419
|
+
function makeEl() {
|
|
420
|
+
return {
|
|
421
|
+
hidden: false, disabled: false, dataset: {}, _html: '', _handlers: [],
|
|
422
|
+
set innerHTML(v) { this._html = String(v); },
|
|
423
|
+
get innerHTML() { return this._html; },
|
|
424
|
+
addEventListener(type, fn) { this._handlers.push({ type, fn }); },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Boot the real profile-connections.js. `transport` defaults to the live
|
|
429
|
+
// loopback client; a test may pass a canned one for a shape only a hostile or
|
|
430
|
+
// future server could send.
|
|
431
|
+
function boot({ transport } = {}) {
|
|
432
|
+
const els = new Map();
|
|
433
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
434
|
+
$('#conn-scroll').hidden = true; // the markup ships it hidden
|
|
435
|
+
const toasts = [];
|
|
436
|
+
const api = transport || { request: (method, path, opts = {}) => call(method, path, opts.body) };
|
|
437
|
+
const sandbox = {
|
|
438
|
+
console: { error() {}, warn() {}, log() {} },
|
|
439
|
+
window: {
|
|
440
|
+
BongosClient: { createClient: () => api },
|
|
441
|
+
OTB: { $, escapeHtml, fmtDate: (d) => String(d).slice(0, 10), toast: (m) => toasts.push(m) },
|
|
442
|
+
OTBKit: {
|
|
443
|
+
makePager: (prefix, key) => ({ prefix, key, page: 1, perPage: 25 }),
|
|
444
|
+
makeLedger: ({ mount, rowHtml, emptyMsg }) => ({
|
|
445
|
+
render(rows) {
|
|
446
|
+
mount.innerHTML = rows.length
|
|
447
|
+
? rows.map((r, i) => rowHtml(r, i)).join('')
|
|
448
|
+
: `<div class="empty-state"><p>${emptyMsg}</p></div>`;
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
sandbox.globalThis = sandbox;
|
|
455
|
+
vm.createContext(sandbox);
|
|
456
|
+
vm.runInContext(CLIENT_SRC, sandbox, { filename: 'profile-connections.js' });
|
|
457
|
+
// Everything the section paints, as one string — in a real DOM the ledger
|
|
458
|
+
// mount is inside the body; in this stub they are two entries in the map.
|
|
459
|
+
const html = () => `${$('#conn-body').innerHTML}${$('#conn-ledger').innerHTML}`;
|
|
460
|
+
return { render: sandbox.window.OTBConnections.render, $, html, toasts, els };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const profileOf = (githubLogin, id) => ({ builder: { id, github_login: githubLogin } });
|
|
464
|
+
|
|
465
|
+
// Drive a delegated click the way a browser would.
|
|
466
|
+
async function click(page, dataset) {
|
|
467
|
+
const btn = { dataset, disabled: false };
|
|
468
|
+
const handler = page.$('#conn-body')._handlers.find((h) => h.type === 'click');
|
|
469
|
+
assert.ok(handler, 'the section never wired its click handler');
|
|
470
|
+
await handler.fn({ target: { closest: (sel) => (sel === 'button[data-act]' ? btn : null) } });
|
|
471
|
+
return btn;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// A fresh world for each test: the tables, the session, and the call log.
|
|
475
|
+
function reset() { seed(); session = { github_id: ME, github_login: 'octo' }; asked = []; }
|
|
476
|
+
|
|
477
|
+
// ===========================================================================
|
|
478
|
+
// 0. The interpreter is the instrument, so it is calibrated before it is used
|
|
479
|
+
// ===========================================================================
|
|
480
|
+
|
|
481
|
+
test('the fake pool refuses a predicate it cannot read, rather than widening', () => {
|
|
482
|
+
assert.throws(() => predicateFor('id = $1 AND mood = $2', [1, 'blue']),
|
|
483
|
+
/unread predicate on \$2/,
|
|
484
|
+
'an unreadable filter must throw — silently ignoring it turns an owner check into no check');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('the fake pool refuses a read that dropped its account floor', () => {
|
|
488
|
+
assert.throws(() => accountFloor('SELECT 1 FROM platform_identity_accounts pa', { axis: 'active' }),
|
|
489
|
+
/expected the consent floor/,
|
|
490
|
+
'a read that stopped joining the floor must break the harness, not pass it');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
// 1. The surface: the section exists, ships hidden, and profile.js calls it
|
|
495
|
+
// ===========================================================================
|
|
496
|
+
|
|
497
|
+
test('profile.html ships the connections section hidden, and profile.js hands it the profile', () => {
|
|
498
|
+
assert.match(PROFILE_HTML, /<section[^>]*id="conn-scroll"[^>]*hidden>/,
|
|
499
|
+
'the section must ship hidden — it reveals only when the hub answered with something to show');
|
|
500
|
+
assert.match(PROFILE_HTML, /<script src="\/builders\/profile-connections\.js/);
|
|
501
|
+
assert.match(PROFILE_JS, /window\.OTBConnections\.render\(data, selfId\)/,
|
|
502
|
+
'profile.js must hand the section the profile it just rendered, or nothing ever calls it');
|
|
503
|
+
assert.match(PROFILE_JS, /const conn = \$\('#conn-scroll'\); if \(conn\) conn\.hidden = true;/,
|
|
504
|
+
'the empty state must blank this section like every other one');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ===========================================================================
|
|
508
|
+
// 2. Degree labels — every one of them over the real graph
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
|
|
511
|
+
test('a 1st-degree connection renders "1st" and offers no Connect button', async () => {
|
|
512
|
+
reset();
|
|
513
|
+
const page = boot();
|
|
514
|
+
await page.render(profileOf('ada', 11), 99);
|
|
515
|
+
assert.equal(page.$('#conn-scroll').hidden, false);
|
|
516
|
+
assert.match(page.html(), />1st</);
|
|
517
|
+
assert.match(page.html(), /You are connected\./);
|
|
518
|
+
assert.doesNotMatch(page.html(), /data-act="connect"/, 'you cannot ask someone you are already connected to');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('a 2nd-degree connection names the mutuals it was computed from', async () => {
|
|
522
|
+
reset();
|
|
523
|
+
const page = boot();
|
|
524
|
+
// BO renamed: the page holds the old handle, the API follows it and echoes
|
|
525
|
+
// back the CANONICAL one, which is what the connect button must then carry.
|
|
526
|
+
await page.render(profileOf('bo-was', 12), 99);
|
|
527
|
+
assert.match(page.html(), />2nd</);
|
|
528
|
+
assert.match(page.html(), /1 connection in common: @ada/);
|
|
529
|
+
assert.match(page.html(), /data-handle="bo"/, 'the write must address the canonical handle, never the login the page happened to hold');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('a 3rd-degree connection renders "3rd"', async () => {
|
|
533
|
+
reset();
|
|
534
|
+
const page = boot();
|
|
535
|
+
await page.render(profileOf('cy', 13), 99);
|
|
536
|
+
assert.match(page.html(), />3rd</);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ===========================================================================
|
|
540
|
+
// 3. THE null LABEL — the rule this whole section is most likely to break
|
|
541
|
+
// ===========================================================================
|
|
542
|
+
|
|
543
|
+
test('degree null renders "Out of network" and never "not connected"', async () => {
|
|
544
|
+
reset();
|
|
545
|
+
const page = boot();
|
|
546
|
+
await page.render(profileOf('zed', 14), 99);
|
|
547
|
+
assert.equal(page.$('#conn-scroll').hidden, false);
|
|
548
|
+
assert.match(page.html(), /Out of network/);
|
|
549
|
+
assert.doesNotMatch(page.html(), /not connected/i,
|
|
550
|
+
'null is "we did not find a path", not "there is no relationship" — ADR 0202 says the copy must never claim the latter');
|
|
551
|
+
assert.doesNotMatch(page.html(), /\bnull\b/, 'the raw answer must never reach the screen');
|
|
552
|
+
// It still offers the ask — out of network is not a refusal.
|
|
553
|
+
assert.match(page.html(), /data-act="connect"/);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('the out-of-network copy is byte-identical whether the expansion truncated or not', async () => {
|
|
557
|
+
reset();
|
|
558
|
+
const small = boot();
|
|
559
|
+
await small.render(profileOf('zed', 14), 99);
|
|
560
|
+
const before = small.html();
|
|
561
|
+
|
|
562
|
+
// Push the viewer past FANOUT_1 so the API really reports truncated:true.
|
|
563
|
+
const conn = require('../modules/platform-identity/connections.js');
|
|
564
|
+
for (let i = 0; i < conn.FANOUT_1; i++) {
|
|
565
|
+
const gid = 20000 + i;
|
|
566
|
+
accounts.push(acct(gid, `filler${i}`, `Filler ${i}`));
|
|
567
|
+
edge(ME, gid, 'accepted');
|
|
568
|
+
}
|
|
569
|
+
const truth = await call('GET', '/api/bongos/connections/degree/zed');
|
|
570
|
+
assert.equal(truth.data.degree, null);
|
|
571
|
+
assert.equal(truth.data.truncated, true, 'the fixture must actually reach the cap or this proves nothing');
|
|
572
|
+
|
|
573
|
+
const big = boot();
|
|
574
|
+
await big.render(profileOf('zed', 14), 99);
|
|
575
|
+
assert.equal(big.html(), before,
|
|
576
|
+
'the target’s own truncation is deliberately unreported (ADR 0207 L2); copy that varied with `truncated` would rebuild that side channel in words');
|
|
577
|
+
assert.doesNotMatch(big.html(), /truncat/i, '`truncated` must never reach the screen');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test('a degree the API never promised falls back to "out of network", never an invented label', async () => {
|
|
581
|
+
reset();
|
|
582
|
+
const page = boot({
|
|
583
|
+
transport: {
|
|
584
|
+
request: async (_m, path) => (path.startsWith('/api/bongos/connections/degree/')
|
|
585
|
+
? { status: 200, ok: true, data: { handle: 'zed', degree: 4, mutuals: [], mutual_count: 0, truncated: false } }
|
|
586
|
+
: { status: 200, ok: true, data: { incoming: [], outgoing: [] } }),
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
await page.render(profileOf('zed', 14), 99);
|
|
590
|
+
assert.match(page.html(), /Out of network/);
|
|
591
|
+
assert.doesNotMatch(page.html(), /4th/, 'the client must not name a degree the API never defined');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ===========================================================================
|
|
595
|
+
// 4. Privacy — addressable to the people you connected with, and to nobody else
|
|
596
|
+
// ===========================================================================
|
|
597
|
+
|
|
598
|
+
test('a private stranger is not addressable: nothing renders, not even the handle', async () => {
|
|
599
|
+
reset();
|
|
600
|
+
const page = boot();
|
|
601
|
+
await page.render(profileOf('vic', 15), 99);
|
|
602
|
+
assert.equal(page.$('#conn-scroll').hidden, true, 'a 404 must leave the section hidden — there is nothing honest to show');
|
|
603
|
+
assert.equal(page.html(), '');
|
|
604
|
+
assert.doesNotMatch(page.html(), /vic/i);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('a provisional account is not addressable, and answers the SAME way a private one does', async () => {
|
|
608
|
+
reset();
|
|
609
|
+
const priv = await call('GET', '/api/bongos/connections/degree/vic');
|
|
610
|
+
const prov = await call('GET', '/api/bongos/connections/degree/prov');
|
|
611
|
+
assert.equal(priv.status, 404);
|
|
612
|
+
assert.equal(prov.status, 404);
|
|
613
|
+
assert.deepEqual(prov.data, priv.data, 'the two must be indistinguishable, or the id space is probeable');
|
|
614
|
+
const page = boot();
|
|
615
|
+
await page.render(profileOf('prov', 16), 99);
|
|
616
|
+
assert.equal(page.$('#conn-scroll').hidden, true);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('a private account you connected with is addressable to YOU and to nobody else', async () => {
|
|
620
|
+
reset();
|
|
621
|
+
const mine = boot();
|
|
622
|
+
await mine.render(profileOf('sec', 17), 99);
|
|
623
|
+
assert.equal(mine.$('#conn-scroll').hidden, false);
|
|
624
|
+
assert.match(mine.html(), />1st</, 'ADR 0202 D4 §2 — a private account you are connected to is addressable to you');
|
|
625
|
+
|
|
626
|
+
session = { github_id: OUTSIDER, github_login: 'outsider' };
|
|
627
|
+
const theirs = boot();
|
|
628
|
+
await theirs.render(profileOf('sec', 17), 99);
|
|
629
|
+
assert.equal(theirs.$('#conn-scroll').hidden, true, '…and to nobody else');
|
|
630
|
+
assert.equal(theirs.html(), '');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('a private third party never appears in another builder’s page', async () => {
|
|
634
|
+
reset();
|
|
635
|
+
const page = boot();
|
|
636
|
+
// VIC is connected to BO. Rendering BO must not disclose that.
|
|
637
|
+
await page.render(profileOf('bo-was', 12), 99);
|
|
638
|
+
assert.match(page.html(), />2nd</);
|
|
639
|
+
assert.doesNotMatch(page.html(), /vic/i, 'BO’s own connections are not this page’s to show');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('this page never asks a question about anyone else’s connections', async () => {
|
|
643
|
+
reset();
|
|
644
|
+
const page = boot();
|
|
645
|
+
await page.render(profileOf('bo-was', 12), 99);
|
|
646
|
+
assert.ok(asked.length > 0);
|
|
647
|
+
for (const path of asked) {
|
|
648
|
+
assert.match(path, /^\/api\/bongos\/connections(\/(requests|degree\/[^/]+))?$/,
|
|
649
|
+
`the section may only read its own scope; ${path} names a subject the session did not`);
|
|
650
|
+
assert.doesNotMatch(path, /\/builders\//, 'a per-builder connections read would be a public list, and there is deliberately no such route');
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// ===========================================================================
|
|
655
|
+
// 5. The connect button claims exactly what a 201 supports
|
|
656
|
+
// ===========================================================================
|
|
657
|
+
|
|
658
|
+
test('Connect sends the ask and then claims only "Request sent"', async () => {
|
|
659
|
+
reset();
|
|
660
|
+
const page = boot();
|
|
661
|
+
await page.render(profileOf('newbie', 18), 99);
|
|
662
|
+
assert.match(page.html(), /data-act="connect"[^>]*data-handle="newbie"|data-handle="newbie"/);
|
|
663
|
+
|
|
664
|
+
await click(page, { act: 'connect', handle: 'newbie' });
|
|
665
|
+
assert.deepEqual(page.toasts, ['Request sent.']);
|
|
666
|
+
const after = page.html();
|
|
667
|
+
assert.match(after, /Request sent/);
|
|
668
|
+
assert.match(after, /<button class="btn-ghost" type="button" disabled>/);
|
|
669
|
+
assert.doesNotMatch(after, /notif|they will|await|pending review|accept/i,
|
|
670
|
+
'a 201 { status: "pending" } supports "Request sent" and nothing else');
|
|
671
|
+
// and the row really is in the requester's outbox
|
|
672
|
+
const reqs = await call('GET', '/api/bongos/connections/requests');
|
|
673
|
+
assert.equal(reqs.data.outgoing.length, 1);
|
|
674
|
+
assert.equal(reqs.data.outgoing[0].handle, 'newbie');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('a re-ask swallowed by the decline cooldown is INDISTINGUISHABLE, and its silence is left silent', async () => {
|
|
678
|
+
reset();
|
|
679
|
+
const page = boot();
|
|
680
|
+
await page.render(profileOf('cooled', 19), 99);
|
|
681
|
+
await click(page, { act: 'connect', handle: 'cooled' });
|
|
682
|
+
// Byte-identical to the real create above.
|
|
683
|
+
assert.deepEqual(page.toasts, ['Request sent.']);
|
|
684
|
+
assert.match(page.html(), /<button class="btn-ghost" type="button" disabled>Request sent<\/button>/);
|
|
685
|
+
// Nothing was written (ADR 0207 L1) — so on the next load the button is
|
|
686
|
+
// simply back, with NO explanation anywhere on the page.
|
|
687
|
+
const reqs = await call('GET', '/api/bongos/connections/requests');
|
|
688
|
+
assert.equal(reqs.data.outgoing.length, 0, 'the cooldown swallowed it — nothing is in the outbox');
|
|
689
|
+
const again = boot();
|
|
690
|
+
await again.render(profileOf('cooled', 19), 99);
|
|
691
|
+
assert.match(again.html(), /data-act="connect"/);
|
|
692
|
+
assert.doesNotMatch(again.html(), /declin|reject|cooldown|expired|disappear|again later/i,
|
|
693
|
+
'the API has no explanation to give, so the UI must invent none — that silence IS the decline’s protection');
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test('an already-open relationship is reported as such and does NOT claim a request was sent', async () => {
|
|
697
|
+
reset();
|
|
698
|
+
const page = boot();
|
|
699
|
+
await page.render(profileOf('newbie', 18), 99);
|
|
700
|
+
assert.match(page.html(), /data-act="connect"/);
|
|
701
|
+
// The relationship opens between this page's outbox read and its click — a
|
|
702
|
+
// second tab, or the other party asking first. The 409 must not be painted
|
|
703
|
+
// as a request this page sent.
|
|
704
|
+
edge(ME, NEW, 'pending', { created_at: Date.now(), resolved_at: null });
|
|
705
|
+
await click(page, { act: 'connect', handle: 'newbie' });
|
|
706
|
+
assert.deepEqual(page.toasts, ['There is already a request or a connection between you.']);
|
|
707
|
+
assert.doesNotMatch(page.html(), /disabled>Request sent/,
|
|
708
|
+
'only a 201 may paint "Request sent" — a 409 says something else happened, not that you asked');
|
|
709
|
+
assert.match(page.html(), /data-act="connect"/, 'the button stays as it was, because nothing this page did changed it');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('a target that stopped being addressable answers one message that names no reason', async () => {
|
|
713
|
+
reset();
|
|
714
|
+
const page = boot();
|
|
715
|
+
await page.render(profileOf('newbie', 18), 99);
|
|
716
|
+
accounts.find((a) => a.handle === 'newbie').account_visibility = 'private';
|
|
717
|
+
await click(page, { act: 'connect', handle: 'newbie' });
|
|
718
|
+
assert.deepEqual(page.toasts, ['That builder cannot be connected with right now.']);
|
|
719
|
+
assert.doesNotMatch(page.toasts.join(' '), /private|provisional|does not exist|unknown/i,
|
|
720
|
+
'absent, provisional and private are one answer — naming which one is the leak the 404 exists to prevent');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('an incoming request turns the connect button into a decision, not a second ask', async () => {
|
|
724
|
+
reset();
|
|
725
|
+
edge(NEW, ME, 'pending', { created_at: Date.now() - DAY, note: 'hello' });
|
|
726
|
+
const page = boot();
|
|
727
|
+
await page.render(profileOf('newbie', 18), 99);
|
|
728
|
+
assert.match(page.html(), /They have asked to connect\./);
|
|
729
|
+
assert.match(page.html(), /data-act="accept"/);
|
|
730
|
+
assert.match(page.html(), /data-act="decline"/);
|
|
731
|
+
assert.doesNotMatch(page.html(), /data-act="connect"/, 'asking back would be a duplicate the database refuses anyway');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// ===========================================================================
|
|
735
|
+
// 6. Counts are array lengths; seen_at never surfaces
|
|
736
|
+
// ===========================================================================
|
|
737
|
+
|
|
738
|
+
test('the mutual count is the length of the names beside it, not a number the response carried', async () => {
|
|
739
|
+
reset();
|
|
740
|
+
const page = boot({
|
|
741
|
+
transport: {
|
|
742
|
+
request: async (_m, path) => (path.startsWith('/api/bongos/connections/degree/')
|
|
743
|
+
? { status: 200, ok: true, data: { handle: 'bo', degree: 2, mutuals: [{ handle: 'ada' }, { handle: 'cy' }], mutual_count: 9, truncated: false } }
|
|
744
|
+
: { status: 200, ok: true, data: { incoming: [], outgoing: [] } }),
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
await page.render(profileOf('bo', 12), 99);
|
|
748
|
+
assert.match(page.html(), /2 connections in common: @ada, @cy/);
|
|
749
|
+
assert.doesNotMatch(page.html(), /\b9\b/,
|
|
750
|
+
'a count beside a filtered list discloses the filtered members by subtraction (ADR 0171 D4)');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('seen_at never reaches the screen, on either side of a request', async () => {
|
|
754
|
+
reset();
|
|
755
|
+
const stamped = edge(ADA, ME, 'pending', { created_at: Date.now() - DAY, seen_at: Date.now() - 3600000 });
|
|
756
|
+
const page = boot();
|
|
757
|
+
await page.render(profileOf('octo', 7), 7); // own profile
|
|
758
|
+
assert.match(page.html(), /Ada Lovelace/);
|
|
759
|
+
assert.doesNotMatch(page.html(), /seen/i);
|
|
760
|
+
assert.doesNotMatch(page.html(), new RegExp(new Date(stamped.seen_at).toISOString().slice(0, 10) + 'T'));
|
|
761
|
+
// and the API never even hands the requester one
|
|
762
|
+
session = { github_id: ADA, github_login: 'ada' };
|
|
763
|
+
const asRequester = await call('GET', '/api/bongos/connections/requests');
|
|
764
|
+
assert.equal(asRequester.data.outgoing.length, 1);
|
|
765
|
+
assert.ok(!('seen_at' in asRequester.data.outgoing[0]), 'an outgoing row must carry no read receipt');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ===========================================================================
|
|
769
|
+
// 7. Your own profile — the list, the queues, and the actions on them
|
|
770
|
+
// ===========================================================================
|
|
771
|
+
|
|
772
|
+
test('your own profile lists your connections, each 1st by definition, with both request queues', async () => {
|
|
773
|
+
reset();
|
|
774
|
+
edge(ADA, ME, 'pending', { created_at: Date.now() - DAY, note: 'worth comparing notes' });
|
|
775
|
+
edge(ME, NEW, 'pending', { created_at: Date.now() - DAY });
|
|
776
|
+
const page = boot();
|
|
777
|
+
await page.render(profileOf('octo', 7), 7);
|
|
778
|
+
assert.equal(page.$('#conn-scroll').hidden, false);
|
|
779
|
+
const html = page.html();
|
|
780
|
+
assert.match(html, /2 connections\./, 'the count is the length of the list the viewer was handed');
|
|
781
|
+
assert.equal((html.match(/conn-degree--1/g) || []).length, 2, 'every accepted connection is 1st by definition');
|
|
782
|
+
assert.equal((html.match(/>1st</g) || []).length, 2, '…and it says so — the class alone is not the label');
|
|
783
|
+
assert.match(html, /1 builder asking to connect/);
|
|
784
|
+
assert.match(html, /worth comparing notes/);
|
|
785
|
+
assert.match(html, /1 request you have sent/);
|
|
786
|
+
assert.match(html, /data-act="withdraw"/);
|
|
787
|
+
assert.match(html, /data-act="remove"/);
|
|
788
|
+
// SEC is private, and it is in MY OWN list — own scope floors on consent only.
|
|
789
|
+
assert.match(html, /@sec/);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('accepting moves the asker into your connections; declining removes it and tells them nothing', async () => {
|
|
793
|
+
reset();
|
|
794
|
+
const ask = edge(ADA, ME, 'pending', { created_at: Date.now() - DAY });
|
|
795
|
+
const page = boot();
|
|
796
|
+
await page.render(profileOf('octo', 7), 7);
|
|
797
|
+
await click(page, { act: 'accept', id: String(ask.id) });
|
|
798
|
+
assert.ok(page.toasts.includes('Connected.'));
|
|
799
|
+
|
|
800
|
+
reset();
|
|
801
|
+
const spurned = edge(NEW, ME, 'pending', { created_at: Date.now() - DAY });
|
|
802
|
+
const page2 = boot();
|
|
803
|
+
await page2.render(profileOf('octo', 7), 7);
|
|
804
|
+
await click(page2, { act: 'decline', id: String(spurned.id) });
|
|
805
|
+
assert.ok(page2.toasts.includes('Declined.'));
|
|
806
|
+
assert.doesNotMatch(page2.html(), /Newbie/, 'the request leaves the inbox');
|
|
807
|
+
// The requester's own page says nothing about it.
|
|
808
|
+
session = { github_id: NEW, github_login: 'newbie' };
|
|
809
|
+
const theirs = boot();
|
|
810
|
+
await theirs.render(profileOf('newbie', 18), 18);
|
|
811
|
+
assert.doesNotMatch(theirs.html(), /declin|reject|refus/i, 'a decline is silent to the person declined (ADR 0202 D2 §1)');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('removing a connection takes it out of the list', async () => {
|
|
815
|
+
reset();
|
|
816
|
+
const page = boot();
|
|
817
|
+
await page.render(profileOf('octo', 7), 7);
|
|
818
|
+
const id = /data-act="remove" data-id="(\d+)"/.exec(page.html())[1];
|
|
819
|
+
await click(page, { act: 'remove', id });
|
|
820
|
+
assert.ok(page.toasts.includes('Connection removed.'));
|
|
821
|
+
assert.match(page.html(), /1 connection\./);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// ===========================================================================
|
|
825
|
+
// 8. Degrade, don't 500 — an instance without the hub has no section
|
|
826
|
+
// ===========================================================================
|
|
827
|
+
|
|
828
|
+
test('an instance without the connections routes renders no section at all', async () => {
|
|
829
|
+
reset();
|
|
830
|
+
const gone = { request: async () => ({ status: 404, ok: false, data: null }) };
|
|
831
|
+
const own = boot({ transport: gone });
|
|
832
|
+
await own.render(profileOf('octo', 7), 7);
|
|
833
|
+
assert.equal(own.$('#conn-scroll').hidden, true);
|
|
834
|
+
const other = boot({ transport: gone });
|
|
835
|
+
await other.render(profileOf('ada', 11), 99);
|
|
836
|
+
assert.equal(other.$('#conn-scroll').hidden, true);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('a viewer whose own account is still provisional is told to activate it, and offered nothing else', async () => {
|
|
840
|
+
reset();
|
|
841
|
+
session = { github_id: PROV, github_login: 'prov' };
|
|
842
|
+
const page = boot();
|
|
843
|
+
await page.render(profileOf('ada', 11), 99);
|
|
844
|
+
assert.equal(page.$('#conn-scroll').hidden, false);
|
|
845
|
+
assert.match(page.html(), /activate your profile/i);
|
|
846
|
+
assert.doesNotMatch(page.html(), /data-act=/, 'a provisional account cannot send, so it is offered no action');
|
|
847
|
+
assert.doesNotMatch(page.html(), /1st|2nd|3rd|Out of network/,
|
|
848
|
+
'the label is about the relationship, and a provisional viewer has none the API will compute');
|
|
849
|
+
// Their own profile degrades the same way: nothing to show, so nothing shows.
|
|
850
|
+
const own = boot();
|
|
851
|
+
await own.render(profileOf('prov', 16), 16);
|
|
852
|
+
assert.equal(own.$('#conn-scroll').hidden, true);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('a signed-out visitor is offered no connect affordance', async () => {
|
|
856
|
+
reset();
|
|
857
|
+
session = null; // requireBuilder refuses
|
|
858
|
+
const page = boot();
|
|
859
|
+
await page.render(profileOf('ada', 11), 99);
|
|
860
|
+
assert.equal(page.$('#conn-scroll').hidden, true);
|
|
861
|
+
assert.equal(page.html(), '');
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// ===========================================================================
|
|
865
|
+
// 9. Lane proof — a test that never runs is not a gate (task 1002806)
|
|
866
|
+
// ===========================================================================
|
|
867
|
+
|
|
868
|
+
test('this proof runs in the DB-free lane that executes on every PR', () => {
|
|
869
|
+
const runner = readFileSync(join(ROOT, 'scripts', 'gds', 'run-unit-tests.js'), 'utf8');
|
|
870
|
+
assert.ok(
|
|
871
|
+
!/profile_connections_ui/.test(runner),
|
|
872
|
+
'named in run-unit-tests.js means parked into INTEGRATION or KNOWN_FAILING — where CI has no Postgres, this file would self-skip forever and gate nothing',
|
|
873
|
+
);
|
|
874
|
+
});
|