@bongos/core 1.19.1018 → 1.19.1020
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 +49 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0332-offboarding-secret-scrub-semantics.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +52 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/modules/hall-ui/public/profile-connections.js +49 -5
- package/modules/platform-identity/connection-suggestions.js +219 -0
- package/modules/platform-identity/connections.js +84 -0
- package/modules/platform-identity/routes/connections.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/db.js +22 -0
- package/src/module-api.js +1 -1
- package/tests/connection_suggestions.mjs +225 -0
- package/tests/connections_api.mjs +51 -0
- package/tests/offboarding_secret_scrub.mjs +115 -0
- package/tests/profile_connections_ui.mjs +87 -0
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1020'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// tests/connection_suggestions.mjs — "builders you may know" (R21, task
|
|
2
|
+
// 1002297, goal 1000045 criterion C6; ADR 0202 D5).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS PINS:
|
|
5
|
+
//
|
|
6
|
+
// 1. THE SCORE — D5's exact weights (3 × min(mutuals, 10) + 2 × shared
|
|
7
|
+
// projects + 1 for recency inside 30 days), including the mutual cap.
|
|
8
|
+
// 2. THE REASON — the PRIMARY signal (whichever weighs more) produces the
|
|
9
|
+
// sentence, and a shared project is named only when the row the source
|
|
10
|
+
// query returned actually carried a name (the caller's naming gate,
|
|
11
|
+
// `onMapSql(...) AND visibility = 'public'`, is asserted separately by
|
|
12
|
+
// the SQL-shape test below).
|
|
13
|
+
// 3. SILENT EXCLUSION — a candidate with an open or in-cooldown row is
|
|
14
|
+
// dropped before scoring, with no trace and no "N more" counter.
|
|
15
|
+
// 4. THE SHORT-CIRCUIT — no candidates means no further queries at all
|
|
16
|
+
// (an empty union costs nothing beyond the three source reads).
|
|
17
|
+
// 5. THE VISIBILITY FLOOR IS IN THE SQL — every statement this file issues
|
|
18
|
+
// against platform_identity_accounts carries the shared predicate,
|
|
19
|
+
// which is what lets tests/effective_visibility_predicate.mjs classify
|
|
20
|
+
// it as an adopter rather than a new unclassified read.
|
|
21
|
+
//
|
|
22
|
+
// DB-free by construction: a recording fake pool that replays queued
|
|
23
|
+
// responses in call order, no Postgres, no server.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/connection_suggestions.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { test } from 'node:test';
|
|
29
|
+
// ESM named import off the CJS module (the tests/project_invite_suggestions.mjs
|
|
30
|
+
// precedent): a createRequire() import reads as unused to knip, and
|
|
31
|
+
// MAX_SUGGESTIONS has no other caller.
|
|
32
|
+
import { listConnectionSuggestions, MAX_SUGGESTIONS } from '../modules/platform-identity/connection-suggestions.js';
|
|
33
|
+
|
|
34
|
+
// A pool that replays queued responses IN CALL ORDER and records what it saw.
|
|
35
|
+
// listConnectionSuggestions issues, in order: mutualFrontier, shared-project
|
|
36
|
+
// candidates, excluded ids — then, only if at least one candidate survived,
|
|
37
|
+
// identities and last-active.
|
|
38
|
+
function fakePool(responses) {
|
|
39
|
+
const calls = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
return {
|
|
42
|
+
calls,
|
|
43
|
+
query: async (text, params) => {
|
|
44
|
+
calls.push({ text: String(text), params: params || [] });
|
|
45
|
+
const next = responses[i++];
|
|
46
|
+
return { rows: next || [] };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const VIEWER = 4242;
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const RECENT = new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
|
|
54
|
+
const STALE = new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago
|
|
55
|
+
|
|
56
|
+
test('scores mutuals and shared projects per D5\'s weights, caps mutuals at 10, and picks the primary-signal reason', async () => {
|
|
57
|
+
// Arrange — 2001 is mutual-heavy and recent; 2002 is shared-project-heavy
|
|
58
|
+
// and stale; 2003 shares a project too but is excluded (open request).
|
|
59
|
+
const pool = fakePool([
|
|
60
|
+
[{ github_id: '2001', mutual_count: 3 }, { github_id: '2002', mutual_count: 1 }], // mutualFrontier
|
|
61
|
+
[{ github_id: '2002', shared_projects: 2, named_project: 'Acme' }, // sharedProjectCandidates
|
|
62
|
+
{ github_id: '2003', shared_projects: 1, named_project: null }],
|
|
63
|
+
[{ github_id: '2003' }], // excludedSuggestionIds
|
|
64
|
+
[{ github_id: '2001', handle: 'ada', display_name: 'Ada', avatar_url: 'a.png' }, // identityById
|
|
65
|
+
{ github_id: '2002', handle: 'grace', display_name: 'Grace', avatar_url: 'g.png' }],
|
|
66
|
+
[{ github_id: '2001', last_active: RECENT }, { github_id: '2002', last_active: STALE }], // lastActiveById
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Act
|
|
70
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
71
|
+
|
|
72
|
+
// Assert — score is RANKING, never RESPONSE (it never crosses the wire, so
|
|
73
|
+
// it is verified through ORDER and REASON, not a returned number): ada's
|
|
74
|
+
// 3 mutuals (weight 9, +1 recency = 10) outranks grace's 1 mutual + 2
|
|
75
|
+
// shared projects (weight 3 + 4 = 7, stale so no recency point).
|
|
76
|
+
assert.deepEqual(out.map((s) => s.handle), ['ada', 'grace'], '2003 is excluded and never appears; 2001 outranks 2002');
|
|
77
|
+
assert.deepEqual(Object.keys(out[0]).sort(), ['avatar_url', 'display_name', 'handle', 'reason'],
|
|
78
|
+
'an explicit whitelist — no score, no github_id');
|
|
79
|
+
assert.equal(out[0].reason, '3 mutual connections', 'mutuals is the primary (and only) signal for ada');
|
|
80
|
+
assert.equal(out[1].reason, 'You both build on Acme', 'shared projects outweighs the single mutual, and the project is named');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a shared project with no publicly-named catalogue row falls back to the generic reason', async () => {
|
|
84
|
+
const pool = fakePool([
|
|
85
|
+
[], // mutualFrontier
|
|
86
|
+
[{ github_id: '3001', shared_projects: 1, named_project: null }], // sharedProjectCandidates
|
|
87
|
+
[], // excludedSuggestionIds
|
|
88
|
+
[{ github_id: '3001', handle: 'linus', display_name: 'Linus', avatar_url: null }],
|
|
89
|
+
[{ github_id: '3001', last_active: null }],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
93
|
+
|
|
94
|
+
assert.equal(out.length, 1);
|
|
95
|
+
assert.equal(out[0].reason, 'A project you both build on', 'the aggregate is not the leak, the name is — D5');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the mutual weight is capped at MUTUAL_CAP mutuals, however many the frontier found', async () => {
|
|
99
|
+
// 4002 has 40 raw mutuals, 4001 has exactly the cap (10) — if the cap did
|
|
100
|
+
// NOT apply, 4002's higher raw count would outrank 4001 outright. Both
|
|
101
|
+
// capping at the same weight (30) is what puts them in a TIE, which the
|
|
102
|
+
// github_id tie-break then resolves — so 4001 sorting first, despite its
|
|
103
|
+
// smaller raw count, is the proof the cap actually fired.
|
|
104
|
+
const pool = fakePool([
|
|
105
|
+
[{ github_id: '4002', mutual_count: 40 }, { github_id: '4001', mutual_count: 10 }],
|
|
106
|
+
[],
|
|
107
|
+
[],
|
|
108
|
+
[{ github_id: '4001', handle: 'ten', display_name: null, avatar_url: null },
|
|
109
|
+
{ github_id: '4002', handle: 'forty', display_name: null, avatar_url: null }],
|
|
110
|
+
[],
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(out.map((s) => s.handle), ['ten', 'forty'], 'weight stops growing past 10 mutuals (D5)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('no candidates from either signal costs nothing beyond the three source reads', async () => {
|
|
119
|
+
const pool = fakePool([[], [], []]);
|
|
120
|
+
|
|
121
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(out, []);
|
|
124
|
+
assert.equal(pool.calls.length, 3, 'identity and last-active are never queried for an empty candidate set');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a candidate the identity read drops (gone private mid-request) is silently absent from the output', async () => {
|
|
128
|
+
const pool = fakePool([
|
|
129
|
+
[{ github_id: '5001', mutual_count: 2 }],
|
|
130
|
+
[],
|
|
131
|
+
[],
|
|
132
|
+
[], // identityById returns nothing for 5001
|
|
133
|
+
[{ github_id: '5001', last_active: null }],
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
137
|
+
|
|
138
|
+
assert.deepEqual(out, [], 'a candidate absent from the identity read never reaches the caller');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the list is capped at MAX_SUGGESTIONS, highest score first', async () => {
|
|
142
|
+
const N = MAX_SUGGESTIONS + 5;
|
|
143
|
+
const mutuals = Array.from({ length: N }, (_, i) => ({ github_id: String(6000 + i), mutual_count: N - i }));
|
|
144
|
+
const identities = mutuals.map((m) => ({ github_id: m.github_id, handle: `b${m.github_id}`, display_name: null, avatar_url: null }));
|
|
145
|
+
const pool = fakePool([mutuals, [], [], identities, []]);
|
|
146
|
+
|
|
147
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
148
|
+
|
|
149
|
+
assert.equal(out.length, MAX_SUGGESTIONS, 'D3: "ranked, LIMIT 20"');
|
|
150
|
+
assert.equal(out[0].handle, 'b6000', 'the highest mutual_count sorts first');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a tie on score breaks on last_active, then github_id — the list is stable (D5)', async () => {
|
|
154
|
+
const pool = fakePool([
|
|
155
|
+
[{ github_id: '7002', mutual_count: 1 }, { github_id: '7001', mutual_count: 1 }],
|
|
156
|
+
[],
|
|
157
|
+
[],
|
|
158
|
+
[{ github_id: '7001', handle: 'a', display_name: null, avatar_url: null },
|
|
159
|
+
{ github_id: '7002', handle: 'b', display_name: null, avatar_url: null }],
|
|
160
|
+
[{ github_id: '7001', last_active: null }, { github_id: '7002', last_active: null }],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(out.map((s) => s.handle), ['a', 'b'], 'equal score and last_active — the lower github_id sorts first');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('every SQL statement carries the shared visibility predicate — never a hand-rolled variant', async () => {
|
|
169
|
+
const pool = fakePool([
|
|
170
|
+
[{ github_id: '8001', mutual_count: 1 }],
|
|
171
|
+
[{ github_id: '8002', shared_projects: 1, named_project: null }],
|
|
172
|
+
[],
|
|
173
|
+
[{ github_id: '8001', handle: 'a', display_name: null, avatar_url: null }],
|
|
174
|
+
[{ github_id: '8001', last_active: null }],
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
178
|
+
|
|
179
|
+
const accountReads = pool.calls.filter((c) => /platform_identity_accounts/.test(c.text));
|
|
180
|
+
assert.ok(accountReads.length >= 2, 'the frontier, the shared-project read and the identity read all touch accounts');
|
|
181
|
+
for (const c of accountReads) {
|
|
182
|
+
assert.match(c.text, /account_visibility = 'public'/, `every account read floors on existence — saw: ${c.text.slice(0, 120)}`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a co-membership query requires the hub\'s own sign-in witness, not bare row existence (ADR 0205 D4)', async () => {
|
|
187
|
+
const pool = fakePool([[], [], []]);
|
|
188
|
+
await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
189
|
+
const membershipReads = pool.calls.filter((c) => /platform_identity_project_memberships/.test(c.text));
|
|
190
|
+
assert.ok(membershipReads.length >= 1, 'sharedProjectCandidates must have run');
|
|
191
|
+
for (const c of membershipReads) {
|
|
192
|
+
assert.match(c.text, /signin_verified_at IS NOT NULL/,
|
|
193
|
+
`a membership row a federated client wrote for someone unproven must not surface — saw: ${c.text.slice(0, 160)}`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('hide_stats blinds the recency signal — a hidden candidate\'s real activity never affects score or order, and never crosses the wire', async () => {
|
|
198
|
+
// Both candidates score identically on mutuals alone (3×2=6); 9001 is
|
|
199
|
+
// hide_stats and genuinely more recent, 9002 is public and stale. If
|
|
200
|
+
// hide_stats were NOT honoured, 9001's recency point would break the tie
|
|
201
|
+
// and put it first. It must not: hidden or not, the github_id tie-break
|
|
202
|
+
// decides, so 9002 (the lower id) sorts first despite being the stale one.
|
|
203
|
+
const pool = fakePool([
|
|
204
|
+
[{ github_id: '9002', mutual_count: 2 }, { github_id: '9001', mutual_count: 2 }],
|
|
205
|
+
[],
|
|
206
|
+
[],
|
|
207
|
+
[{ github_id: '9001', handle: 'hidden', display_name: null, avatar_url: null, hide_stats: true },
|
|
208
|
+
{ github_id: '9002', handle: 'plain', display_name: null, avatar_url: null, hide_stats: false }],
|
|
209
|
+
[{ github_id: '9001', last_active: RECENT }, { github_id: '9002', last_active: STALE }],
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
const out = await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
213
|
+
|
|
214
|
+
assert.deepEqual(out.map((s) => s.handle), ['plain', 'hidden'],
|
|
215
|
+
'the hidden builder\'s real recency must not win it the tie');
|
|
216
|
+
for (const s of out) assert.ok(!('score' in s) && !('hide_stats' in s), 'neither the ranking scalar nor the flag itself ever reaches the wire');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('the mutual frontier\'s 2-hop expansion is capped, not just its output (D3\'s FANOUT_2 bound)', async () => {
|
|
220
|
+
const pool = fakePool([[], [], []]);
|
|
221
|
+
await listConnectionSuggestions({ githubId: VIEWER, pool });
|
|
222
|
+
const frontier = pool.calls[0].text;
|
|
223
|
+
assert.match(frontier, /LIMIT \$4/, 'the raw edges CTE carries its own LIMIT, distinct from the output LIMIT $3');
|
|
224
|
+
assert.equal(pool.calls[0].params[3], 5000, 'defaults to FANOUT_2 — the same bound degreeBetween puts on its own 2-hop frontier');
|
|
225
|
+
});
|
|
@@ -533,6 +533,57 @@ test('the caps are the ADR 0202 D3 numbers, and DEGREE_MAX really is 3', () => {
|
|
|
533
533
|
assert.equal(conn.DECLINE_COOLDOWN_DAYS, 90);
|
|
534
534
|
});
|
|
535
535
|
|
|
536
|
+
// ===========================================================================
|
|
537
|
+
// 4b. "Builders you may know" (R21, task 1002297 — ADR 0202 D5)
|
|
538
|
+
// ===========================================================================
|
|
539
|
+
//
|
|
540
|
+
// The SCORING itself (weights, the mutual cap, the reason string, exclusion)
|
|
541
|
+
// is pinned DB-free against the domain function directly in
|
|
542
|
+
// tests/connection_suggestions.mjs. This is the ROUTE wrapper: own-scope,
|
|
543
|
+
// the response envelope, and the consent floor every other read here shares.
|
|
544
|
+
|
|
545
|
+
test('suggestions: returns the ranked list with reasons, and costs the bounded read set', async () => {
|
|
546
|
+
expect(
|
|
547
|
+
meActive(),
|
|
548
|
+
result([{ github_id: '9001', mutual_count: 2 }]), // mutualFrontier
|
|
549
|
+
result([]), // sharedProjectCandidates
|
|
550
|
+
result([]), // excludedSuggestionIds
|
|
551
|
+
result([{ github_id: '9001', handle: 'bob', display_name: 'Bob', avatar_url: null }]), // identityById
|
|
552
|
+
result([{ github_id: '9001', last_active: null }]), // lastActiveById
|
|
553
|
+
);
|
|
554
|
+
const res = await call('GET', '/api/bongos/connections/suggestions');
|
|
555
|
+
assert.equal(res.status, 200);
|
|
556
|
+
assert.deepEqual(res.json.suggestions.map((s) => s.handle), ['bob']);
|
|
557
|
+
assert.equal(res.json.suggestions[0].reason, '2 mutual connections');
|
|
558
|
+
assert.equal(res.json.count, res.json.suggestions.length, 'the count is the array\'s own length');
|
|
559
|
+
assert.equal(calls.length, 6, 'own account, then the three candidate reads, then identity + last-active');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('suggestions: the candidate pool is bound to the SESSION account, never a request parameter', async () => {
|
|
563
|
+
expect(meActive(), result([]), result([]), result([]));
|
|
564
|
+
const res = await call('GET', '/api/bongos/connections/suggestions?github_id=99999999');
|
|
565
|
+
assert.equal(res.status, 200);
|
|
566
|
+
assert.deepEqual(res.json, { suggestions: [], count: 0 });
|
|
567
|
+
// The mutualFrontier query (calls[1]) binds $1 to the SESSION id — a
|
|
568
|
+
// query-string parameter this route never reads cannot reach it.
|
|
569
|
+
assert.equal(calls[1].params[0], ME.github_id);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('suggestions: an empty candidate pool costs no identity or last-active read', async () => {
|
|
573
|
+
expect(meActive(), result([]), result([]), result([]));
|
|
574
|
+
const res = await call('GET', '/api/bongos/connections/suggestions');
|
|
575
|
+
assert.equal(res.status, 200);
|
|
576
|
+
assert.deepEqual(res.json.suggestions, []);
|
|
577
|
+
assert.equal(calls.length, 4, 'own account plus the three source reads — nothing more for an empty union');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test('suggestions: a provisional account is refused before any candidate read runs', async () => {
|
|
581
|
+
expect(result([{ github_id: String(ME.github_id), profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', handle: 'octo' }]));
|
|
582
|
+
const res = await call('GET', '/api/bongos/connections/suggestions');
|
|
583
|
+
assert.equal(res.status, 403);
|
|
584
|
+
assert.equal(calls.length, 1, 'refused at the consent floor (D4 §1), before any candidate read');
|
|
585
|
+
});
|
|
586
|
+
|
|
536
587
|
// ===========================================================================
|
|
537
588
|
// 5. Nothing here is authority (ADR 0016 / ADR 0141)
|
|
538
589
|
// ===========================================================================
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/offboarding_secret_scrub.mjs — deactivateBuilder scrubs stored secrets
|
|
2
|
+
// (task 1003338 / ADR 0332).
|
|
3
|
+
//
|
|
4
|
+
// DB-free, mirroring the withTx-stub pattern in tests/government_rank_sync.mjs:
|
|
5
|
+
// kernel.withTx is stubbed to hand deactivateBuilder/reactivateBuilder a single
|
|
6
|
+
// recording fake client BEFORE src/bongos/db.js is required (it destructures
|
|
7
|
+
// withTx at load time). No government seam is registered, so
|
|
8
|
+
// syncGovernanceRankRole's optional port resolves to nothing and no-ops —
|
|
9
|
+
// out of scope for this file.
|
|
10
|
+
//
|
|
11
|
+
// Two secrets, two different guarantees under test:
|
|
12
|
+
// 1. github_oauth_tokens (core-owned) — deleteGithubToken must be called
|
|
13
|
+
// INSIDE deactivateBuilder's transaction (a real DELETE, not an event).
|
|
14
|
+
// 2. the module-owned Gemini BYOK key — core never touches those columns;
|
|
15
|
+
// it emits builder.secrets-scrub-needed and a listening module (not
|
|
16
|
+
// vendored in this checkout) clears its own store. Asserted here by
|
|
17
|
+
// registering a stub listener and checking the payload.
|
|
18
|
+
// reactivateBuilder must do neither — restoring a stored secret on return is
|
|
19
|
+
// exactly the behaviour ADR 0332 rules out.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/offboarding_secret_scrub.mjs
|
|
22
|
+
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
|
|
32
|
+
const kernel = require(path.join(ROOT, 'src/bongos/db-kernel.js'));
|
|
33
|
+
const seams = require(path.join(ROOT, 'src/module-seams.js'));
|
|
34
|
+
|
|
35
|
+
// A minimal recording client: answers just enough of deactivateBuilder's /
|
|
36
|
+
// reactivateBuilder's queries to run to completion, and records every call so
|
|
37
|
+
// tests can assert on what was (or wasn't) issued.
|
|
38
|
+
function exitTxClient({ builderId, rank, status, newRank, newStatus }) {
|
|
39
|
+
const calls = [];
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
find: (re) => calls.find((c) => re.test(c.sql)),
|
|
43
|
+
async query(sql, params) {
|
|
44
|
+
calls.push({ sql, params });
|
|
45
|
+
if (/FROM builders WHERE id = \$1 FOR UPDATE/.test(sql)) {
|
|
46
|
+
return { rows: [{ id: builderId, rank, status }] };
|
|
47
|
+
}
|
|
48
|
+
if (/FROM claims/.test(sql)) return { rows: [] };
|
|
49
|
+
if (/UPDATE builders/.test(sql)) {
|
|
50
|
+
return { rows: [{ id: builderId, rank: newRank, status: newStatus }] };
|
|
51
|
+
}
|
|
52
|
+
return { rows: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let txClient = null;
|
|
58
|
+
kernel.withTx = async (fn) => fn(txClient);
|
|
59
|
+
const coreDb = require(path.join(ROOT, 'src/bongos/db.js'));
|
|
60
|
+
|
|
61
|
+
test('deactivateBuilder deletes the github_oauth_tokens row inside its transaction', async () => {
|
|
62
|
+
txClient = exitTxClient({
|
|
63
|
+
builderId: 9001, rank: 'metic', status: 'active', newRank: 'xenos', newStatus: 'inactive',
|
|
64
|
+
});
|
|
65
|
+
await coreDb.deactivateBuilder({ builderId: 9001, reason: 'left the team', actorBuilderId: 3 });
|
|
66
|
+
|
|
67
|
+
const del = txClient.find(/DELETE FROM github_oauth_tokens/);
|
|
68
|
+
assert.ok(del, 'deactivateBuilder must delete the stored GitHub token in-transaction');
|
|
69
|
+
assert.deepEqual(del.params, [9001]);
|
|
70
|
+
|
|
71
|
+
const sessionRevokeIdx = txClient.calls.findIndex((c) => /UPDATE builder_sessions SET revoked_at/.test(c.sql));
|
|
72
|
+
const tokenDeleteIdx = txClient.calls.findIndex((c) => /DELETE FROM github_oauth_tokens/.test(c.sql));
|
|
73
|
+
assert.ok(tokenDeleteIdx > sessionRevokeIdx, 'the token delete must be inside the same transaction, after the session revoke');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('deactivateBuilder emits builder.secrets-scrub-needed with { builderId, actor }', async () => {
|
|
77
|
+
txClient = exitTxClient({
|
|
78
|
+
builderId: 9002, rank: 'metic', status: 'active', newRank: 'xenos', newStatus: 'inactive',
|
|
79
|
+
});
|
|
80
|
+
let received = null;
|
|
81
|
+
const off = seams.on('builder.secrets-scrub-needed', async (payload) => { received = payload; });
|
|
82
|
+
try {
|
|
83
|
+
await coreDb.deactivateBuilder({ builderId: 9002, reason: null, actorBuilderId: 5 });
|
|
84
|
+
} finally {
|
|
85
|
+
off();
|
|
86
|
+
}
|
|
87
|
+
assert.ok(received, 'the module teardown event must fire post-commit');
|
|
88
|
+
assert.equal(received.builderId, 9002);
|
|
89
|
+
assert.equal(received.actor, 'builder-exit:builder#5');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a listener-free instance (no module installed) still completes deactivation cleanly', async () => {
|
|
93
|
+
// No seams.on registered here — mirrors a vanilla instance with art-pipeline off.
|
|
94
|
+
txClient = exitTxClient({
|
|
95
|
+
builderId: 9003, rank: 'xenos', status: 'active', newRank: 'xenos', newStatus: 'inactive',
|
|
96
|
+
});
|
|
97
|
+
const result = await coreDb.deactivateBuilder({ builderId: 9003, reason: null, actorBuilderId: 3 });
|
|
98
|
+
assert.equal(result.builder.status, 'inactive');
|
|
99
|
+
assert.ok(txClient.find(/DELETE FROM github_oauth_tokens/), 'the core-owned delete still runs with no module listening');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('reactivateBuilder does not delete the github token row or emit the scrub event', async () => {
|
|
103
|
+
txClient = exitTxClient({
|
|
104
|
+
builderId: 9004, rank: 'xenos', status: 'inactive', newRank: 'xenos', newStatus: 'active',
|
|
105
|
+
});
|
|
106
|
+
let scrubFired = false;
|
|
107
|
+
const off = seams.on('builder.secrets-scrub-needed', async () => { scrubFired = true; });
|
|
108
|
+
try {
|
|
109
|
+
await coreDb.reactivateBuilder({ builderId: 9004, actorBuilderId: 3 });
|
|
110
|
+
} finally {
|
|
111
|
+
off();
|
|
112
|
+
}
|
|
113
|
+
assert.ok(!txClient.find(/DELETE FROM github_oauth_tokens/), 'reactivation must not touch the token table');
|
|
114
|
+
assert.ok(!scrubFired, 'reactivation must not re-trigger the secrets scrub');
|
|
115
|
+
});
|
|
@@ -913,6 +913,93 @@ test('removing a connection takes it out of the list', async () => {
|
|
|
913
913
|
assert.match(page.html(), /1 connection\./);
|
|
914
914
|
});
|
|
915
915
|
|
|
916
|
+
// ===========================================================================
|
|
917
|
+
// 7b. "Builders you may know" (R21, task 1002297 — ADR 0202 D5)
|
|
918
|
+
// ===========================================================================
|
|
919
|
+
//
|
|
920
|
+
// The SCORING (weights, the mutual cap, exclusion, the reason string) is
|
|
921
|
+
// pinned DB-free against the domain function in
|
|
922
|
+
// tests/connection_suggestions.mjs, and the route wrapper in
|
|
923
|
+
// tests/connections_api.mjs. This is the RENDER: canned suggestions data
|
|
924
|
+
// over a real `boot()`, because modelling the frontier/co-membership SQL in
|
|
925
|
+
// this file's in-memory graph would duplicate that coverage rather than add
|
|
926
|
+
// to it — the same reason the degree tests above use a canned transport for
|
|
927
|
+
// "a degree the API never promised" and "the mutual count is the length…".
|
|
928
|
+
|
|
929
|
+
function suggestTransport(suggestions, { onConnect } = {}) {
|
|
930
|
+
const calls = { suggestions: 0 };
|
|
931
|
+
return {
|
|
932
|
+
calls,
|
|
933
|
+
request: async (method, path, opts = {}) => {
|
|
934
|
+
if (method === 'POST' && path === '/api/bongos/connections') {
|
|
935
|
+
if (onConnect) onConnect(opts.body && opts.body.handle);
|
|
936
|
+
return { status: 201, ok: true, data: { status: 'pending' } };
|
|
937
|
+
}
|
|
938
|
+
if (method === 'GET' && path === '/api/bongos/connections') return { status: 200, ok: true, data: { connections: [], count: 0 } };
|
|
939
|
+
if (method === 'GET' && path === '/api/bongos/connections/requests') return { status: 200, ok: true, data: { incoming: [], outgoing: [] } };
|
|
940
|
+
if (method === 'GET' && path === '/api/bongos/connections/suggestions') {
|
|
941
|
+
calls.suggestions++;
|
|
942
|
+
return { status: 200, ok: true, data: { suggestions, count: suggestions.length } };
|
|
943
|
+
}
|
|
944
|
+
return { status: 404, ok: false, data: null };
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
test('the own profile renders "builders you may know" with the server\'s own reason', async () => {
|
|
950
|
+
reset();
|
|
951
|
+
const page = boot({
|
|
952
|
+
transport: suggestTransport([
|
|
953
|
+
{ handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 mutual connections' },
|
|
954
|
+
]),
|
|
955
|
+
});
|
|
956
|
+
await page.render(profileOf('octo', 7), 7);
|
|
957
|
+
assert.match(page.html(), /Builders you may know/);
|
|
958
|
+
assert.match(page.html(), /3 mutual connections/, 'the server picked the reason; the page states it verbatim');
|
|
959
|
+
assert.match(page.html(), /data-act="suggest-connect"/);
|
|
960
|
+
assert.match(page.html(), /data-handle="grace"/);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test('an empty suggestion list renders no heading at all — never an empty shell', async () => {
|
|
964
|
+
reset();
|
|
965
|
+
const page = boot({ transport: suggestTransport([]) });
|
|
966
|
+
await page.render(profileOf('octo', 7), 7);
|
|
967
|
+
assert.doesNotMatch(page.html(), /Builders you may know/);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test('a failed suggestions read leaves the rest of the own-profile section intact', async () => {
|
|
971
|
+
reset();
|
|
972
|
+
const page = boot({
|
|
973
|
+
transport: {
|
|
974
|
+
request: async (_m, path) => {
|
|
975
|
+
if (path === '/api/bongos/connections') return { status: 200, ok: true, data: { connections: [], count: 0 } };
|
|
976
|
+
if (path === '/api/bongos/connections/requests') return { status: 200, ok: true, data: { incoming: [], outgoing: [] } };
|
|
977
|
+
return { status: 500, ok: false, data: null }; // suggestions read fails
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
});
|
|
981
|
+
await page.render(profileOf('octo', 7), 7);
|
|
982
|
+
assert.equal(page.$('#conn-scroll').hidden, false, 'suggestions are additive, never load-bearing for the section itself');
|
|
983
|
+
assert.doesNotMatch(page.html(), /Builders you may know/);
|
|
984
|
+
assert.match(page.html(), /0 connections\./);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test('clicking Connect on a suggestion sends the ask by handle and refreshes the section', async () => {
|
|
988
|
+
reset();
|
|
989
|
+
let asked = null;
|
|
990
|
+
const transport = suggestTransport(
|
|
991
|
+
[{ handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: 'You both build on Acme' }],
|
|
992
|
+
{ onConnect: (h) => { asked = h; } },
|
|
993
|
+
);
|
|
994
|
+
const page = boot({ transport });
|
|
995
|
+
await page.render(profileOf('octo', 7), 7);
|
|
996
|
+
assert.equal(transport.calls.suggestions, 1, 'the first render read suggestions once');
|
|
997
|
+
await click(page, { act: 'suggest-connect', handle: 'grace' });
|
|
998
|
+
assert.equal(asked, 'grace', 'the request names the SUGGESTION\'S handle, never an id');
|
|
999
|
+
assert.deepEqual(page.toasts, ['Request sent.']);
|
|
1000
|
+
assert.equal(transport.calls.suggestions, 2, 'the click really did trigger a full re-render, not just a toast');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
916
1003
|
// ===========================================================================
|
|
917
1004
|
// 8. Degrade, don't 500 — an instance without the hub has no section
|
|
918
1005
|
// ===========================================================================
|