@bongos/core 1.19.431 → 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 +29 -24
- package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- 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 +79 -49
- 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/scouting_owner_access_and_reach.mjs +171 -39
- package/tests/scouting_rollup_fanout.mjs +15 -4
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Proves the consent + hide semantics against real SQL — pg-mem cannot execute
|
|
4
4
|
// unnest(), so the rollup queries need a live Postgres (the task-1002911
|
|
5
5
|
// precedent). Replays the module's actual migrations, then asserts:
|
|
6
|
-
// 1. listScoutingRollup lists ONLY
|
|
7
|
-
// provisional and
|
|
8
|
-
// construction.
|
|
6
|
+
// 1. listScoutingRollup lists ONLY consented + not-hidden builders (D2/D3) —
|
|
7
|
+
// provisional, hidden and never-accepted accounts drop out of /people +
|
|
8
|
+
// /scouting by construction. Consent is TWO columns since ADR 0213
|
|
9
|
+
// (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
|
|
10
|
+
// LEGACY fixture is the pre-terms row that proves the second one.
|
|
9
11
|
// 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
|
|
10
12
|
// catalog row is publicly listed (featured + active); everything else is
|
|
11
13
|
// counted in more_projects, never named. client_id never crosses.
|
|
@@ -44,8 +46,12 @@ try {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// LEGACY is the ADR 0213 shape: profile_state='active' from before the platform
|
|
50
|
+
// terms existed, so terms_accepted_at is NULL. Consent is TWO columns now, and
|
|
51
|
+
// this is the row that makes that second column load-bearing here — delete the
|
|
52
|
+
// acceptance term from accountActiveSql and this fixture is the one that flips.
|
|
53
|
+
const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281, LEGACY = 90002283;
|
|
54
|
+
const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY, LEGACY];
|
|
49
55
|
const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
|
|
50
56
|
const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
|
|
51
57
|
const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
|
|
@@ -63,19 +69,32 @@ let pass = 0;
|
|
|
63
69
|
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
64
70
|
|
|
65
71
|
try {
|
|
66
|
-
// The module's actual migrations (idempotent).
|
|
72
|
+
// The module's actual migrations (idempotent). 012 and 019 are here because
|
|
73
|
+
// the predicates under test READ their columns: accountVisibleSql compares
|
|
74
|
+
// account_visibility, scoutingListableSql COALESCEs recruiter_discoverable
|
|
75
|
+
// (012), and the consent floor tests terms_accepted_at (019). Without them a
|
|
76
|
+
// FRESH database answers 42703 "column does not exist" — an infrastructure
|
|
77
|
+
// error dressed up as a boundary failure — instead of a clean assertion.
|
|
67
78
|
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
68
79
|
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
69
80
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
70
81
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
82
|
+
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
83
|
+
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
71
84
|
await cleanup();
|
|
72
85
|
|
|
86
|
+
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|
|
87
|
+
// an acceptance of the platform terms. A fixture that seeds only the first is
|
|
88
|
+
// a row from BEFORE the terms shipped, and is deliberately not consented — so
|
|
89
|
+
// every consented fixture here stamps both, exactly as upsertAccount's
|
|
90
|
+
// activate branch does, and LEGACY stamps only the first on purpose.
|
|
73
91
|
await q(
|
|
74
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats)
|
|
75
|
-
VALUES ($1, 'active-b', 'Active', 'active', false),
|
|
76
|
-
($2, 'hidden-b', 'Hidden', 'active', true),
|
|
77
|
-
($3, 'prov-b', 'Prov', 'provisional', false)
|
|
78
|
-
|
|
92
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats, terms_accepted_at)
|
|
93
|
+
VALUES ($1, 'active-b', 'Active', 'active', false, now()),
|
|
94
|
+
($2, 'hidden-b', 'Hidden', 'active', true, now()),
|
|
95
|
+
($3, 'prov-b', 'Prov', 'provisional', false, NULL),
|
|
96
|
+
($4, 'legacy-b', 'Legacy', 'active', false, NULL)`,
|
|
97
|
+
[ACTIVE, HIDDEN, PROV, LEGACY],
|
|
79
98
|
);
|
|
80
99
|
// Three reporting projects: one publicly catalogued (featured+active), one
|
|
81
100
|
// catalogued but NOT featured (→ never named), one with no catalog row at all.
|
|
@@ -96,15 +115,21 @@ try {
|
|
|
96
115
|
($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
|
|
97
116
|
($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
|
|
98
117
|
($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
|
|
99
|
-
($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z')
|
|
100
|
-
|
|
118
|
+
($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z'),
|
|
119
|
+
($1, $7, 90, 3, 1, 'metic', '{engineer}', '2026-01-01T00:00:00Z')`,
|
|
120
|
+
[...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
|
|
101
121
|
);
|
|
102
122
|
|
|
103
123
|
// 1 — the scouting/people predicate (D2/D3).
|
|
104
124
|
const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
105
|
-
ok(listed.includes(ACTIVE), 'active + visible builder is listed');
|
|
125
|
+
ok(listed.includes(ACTIVE), 'active + accepted + visible builder is listed');
|
|
106
126
|
ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
|
|
107
127
|
ok(!listed.includes(PROV), 'provisional (no hub-session consent) builder drops out');
|
|
128
|
+
// The ADR 0213 half, against real SQL. LEGACY differs from ACTIVE in ONE
|
|
129
|
+
// column, so this is the acceptance term and nothing else: an account
|
|
130
|
+
// activated before the platform terms existed has no consent to aggregate on
|
|
131
|
+
// and does not publish until it re-accepts at its next hub sign-in.
|
|
132
|
+
ok(!listed.includes(LEGACY), 'active but never-accepted (pre-terms) builder drops out too — consent is two columns');
|
|
108
133
|
|
|
109
134
|
// 2 — getRollupForBuilder semantics.
|
|
110
135
|
const r = await pi.getRollupForBuilder(ACTIVE, { pool });
|
|
@@ -124,6 +149,7 @@ try {
|
|
|
124
149
|
// 3 — the port reads.
|
|
125
150
|
ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
|
|
126
151
|
ok(await pi.getPublicProfileExtras(PROV, { pool }) === null, 'public read → null for a provisional builder');
|
|
152
|
+
ok(await pi.getPublicProfileExtras(LEGACY, { pool }) === null, 'public read → null for an active builder who never accepted the terms (ADR 0213)');
|
|
127
153
|
ok(await pi.getPublicProfileExtras(99999999999, { pool }) === null, 'public read → null with no account');
|
|
128
154
|
ok(await pi.getPublicProfileExtras('system:bfg', { pool }) === null, 'non-numeric principal → null, no bigint cast error');
|
|
129
155
|
const pub = await pi.getPublicProfileExtras(ACTIVE, { pool });
|
|
@@ -138,9 +164,15 @@ try {
|
|
|
138
164
|
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b', activate: true }, { pool });
|
|
139
165
|
acct = await pi.getAccount(LAZY, { pool });
|
|
140
166
|
ok(acct.profile_state === 'active', 'an authorize-path upsert (activate) flips it active');
|
|
167
|
+
// BOTH consent columns come from that ONE branch (ADR 0213), which is what
|
|
168
|
+
// makes "active without an acceptance" a legacy shape rather than a state the
|
|
169
|
+
// code can still produce — and what makes the LEGACY fixture above honest.
|
|
170
|
+
ok(acct.terms_accepted_at !== null, 'the same activate branch stamps the T&C acceptance');
|
|
171
|
+
const firstAccept = acct.terms_accepted_at;
|
|
141
172
|
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
|
|
142
173
|
acct = await pi.getAccount(LAZY, { pool });
|
|
143
174
|
ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
|
|
175
|
+
ok(String(acct.terms_accepted_at) === String(firstAccept), 'and never rewrites the acceptance — the FIRST one stands (idempotent by COALESCE)');
|
|
144
176
|
|
|
145
177
|
// 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
|
|
146
178
|
// effect IMMEDIATELY on every public read — the profile port and the
|
|
@@ -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
|
}
|
|
@@ -439,7 +521,8 @@ await t('and the whole matrix lands exactly where the specs put it', async () =>
|
|
|
439
521
|
assert.deepEqual(shown, ['octo', 'optin'],
|
|
440
522
|
'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
|
|
441
523
|
+ 'out: optout (opted out), scout (private, and reach never buys addressability), '
|
|
442
|
-
+
|
|
524
|
+
+ 'shy (hidden), newbie (provisional), unaccepted (never accepted the platform '
|
|
525
|
+
+ `terms — ADR 0213) — got ${JSON.stringify(shown)}`);
|
|
443
526
|
});
|
|
444
527
|
|
|
445
528
|
await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
|
|
@@ -472,6 +555,55 @@ await t('MUTATION CHECK: drop the reach half and the opted-out builder comes str
|
|
|
472
555
|
'WITHOUT it: optout is readmitted — so the passing assertions above are load-bearing, not decorative');
|
|
473
556
|
});
|
|
474
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
|
+
|
|
475
607
|
await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
|
|
476
608
|
const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
|
|
477
609
|
assert.match(page, /fetch\('\/api\/gds\/scouting/,
|
|
@@ -503,7 +635,7 @@ await t('opting out of recruiting grants and denies NO permission', async () =>
|
|
|
503
635
|
|
|
504
636
|
await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
|
|
505
637
|
const res = await getScouting(OWNER);
|
|
506
|
-
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']) {
|
|
507
639
|
assert.ok(!res.text.includes(leak), `${leak} must never serialize into the directory body`);
|
|
508
640
|
}
|
|
509
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
|