@bongos/core 1.19.430 → 1.19.432
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 +64 -44
- 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/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
- package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +64 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +48 -48
- package/docs/copy-registry.json +57 -57
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/settings.js +17 -8
- package/modules/platform-identity/account-visibility.js +47 -0
- package/modules/platform-identity/platform-identity.js +4 -4
- package/modules/platform-identity/recruiter-sliver.js +197 -0
- package/modules/platform-identity/routes/scouting.js +58 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +115 -9
- package/tests/application_echo.mjs +43 -9
- package/tests/application_lifecycle.mjs +18 -7
- package/tests/connections_ownership.mjs +44 -4
- package/tests/effective_visibility_predicate.mjs +137 -50
- package/tests/government_require_permission.mjs +22 -0
- package/tests/handle_machinery.mjs +15 -1
- package/tests/hub_profile_leak.mjs +18 -4
- package/tests/own_session_revoke.mjs +77 -9
- package/tests/profile_connections_ui.mjs +49 -10
- package/tests/profile_privacy_owner_view.mjs +68 -37
- package/tests/profile_rollup_consent.mjs +46 -14
- package/tests/profile_route.mjs +6 -0
- package/tests/recruiter_sliver_boundary.mjs +841 -0
- package/tests/scouting_owner_access_and_reach.mjs +175 -39
- package/tests/scouting_rollup_fanout.mjs +15 -4
|
@@ -22,13 +22,31 @@
|
|
|
22
22
|
// including from their own copy of it.
|
|
23
23
|
//
|
|
24
24
|
// WHY THE FAKE POOL IS SHAPED THE WAY IT IS. Every filter below is built FROM THE
|
|
25
|
-
// STATEMENT'S OWN WHERE CLAUSE — the tests/
|
|
26
|
-
//
|
|
27
|
-
// from
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
25
|
+
// STATEMENT'S OWN WHERE CLAUSE — the tests/recruiter_sliver_boundary.mjs pattern.
|
|
26
|
+
// So deleting the reach term from listScoutingRollup genuinely stops the clause
|
|
27
|
+
// from carrying the COALESCE, the opted-out row genuinely comes back, and the
|
|
28
|
+
// assertions below genuinely go red. A statement carrying a placeholder, a
|
|
29
|
+
// literal comparison, a NULL test or a boolean SHAPE the interpreter cannot read
|
|
30
|
+
// is a LOUD ERROR, never a silently absent filter — a guard that degrades to "no
|
|
31
|
+
// WHERE at all" passes forever and protects nothing.
|
|
32
|
+
//
|
|
33
|
+
// IT READS STRUCTURE, NOT JUST TERMS, and that is why each filter is a CONSUMING
|
|
34
|
+
// parse rather than a list of `.test()` calls. Two ways a term-collecting
|
|
35
|
+
// interpreter lies, both found and both closed here (task 1003354):
|
|
36
|
+
//
|
|
37
|
+
// THE MISSING TERM. ADR 0213 made consent two columns and this file modelled
|
|
38
|
+
// only the first, so `terms_accepted_at IS NOT NULL` was silently ignored —
|
|
39
|
+
// the exact failure the paragraph above claims is impossible. An unmodelled
|
|
40
|
+
// term now survives in the residue and the structure check refuses it.
|
|
41
|
+
// THE REWRITTEN JOINER. An interpreter that COLLECTS the terms it recognises
|
|
42
|
+
// and ANDs them re-imposes an `AND` the SQL may no longer contain: rewrite
|
|
43
|
+
// scoutingListableSql's `AND` to `OR` and Postgres serves every active
|
|
44
|
+
// account on the platform, while a term-collecting fake answers exactly as
|
|
45
|
+
// before (idea 1000877, from the adversarial review of task 1002968).
|
|
46
|
+
//
|
|
47
|
+
// So each recognised term is REMOVED from the text as it is matched, and whatever
|
|
48
|
+
// survives must be nothing but whitespace, parentheses and `AND`. Both mutations
|
|
49
|
+
// are pinned by executable checks below, on the REAL predicates.
|
|
32
50
|
//
|
|
33
51
|
// Everything above the pool is real: the real route file, the real
|
|
34
52
|
// requirePermission gate over the real government catalog, real HTTP. Only the
|
|
@@ -75,27 +93,43 @@ async function t(name, fn) {
|
|
|
75
93
|
// ===========================================================================
|
|
76
94
|
|
|
77
95
|
// Accounts. Only the columns the predicate reads matter; the rest is display.
|
|
96
|
+
//
|
|
97
|
+
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state = 'active' AND
|
|
98
|
+
// terms_accepted_at IS NOT NULL, both stamped by the same upsertAccount branch.
|
|
99
|
+
// So a consented fixture carries both, and a row carrying only the first is an
|
|
100
|
+
// account activated BEFORE the platform terms existed — a real shape in the
|
|
101
|
+
// database, and the one that makes the acceptance term below load-bearing here
|
|
102
|
+
// rather than merely satisfied by every row.
|
|
103
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
104
|
+
|
|
78
105
|
const ACCOUNTS = [
|
|
79
106
|
// The control: public, unhidden, reach unset (NULL = follow visibility = in).
|
|
80
107
|
{ github_id: '4242', github_login: 'octo', display_name: 'Octo',
|
|
81
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
108
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
82
109
|
// THE ROW THIS TASK EXISTS FOR: public, unhidden, and explicitly opted OUT.
|
|
83
110
|
// Before R14 this account appeared in /scouting anyway.
|
|
84
111
|
{ github_id: '5000', github_login: 'optout', display_name: 'Opt Out',
|
|
85
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: false },
|
|
112
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: false },
|
|
86
113
|
// Explicitly opted IN while public — the same effective answer as NULL, from
|
|
87
114
|
// the other side of the tri-state. Proves the column is read, not the default.
|
|
88
115
|
{ github_id: '8000', github_login: 'optin', display_name: 'Opt In',
|
|
89
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: true },
|
|
116
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: true },
|
|
90
117
|
// Private AND discoverable — the spec's D2 "private / discoverable" row. Reach
|
|
91
118
|
// never buys addressability: the full rollup is never its disclosure.
|
|
92
119
|
{ github_id: '5151', github_login: 'scout', display_name: 'Scout',
|
|
93
|
-
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
120
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
94
121
|
// Hidden, and provisional — the two floors that already held before R14.
|
|
95
122
|
{ github_id: '6060', github_login: 'shy', display_name: 'Shy',
|
|
96
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
123
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
97
124
|
{ github_id: '7070', github_login: 'newbie', display_name: 'Newbie',
|
|
98
|
-
profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
125
|
+
profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
126
|
+
// The ADR 0213 floor, and the ONLY row that differs from `octo` in exactly one
|
|
127
|
+
// column: active and public and unhidden and reachable, but activated before
|
|
128
|
+
// the platform terms existed. Delete the acceptance term from the composition
|
|
129
|
+
// and this is the row that walks back in — which is what makes the term's
|
|
130
|
+
// presence in the SQL provable here rather than merely asserted.
|
|
131
|
+
{ github_id: '3030', github_login: 'unaccepted', display_name: 'Unaccepted',
|
|
132
|
+
profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
99
133
|
];
|
|
100
134
|
|
|
101
135
|
// Project memberships, as the hub holds them. `membership_kind` is what the
|
|
@@ -133,24 +167,75 @@ const CLIENTS = [
|
|
|
133
167
|
|
|
134
168
|
const KNOWN_ACCOUNT_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'recruiter_discoverable']);
|
|
135
169
|
|
|
170
|
+
// What a fully-consumed WHERE may leave behind: whitespace, the parentheses the
|
|
171
|
+
// predicates compose with, and the `AND` that joins them. Anything else — an
|
|
172
|
+
// `OR`, a `NOT`, a `CASE`, or a term this interpreter never learned — means the
|
|
173
|
+
// fake would be applying a predicate the database was never given.
|
|
174
|
+
const PURE_CONJUNCTION = /^[\s()]*(?:AND[\s()]*)*$/;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Make a consuming term-matcher over one WHERE string. `take(re, make)` matches,
|
|
178
|
+
* models, and SPLICES the matched text out, so `residue()` is exactly the part
|
|
179
|
+
* of the clause this interpreter did not understand — and `assertPureConjunction`
|
|
180
|
+
* can then refuse a boolean shape it cannot faithfully reproduce.
|
|
181
|
+
*
|
|
182
|
+
* Written out per file rather than shared: each interpreter's TERM vocabulary is
|
|
183
|
+
* its own table's, and a shared registry would be a small SQL engine. What must
|
|
184
|
+
* not drift is the REFUSAL, so the guard below is identical everywhere and each
|
|
185
|
+
* copy pins it with its own calibration test.
|
|
186
|
+
*/
|
|
187
|
+
function consumingParse(where) {
|
|
188
|
+
let residue = where;
|
|
189
|
+
const tests = [];
|
|
190
|
+
return {
|
|
191
|
+
tests,
|
|
192
|
+
take(re, make) {
|
|
193
|
+
const m = re.exec(residue);
|
|
194
|
+
if (!m) return null;
|
|
195
|
+
residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
|
|
196
|
+
tests.push(make(m));
|
|
197
|
+
return m;
|
|
198
|
+
},
|
|
199
|
+
assertPureConjunction() {
|
|
200
|
+
if (!PURE_CONJUNCTION.test(residue)) {
|
|
201
|
+
throw new Error(`fake pool: unmodelled boolean structure "${residue.trim()}" in "${where}" — every recognised term is ANDed here, so a predicate that is not a pure conjunction must be a loud error, never a silently different filter`);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
136
207
|
/**
|
|
137
208
|
* A row test for platform_identity_accounts, built from `where`. Literal
|
|
138
209
|
* comparisons count as much as placeholders: the effective-visibility predicate
|
|
139
210
|
* is made ENTIRELY of literals, so an interpreter that modelled only $N filters
|
|
140
|
-
* would score every privacy term as absent and pass no matter what.
|
|
211
|
+
* would score every privacy term as absent and pass no matter what. And the
|
|
212
|
+
* consent half is a NULL TEST, a third syntax again — which is precisely how it
|
|
213
|
+
* went unmodelled and unnoticed until task 1003354.
|
|
141
214
|
*/
|
|
142
215
|
function accountFilter(where, params) {
|
|
143
|
-
const tests =
|
|
216
|
+
const { tests, take, assertPureConjunction } = consumingParse(where);
|
|
144
217
|
const consumed = new Set();
|
|
145
218
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
// re-implemented
|
|
219
|
+
// The reach axis FIRST, because recruiterReachSql's COALESCE spells
|
|
220
|
+
// `account_visibility = 'public'` inside itself as its own fallback —
|
|
221
|
+
// consuming it first is what stops that fallback being scored as a separate
|
|
222
|
+
// existence check. Modelled through the SHARED JS twin rather than
|
|
223
|
+
// re-implemented: an interpreter that read the COALESCE its own way could
|
|
151
224
|
// agree with a WRONG query and still call it a pass.
|
|
152
|
-
|
|
153
|
-
|
|
225
|
+
take(/\bCOALESCE\((?:[a-z_]+\.)?recruiter_discoverable,\s*(?:[a-z_]+\.)?account_visibility = 'public'\s*\)/,
|
|
226
|
+
() => isRecruiterDiscoverable);
|
|
227
|
+
take(/\b(?:[a-z_]+\.)?profile_state = 'active'/, () => (r) => r.profile_state === 'active');
|
|
228
|
+
// The T&C half of the CONSENT floor (ADR 0213). Neither sweep below can see
|
|
229
|
+
// it — the placeholder sweep scans `$N`, the literal sweep matches `col = '…'`
|
|
230
|
+
// — so before this line the term was silently dropped and every fixture sailed
|
|
231
|
+
// through a predicate it did not satisfy. Now it is modelled; if it ever
|
|
232
|
+
// changes shape, the residue check refuses the statement instead.
|
|
233
|
+
take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
|
|
234
|
+
take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
|
|
235
|
+
take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
|
|
236
|
+
|
|
237
|
+
// The TERM guards run before the STRUCTURE guard, so an unreadable term is
|
|
238
|
+
// reported as the unreadable term it is rather than as leftover text.
|
|
154
239
|
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
155
240
|
if (!consumed.has(m[1])) {
|
|
156
241
|
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
@@ -161,6 +246,7 @@ function accountFilter(where, params) {
|
|
|
161
246
|
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
162
247
|
}
|
|
163
248
|
}
|
|
249
|
+
assertPureConjunction();
|
|
164
250
|
assert.ok(tests.length > 0, `fake pool: the accounts predicate in "${where}" carried NO readable term — the boundary would be vacuous`);
|
|
165
251
|
return (r) => tests.every((fn) => fn(r));
|
|
166
252
|
}
|
|
@@ -172,33 +258,29 @@ function accountFilter(where, params) {
|
|
|
172
258
|
* how the client-status join announced itself when it was added.
|
|
173
259
|
*/
|
|
174
260
|
function membershipFilter(where, params) {
|
|
175
|
-
const tests =
|
|
261
|
+
const { tests, take, assertPureConjunction } = consumingParse(where);
|
|
176
262
|
const consumed = new Set();
|
|
177
263
|
const clientStatus = (clientId) => (CLIENTS.find((c) => c.client_id === clientId) || {}).status;
|
|
264
|
+
const bind = (m) => { consumed.add(m[1]); return params[Number(m[1]) - 1]; };
|
|
178
265
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const byKind = /\bmembership_kind = \$(\d+)/.exec(where);
|
|
183
|
-
if (byKind) { consumed.add(byKind[1]); const v = params[Number(byKind[1]) - 1]; tests.push((r) => r.membership_kind === v); }
|
|
184
|
-
|
|
185
|
-
if (/\bsignin_verified_at IS NOT NULL\b/.test(where)) tests.push((r) => !!r.signin_verified_at);
|
|
186
|
-
|
|
266
|
+
take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => { const v = bind(m); return (r) => String(r.github_id) === String(v); });
|
|
267
|
+
take(/\b(?:[a-z_]+\.)?membership_kind = \$(\d+)/, (m) => { const v = bind(m); return (r) => r.membership_kind === v; });
|
|
268
|
+
take(/\b(?:[a-z_]+\.)?signin_verified_at IS NOT NULL\b/, () => (r) => !!r.signin_verified_at);
|
|
187
269
|
// The JOIN's own condition. Modelled as a real lookup against CLIENTS, so a
|
|
188
270
|
// membership whose project is not in the table drops out the way an INNER JOIN
|
|
189
271
|
// drops it — not as a term that quietly passes.
|
|
190
|
-
|
|
191
|
-
if (byStatus) {
|
|
192
|
-
consumed.add(byStatus[1]);
|
|
193
|
-
const v = params[Number(byStatus[1]) - 1];
|
|
194
|
-
tests.push((r) => clientStatus(r.client_id) === v);
|
|
195
|
-
}
|
|
272
|
+
take(/\bc\.status = \$(\d+)/, (m) => { const v = bind(m); return (r) => clientStatus(r.client_id) === v; });
|
|
196
273
|
|
|
197
274
|
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
198
275
|
if (!consumed.has(m[1])) {
|
|
199
276
|
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}"`);
|
|
200
277
|
}
|
|
201
278
|
}
|
|
279
|
+
// The ownership gate is four ANDed terms and an `OR` anywhere in it would
|
|
280
|
+
// admit on ANY one of them — an un-witnessed claim, or a membership on a
|
|
281
|
+
// suspended project. The same refusal the accounts filter makes, for the same
|
|
282
|
+
// reason: this interpreter must not invent a joiner the database never saw.
|
|
283
|
+
assertPureConjunction();
|
|
202
284
|
assert.ok(tests.length > 0, `fake pool: the membership predicate in "${where}" carried NO readable term`);
|
|
203
285
|
return (r) => tests.every((fn) => fn(r));
|
|
204
286
|
}
|
|
@@ -263,6 +345,10 @@ require.cache[moduleApiPath] = {
|
|
|
263
345
|
// THE REAL GATE. Not a stub: the 403 a non-owner gets below is produced by
|
|
264
346
|
// production code resolving production permission atoms.
|
|
265
347
|
requirePermission: auth.requirePermission,
|
|
348
|
+
// The sliver route beside the directory gates on rank (ADR 0215 D4), so the
|
|
349
|
+
// PARTIAL doorway this proof pins has to carry it — otherwise requiring the
|
|
350
|
+
// router throws and this file fails for a reason that is not its subject.
|
|
351
|
+
requireRank: auth.requireRank,
|
|
266
352
|
requireBuilder: (req, res, next) => {
|
|
267
353
|
if (!currentCaller) return res.fail('unauthenticated', 401);
|
|
268
354
|
req.builder = { ...currentCaller };
|
|
@@ -435,7 +521,8 @@ await t('and the whole matrix lands exactly where the specs put it', async () =>
|
|
|
435
521
|
assert.deepEqual(shown, ['octo', 'optin'],
|
|
436
522
|
'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
|
|
437
523
|
+ 'out: optout (opted out), scout (private, and reach never buys addressability), '
|
|
438
|
-
+
|
|
524
|
+
+ 'shy (hidden), newbie (provisional), unaccepted (never accepted the platform '
|
|
525
|
+
+ `terms — ADR 0213) — got ${JSON.stringify(shown)}`);
|
|
439
526
|
});
|
|
440
527
|
|
|
441
528
|
await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
|
|
@@ -468,6 +555,55 @@ await t('MUTATION CHECK: drop the reach half and the opted-out builder comes str
|
|
|
468
555
|
'WITHOUT it: optout is readmitted — so the passing assertions above are load-bearing, not decorative');
|
|
469
556
|
});
|
|
470
557
|
|
|
558
|
+
await t('MUTATION CHECK: drop the ACCEPTANCE half and the pre-terms account walks back in', async () => {
|
|
559
|
+
// The same standard applied to the ADR 0213 term, and the reason a fixture
|
|
560
|
+
// that never accepted the platform terms exists at all: without it every row
|
|
561
|
+
// would satisfy the acceptance clause and deleting the clause would change
|
|
562
|
+
// nothing observable — a modelled term that proves nothing. The clause is
|
|
563
|
+
// removed from the REAL predicate rather than hand-written, so this cannot
|
|
564
|
+
// drift from what account-visibility.js actually composes.
|
|
565
|
+
const withTerms = scoutingListableSql('pa');
|
|
566
|
+
assert.match(withTerms, /pa\.terms_accepted_at IS NOT NULL/,
|
|
567
|
+
'the directory predicate must still carry the acceptance term at all (ADR 0213)');
|
|
568
|
+
const withoutTerms = withTerms.replace(" AND pa.terms_accepted_at IS NOT NULL", '');
|
|
569
|
+
|
|
570
|
+
const admits = (clause) => ACCOUNTS.filter(accountFilter(clause, [])).map((a) => a.github_login).sort();
|
|
571
|
+
assert.deepEqual(admits(withTerms), ['octo', 'optin'], 'with the acceptance term: the pre-terms account is out');
|
|
572
|
+
assert.deepEqual(admits(withoutTerms), ['octo', 'optin', 'unaccepted'],
|
|
573
|
+
'WITHOUT it: an account activated before the platform terms existed is served the recruiting '
|
|
574
|
+
+ 'directory on a consent premise that was never true — which is what the term prevents');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await t('CALIBRATION: the interpreter refuses what it cannot model, rather than ignoring it', () => {
|
|
578
|
+
// Every guard above is worth exactly as much as this test. Before task 1003354
|
|
579
|
+
// the acceptance term fell through all three sweeps in silence and this file
|
|
580
|
+
// reported 17 passed while modelling a predicate the database no longer had.
|
|
581
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND tier = 'gold'", []),
|
|
582
|
+
/unrecognised literal comparison on tier/,
|
|
583
|
+
'a literal comparison on an unknown column is a term this interpreter cannot apply');
|
|
584
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND github_id = $1", ['4242']),
|
|
585
|
+
/unrecognised predicate on \$1/,
|
|
586
|
+
'and an unread placeholder, which is the older half of the same rule');
|
|
587
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND deleted_at IS NULL", []),
|
|
588
|
+
/unmodelled boolean structure "[^"]*deleted_at IS NULL"/,
|
|
589
|
+
'a NULL TEST is a third syntax again — invisible to both sweeps above, and the exact shape '
|
|
590
|
+
+ 'that let terms_accepted_at be dropped silently');
|
|
591
|
+
assert.throws(() => accountFilter("profile_state = 'active' OR account_visibility = 'public'", []),
|
|
592
|
+
/unmodelled boolean structure/,
|
|
593
|
+
'AND rewritten to OR serves every active account on the platform; a term-collecting '
|
|
594
|
+
+ 'interpreter would re-impose the AND and stay green (idea 1000877)');
|
|
595
|
+
assert.throws(() => membershipFilter("github_id = $1 OR membership_kind = $2", ['9001', 'owner']),
|
|
596
|
+
/unmodelled boolean structure/,
|
|
597
|
+
'the ownership gate gets the same refusal — an OR there admits on any one of its four terms');
|
|
598
|
+
// And the shapes it really does model still parse clean, so the guard is a
|
|
599
|
+
// filter on the unreadable and not a blanket refusal.
|
|
600
|
+
assert.doesNotThrow(() => accountFilter(scoutingListableSql('pa'), []));
|
|
601
|
+
assert.doesNotThrow(() => membershipFilter(
|
|
602
|
+
"m.github_id = $1 AND m.membership_kind = $2 AND m.signin_verified_at IS NOT NULL AND c.status = $3",
|
|
603
|
+
['9001', 'owner', 'active'],
|
|
604
|
+
));
|
|
605
|
+
});
|
|
606
|
+
|
|
471
607
|
await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
|
|
472
608
|
const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
|
|
473
609
|
assert.match(page, /fetch\('\/api\/gds\/scouting/,
|
|
@@ -499,7 +635,7 @@ await t('opting out of recruiting grants and denies NO permission', async () =>
|
|
|
499
635
|
|
|
500
636
|
await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
|
|
501
637
|
const res = await getScouting(OWNER);
|
|
502
|
-
for (const leak of ['recruiter_discoverable', 'account_visibility', 'profile_state', 'hide_stats', 'client_id']) {
|
|
638
|
+
for (const leak of ['recruiter_discoverable', 'account_visibility', 'profile_state', 'terms_accepted_at', 'hide_stats', 'client_id']) {
|
|
503
639
|
assert.ok(!res.text.includes(leak), `${leak} must never serialize into the directory body`);
|
|
504
640
|
}
|
|
505
641
|
const row = JSON.parse(res.text).builders[0];
|
|
@@ -51,17 +51,28 @@ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${
|
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
53
|
// The module's actual migrations (idempotent CREATE TABLE IF NOT EXISTS).
|
|
54
|
+
// 012 and 019 are replayed because listScoutingRollup joins scoutingListableSql,
|
|
55
|
+
// which reads account_visibility + recruiter_discoverable (012) and
|
|
56
|
+
// terms_accepted_at (019). Without them a FRESH database answers 42703
|
|
57
|
+
// "column does not exist" rather than an aggregation assertion.
|
|
54
58
|
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
55
59
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
56
60
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
61
|
+
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
62
|
+
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
57
63
|
await cleanup();
|
|
58
64
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
65
|
+
// Since ADR 0171 the rollup only lists consented, visible builders, and since
|
|
66
|
+
// ADR 0213 consent is TWO columns — profile_state='active' AND an acceptance
|
|
67
|
+
// of the platform terms, both stamped by the same upsertAccount branch. This
|
|
68
|
+
// test is about AGGREGATION, so every fixture opts fully in; a row carrying
|
|
69
|
+
// only profile_state would be a pre-terms account and would drop out.
|
|
61
70
|
// (The predicate itself is proven by tests/profile_rollup_consent.mjs.)
|
|
62
71
|
await q(
|
|
63
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
64
|
-
VALUES ($1, 'multi', 'Multi', 'a', 'active'
|
|
72
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state, terms_accepted_at)
|
|
73
|
+
VALUES ($1, 'multi', 'Multi', 'a', 'active', now()),
|
|
74
|
+
($2, 'solo', 'Solo', 'b', 'active', now()),
|
|
75
|
+
($3, 'ghost', 'Ghost', 'c', 'active', now())`,
|
|
65
76
|
[MULTI, SOLO, GHOST],
|
|
66
77
|
);
|
|
67
78
|
// MULTI: two projects, 2 and 3 disciplines — the exact fan-out shape the old
|