@bongos/core 1.19.1016 → 1.19.1017
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 +78 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +56 -5
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_route.mjs +23 -5
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- package/tests/ui_design_styles.mjs +13 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// tests/community_search_route.mjs
|
|
2
|
+
//
|
|
3
|
+
// Guards GET /community/search — the PUBLIC builder search (D4,
|
|
4
|
+
// docs/specs/bongos-v1-community-tab.md; task 1002979). DB-free: a fake pool
|
|
5
|
+
// asserts the REAL query text literally contains the shared predicates
|
|
6
|
+
// (accountVisibleSql('pa') / rollupVisibleSql('pa2'), read from
|
|
7
|
+
// account-visibility.js — never re-implemented), so deleting either predicate
|
|
8
|
+
// from builder-search.js fails THIS test, not just the separate structural
|
|
9
|
+
// guard. The fake's own row-matching (which rows pass) is then a fixture
|
|
10
|
+
// simulation layered on top, applying the same JS-twin predicates to decide
|
|
11
|
+
// what the query WOULD return — it is not, on its own, proof of the SQL, and
|
|
12
|
+
// isn't presented as such; the predicate-presence assertions above are what
|
|
13
|
+
// actually pins the SQL.
|
|
14
|
+
//
|
|
15
|
+
// What this pins, in order:
|
|
16
|
+
// 1. the pure helpers (clampLimit/clampOffset/escapeLikePrefix) — no DB.
|
|
17
|
+
// 2. the D4 matrix, end to end through the real router + real domain fn:
|
|
18
|
+
// - a hide_stats account is findable BY NAME (name-only search).
|
|
19
|
+
// - the SAME account drops out of a DISCIPLINE-filtered search (a
|
|
20
|
+
// discipline is rollup-derived, and hide_stats suppresses the rollup).
|
|
21
|
+
// - private and provisional accounts never appear, filtered or not.
|
|
22
|
+
// - a plain active+public account is findable by name and by its real
|
|
23
|
+
// discipline, and absent under a discipline it does not hold.
|
|
24
|
+
// 3. input validation (400 on missing/too-short/oversized q, oversized
|
|
25
|
+
// discipline), the 500 path, the rate limiter, and the response shape
|
|
26
|
+
// (github_id never crosses, MAX_OFFSET clamp echoed in `page`).
|
|
27
|
+
//
|
|
28
|
+
// Run: node tests/community_search_route.mjs
|
|
29
|
+
|
|
30
|
+
import { strict as assert } from 'node:assert';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import http from 'node:http';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const express = require('express');
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
async function t(name, fn) {
|
|
40
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
41
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- pure helpers ----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const builderSearch = require('../modules/platform-identity/builder-search.js');
|
|
47
|
+
|
|
48
|
+
await t('clampLimit: default, clamp to MAX_LIMIT, ignore garbage', () => {
|
|
49
|
+
assert.equal(builderSearch.clampLimit(undefined), builderSearch.DEFAULT_LIMIT);
|
|
50
|
+
assert.equal(builderSearch.clampLimit('not a number'), builderSearch.DEFAULT_LIMIT);
|
|
51
|
+
assert.equal(builderSearch.clampLimit(0), builderSearch.DEFAULT_LIMIT);
|
|
52
|
+
assert.equal(builderSearch.clampLimit(-5), builderSearch.DEFAULT_LIMIT);
|
|
53
|
+
assert.equal(builderSearch.clampLimit(5), 5);
|
|
54
|
+
assert.equal(builderSearch.clampLimit(9999), builderSearch.MAX_LIMIT);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await t('clampOffset: default 0, ignore negatives/garbage', () => {
|
|
58
|
+
assert.equal(builderSearch.clampOffset(undefined), 0);
|
|
59
|
+
assert.equal(builderSearch.clampOffset(-1), 0);
|
|
60
|
+
assert.equal(builderSearch.clampOffset('nope'), 0);
|
|
61
|
+
assert.equal(builderSearch.clampOffset(40), 40);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t('escapeLikePrefix: escapes %, _ and backslash so a query cannot widen its own prefix', () => {
|
|
65
|
+
assert.equal(builderSearch.escapeLikePrefix('50%'), '50\\%');
|
|
66
|
+
assert.equal(builderSearch.escapeLikePrefix('a_b'), 'a\\_b');
|
|
67
|
+
assert.equal(builderSearch.escapeLikePrefix('a\\b'), 'a\\\\b');
|
|
68
|
+
assert.equal(builderSearch.escapeLikePrefix('plain'), 'plain');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t('clampOffset: capped at MAX_OFFSET (deep pagination on a public route is a free scan-and-discard knob)', () => {
|
|
72
|
+
assert.equal(builderSearch.clampOffset(builderSearch.MAX_OFFSET), builderSearch.MAX_OFFSET);
|
|
73
|
+
assert.equal(builderSearch.clampOffset(builderSearch.MAX_OFFSET + 1), builderSearch.MAX_OFFSET);
|
|
74
|
+
assert.equal(builderSearch.clampOffset(999999999), builderSearch.MAX_OFFSET);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await t('the fuzzy trigram leg case-folds BOTH sides (a bug class: pg_trgm % is case-sensitive, unlike ILIKE)', () => {
|
|
78
|
+
// Static, source-level pin — the fake pool below cannot reproduce Postgres's
|
|
79
|
+
// real case sensitivity, so this is the guard that actually catches a
|
|
80
|
+
// regression to the raw (unlowered) form the bug report named.
|
|
81
|
+
const fs = require('fs');
|
|
82
|
+
const src = fs.readFileSync(require.resolve('../modules/platform-identity/builder-search.js'), 'utf8');
|
|
83
|
+
assert.match(src, /lower\(pa\.handle\) % lower\(\$1\)/, 'the handle fuzzy leg must compare lower() on both sides');
|
|
84
|
+
assert.match(src, /lower\(pa\.display_name\) % lower\(\$1\)/, 'the display_name fuzzy leg must compare lower() on both sides');
|
|
85
|
+
assert.ok(!src.includes('pa.handle % $1'), 'the raw (unlowered) form must not reappear');
|
|
86
|
+
assert.ok(!src.includes('pa.display_name % $1'), 'the raw (unlowered) form must not reappear');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---- fake engine ------------------------------------------------------------
|
|
90
|
+
//
|
|
91
|
+
// Reads the SAME predicates the module reads — imported from
|
|
92
|
+
// account-visibility.js, never restated — so this test cannot pass by
|
|
93
|
+
// agreeing with a hand-rolled copy of the rule under proof.
|
|
94
|
+
const {
|
|
95
|
+
isAccountVisible, isRollupVisible, accountVisibleSql, rollupVisibleSql,
|
|
96
|
+
} = require('../modules/platform-identity/account-visibility.js');
|
|
97
|
+
|
|
98
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
99
|
+
|
|
100
|
+
// One row per matrix cell D4 names, plus a plain control.
|
|
101
|
+
const ACCOUNTS = [
|
|
102
|
+
// active + public + NOT hidden — the plain control.
|
|
103
|
+
{ github_id: '100', handle: 'plain-vis', github_login: 'plainvis', display_name: 'Plain Visible',
|
|
104
|
+
avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
|
|
105
|
+
disciplines: ['engineer'] },
|
|
106
|
+
// active + public + HIDDEN — findable by name, not by discipline.
|
|
107
|
+
{ github_id: '101', handle: 'hidden-builder', github_login: 'hiddenbuilder', display_name: 'Hidden Builder',
|
|
108
|
+
avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true,
|
|
109
|
+
disciplines: ['artist'] },
|
|
110
|
+
// active + PRIVATE — absent entirely, findable or not.
|
|
111
|
+
{ github_id: '102', handle: 'private-builder', github_login: 'privatebuilder', display_name: 'Private Builder',
|
|
112
|
+
avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false,
|
|
113
|
+
disciplines: ['engineer'] },
|
|
114
|
+
// PROVISIONAL — absent entirely.
|
|
115
|
+
{ github_id: '103', handle: 'provisional-builder', github_login: 'provbuilder', display_name: 'Provisional Builder',
|
|
116
|
+
avatar_url: null, profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false,
|
|
117
|
+
disciplines: ['engineer'] },
|
|
118
|
+
// active + public + NOT hidden, but 'octocat' sits mid-handle — a query for
|
|
119
|
+
// it is NOT a prefix match, so this fixture exercises the FUZZY (`%`) leg
|
|
120
|
+
// specifically, not the LIKE prefix leg.
|
|
121
|
+
{ github_id: '104', handle: 'jax-octocat', github_login: 'jaxoctocat', display_name: 'Jax Octocat',
|
|
122
|
+
avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
|
|
123
|
+
disciplines: ['ui'] },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const engine = {
|
|
127
|
+
calls: [],
|
|
128
|
+
query: async (text, params = []) => {
|
|
129
|
+
const sql = String(text);
|
|
130
|
+
engine.calls.push({ sql, params });
|
|
131
|
+
if (engine.throwOnQuery) throw engine.throwOnQuery;
|
|
132
|
+
assert.match(sql, /WITH matches AS/, 'the query shape moved — update this fake alongside it');
|
|
133
|
+
assert.match(sql, /platform_identity_builder_project_activity/, 'the discipline CTE moved — update this fake alongside it');
|
|
134
|
+
// THE load-bearing assertions (the code-review finding this test now
|
|
135
|
+
// closes): the REAL statement text must literally contain the shared
|
|
136
|
+
// predicates, not merely SOME predicate — deleting accountVisibleSql from
|
|
137
|
+
// the 'matches' CTE, or rollupVisibleSql from the 'disc' CTE, fails HERE,
|
|
138
|
+
// independent of the fixture simulation below and independent of
|
|
139
|
+
// tests/effective_visibility_predicate.mjs's function-body-level check.
|
|
140
|
+
assert.ok(sql.includes(accountVisibleSql('pa')),
|
|
141
|
+
"the 'matches' CTE must join accountVisibleSql('pa') — the NAME-match existence floor");
|
|
142
|
+
assert.ok(sql.includes(rollupVisibleSql('pa2')),
|
|
143
|
+
"the 'disc' CTE must join rollupVisibleSql('pa2') — the DISCIPLINE-match rollup floor");
|
|
144
|
+
|
|
145
|
+
const [rawQ, likePattern, discipline, limit, offset] = params;
|
|
146
|
+
const q = String(rawQ || '').toLowerCase();
|
|
147
|
+
const like = String(likePattern || '').replace(/\\(.)/g, '$1').replace(/%$/, '').toLowerCase();
|
|
148
|
+
|
|
149
|
+
// The NAME predicate: real accountVisibleSql via its JS twin, applied to
|
|
150
|
+
// the fixture — never a hand-rolled restatement.
|
|
151
|
+
let rows = ACCOUNTS.filter(isAccountVisible).filter((a) => {
|
|
152
|
+
const handle = a.handle.toLowerCase();
|
|
153
|
+
const name = (a.display_name || '').toLowerCase();
|
|
154
|
+
return handle.startsWith(like) || name.startsWith(like) || handle.includes(q) || name.includes(q);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The DISCIPLINE predicate: rollup-derived, so it floors on isRollupVisible
|
|
158
|
+
// (existence AND NOT hidden) exactly as the disc CTE's EXISTS clause does.
|
|
159
|
+
const disciplinesFor = (a) => (isRollupVisible(a) ? a.disciplines : null);
|
|
160
|
+
|
|
161
|
+
if (discipline) {
|
|
162
|
+
rows = rows.filter((a) => {
|
|
163
|
+
const d = disciplinesFor(a);
|
|
164
|
+
return Array.isArray(d) && d.includes(discipline);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const shaped = rows
|
|
169
|
+
.sort((a, b) => (a.handle.toLowerCase() === q ? -1 : b.handle.toLowerCase() === q ? 1 : 0))
|
|
170
|
+
.slice(offset, offset + limit)
|
|
171
|
+
.map((a) => ({
|
|
172
|
+
handle: a.handle,
|
|
173
|
+
github_login: a.github_login,
|
|
174
|
+
display_name: a.display_name,
|
|
175
|
+
avatar_url: a.avatar_url,
|
|
176
|
+
disciplines: disciplinesFor(a),
|
|
177
|
+
}));
|
|
178
|
+
return { rows: shaped };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Pin the module doorway (ADR 0083) BEFORE the route loads, so its pool is the
|
|
183
|
+
// fake engine above and its auth-adjacent exports satisfy the router factory.
|
|
184
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
185
|
+
require.cache[moduleApiPath] = {
|
|
186
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
187
|
+
exports: {
|
|
188
|
+
pool: engine,
|
|
189
|
+
corsPublicGet: () => (_req, _res, next) => next(),
|
|
190
|
+
logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
195
|
+
const buildCommunitySearchRoutes = require('../modules/platform-identity/routes/community-search.js');
|
|
196
|
+
|
|
197
|
+
function buildApp() {
|
|
198
|
+
const app = express();
|
|
199
|
+
app.use(attachFail);
|
|
200
|
+
app.use('/api/bongos', buildCommunitySearchRoutes());
|
|
201
|
+
return app;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function listen(app) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const server = app.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function get(port, path) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
|
|
213
|
+
const chunks = [];
|
|
214
|
+
res.on('data', (c) => chunks.push(c));
|
|
215
|
+
res.on('end', () => {
|
|
216
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
217
|
+
let json = null; try { json = JSON.parse(text); } catch (_) {}
|
|
218
|
+
resolve({ status: res.statusCode, json, text, headers: res.headers });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
req.on('error', reject);
|
|
222
|
+
req.end();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { server, port } = await listen(buildApp());
|
|
227
|
+
|
|
228
|
+
// ---- D4 matrix, end to end --------------------------------------------------
|
|
229
|
+
|
|
230
|
+
await t('name-only search finds the plain visible account', async () => {
|
|
231
|
+
const r = await get(port, '/api/bongos/community/search?q=plain-vis');
|
|
232
|
+
assert.equal(r.status, 200);
|
|
233
|
+
const logins = r.json.results.map((x) => x.github_login);
|
|
234
|
+
assert.ok(logins.includes('plainvis'), 'the plain control must be found');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await t('a hide_stats account IS findable by NAME (D4: hiding removes the rollup, not the page)', async () => {
|
|
238
|
+
const r = await get(port, '/api/bongos/community/search?q=hidden-builder');
|
|
239
|
+
assert.equal(r.status, 200);
|
|
240
|
+
const hit = r.json.results.find((x) => x.github_login === 'hiddenbuilder');
|
|
241
|
+
assert.ok(hit, 'the hidden account must still be found by name');
|
|
242
|
+
assert.equal(hit.disciplines, null, 'but its disciplines never attach — the rollup stays suppressed');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await t('the SAME hide_stats account drops out of a DISCIPLINE-filtered search', async () => {
|
|
246
|
+
const r = await get(port, '/api/bongos/community/search?q=hidden-builder&discipline=artist');
|
|
247
|
+
assert.equal(r.status, 200);
|
|
248
|
+
const hit = r.json.results.find((x) => x.github_login === 'hiddenbuilder');
|
|
249
|
+
assert.ok(!hit, 'a discipline filter must exclude the hidden account even though it genuinely holds that discipline');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await t('a private account never appears — by name or by its real discipline', async () => {
|
|
253
|
+
const byName = await get(port, '/api/bongos/community/search?q=private-builder');
|
|
254
|
+
assert.ok(!byName.json.results.some((x) => x.github_login === 'privatebuilder'));
|
|
255
|
+
const byDiscipline = await get(port, '/api/bongos/community/search?q=private&discipline=engineer');
|
|
256
|
+
assert.ok(!byDiscipline.json.results.some((x) => x.github_login === 'privatebuilder'));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await t('a provisional account never appears', async () => {
|
|
260
|
+
const r = await get(port, '/api/bongos/community/search?q=provisional-builder');
|
|
261
|
+
assert.ok(!r.json.results.some((x) => x.github_login === 'provbuilder'));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await t('a case-differing, NON-PREFIX query hits the fuzzy leg (not just the LIKE prefix leg)', async () => {
|
|
265
|
+
// 'OCTOCAT' is upper-case and is NOT a prefix of 'jax-octocat' — only the
|
|
266
|
+
// trigram `%` leg (never the prefix LIKE leg) can find it.
|
|
267
|
+
const r = await get(port, '/api/bongos/community/search?q=OCTOCAT');
|
|
268
|
+
assert.equal(r.status, 200);
|
|
269
|
+
const hit = r.json.results.find((x) => x.github_login === 'jaxoctocat');
|
|
270
|
+
assert.ok(hit, 'a case-differing, non-prefix query must still find it via the fuzzy leg');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await t('the plain visible account IS found under its real discipline, and ABSENT under another', async () => {
|
|
274
|
+
const hit = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=engineer');
|
|
275
|
+
assert.ok(hit.json.results.some((x) => x.github_login === 'plainvis'), 'engineer is its real discipline');
|
|
276
|
+
const miss = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=artist');
|
|
277
|
+
assert.ok(!miss.json.results.some((x) => x.github_login === 'plainvis'), 'artist is not its discipline');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await t('github_id never crosses the wire', async () => {
|
|
281
|
+
const r = await get(port, '/api/bongos/community/search?q=plain-vis');
|
|
282
|
+
assert.ok(!r.text.includes('github_id'), 'the internal join key must not appear in the response');
|
|
283
|
+
assert.ok(!r.text.includes('"100"') && !r.text.includes('"101"'));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---- validation --------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
await t('missing q → 400 bad_request', async () => {
|
|
289
|
+
const r = await get(port, '/api/bongos/community/search');
|
|
290
|
+
assert.equal(r.status, 400);
|
|
291
|
+
assert.equal(r.json.error.code, 'bad_request');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await t('blank q → 400 bad_request', async () => {
|
|
295
|
+
const r = await get(port, '/api/bongos/community/search?q=%20%20');
|
|
296
|
+
assert.equal(r.status, 400);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await t('too-short q → 400 query_too_short', async () => {
|
|
300
|
+
const r = await get(port, '/api/bongos/community/search?q=a');
|
|
301
|
+
assert.equal(r.status, 400);
|
|
302
|
+
assert.equal(r.json.error.code, 'query_too_short');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await t('oversized q → 400 query_too_long', async () => {
|
|
306
|
+
const long = 'a'.repeat(builderSearch.MAX_QUERY_CHARS + 1);
|
|
307
|
+
const r = await get(port, `/api/bongos/community/search?q=${long}`);
|
|
308
|
+
assert.equal(r.status, 400);
|
|
309
|
+
assert.equal(r.json.error.code, 'query_too_long');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await t('oversized discipline → 400 discipline_too_long', async () => {
|
|
313
|
+
const long = 'a'.repeat(builderSearch.MAX_DISCIPLINE_CHARS + 1);
|
|
314
|
+
const r = await get(port, `/api/bongos/community/search?q=plain&discipline=${long}`);
|
|
315
|
+
assert.equal(r.status, 400);
|
|
316
|
+
assert.equal(r.json.error.code, 'discipline_too_long');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await t('response is Cache-Control: no-store (hide must beat the cache, ADR 0171 D5)', async () => {
|
|
320
|
+
const r = await get(port, '/api/bongos/community/search?q=plain-vis');
|
|
321
|
+
assert.equal(r.headers['cache-control'], 'no-store');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await t('page.offset echoes the MAX_OFFSET clamp through the real route', async () => {
|
|
325
|
+
const r = await get(port, `/api/bongos/community/search?q=plain-vis&offset=${builderSearch.MAX_OFFSET + 5000}`);
|
|
326
|
+
assert.equal(r.status, 200);
|
|
327
|
+
assert.equal(r.json.page.offset, builderSearch.MAX_OFFSET, 'the route must echo the CLAMPED offset, not the raw query param');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await t('a query engine failure → 500 search_failed (never a leaked stack/500 HTML page)', async () => {
|
|
331
|
+
engine.throwOnQuery = new Error('DO-NOT-LEAK-connection string or stack detail');
|
|
332
|
+
try {
|
|
333
|
+
const r = await get(port, '/api/bongos/community/search?q=plain-vis');
|
|
334
|
+
assert.equal(r.status, 500);
|
|
335
|
+
assert.equal(r.json.error.code, 'search_failed');
|
|
336
|
+
assert.ok(!r.text.includes('DO-NOT-LEAK'), 'the raw error message must not reach the response body');
|
|
337
|
+
} finally {
|
|
338
|
+
engine.throwOnQuery = null;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ---- rate limit -------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
await t('searchReadRateLimit: allows up to the cap, then 429s with Retry-After', () => {
|
|
345
|
+
const { searchReadRateLimit, READ_RATE_LIMIT } = buildCommunitySearchRoutes._internals;
|
|
346
|
+
const mkRes = () => {
|
|
347
|
+
const r = { _status: 200, _json: null, _headers: {} };
|
|
348
|
+
r.set = (k, v) => { r._headers[k] = v; return r; };
|
|
349
|
+
r.status = (s) => { r._status = s; return r; };
|
|
350
|
+
r.json = (j) => { r._json = j; return r; };
|
|
351
|
+
attachFail({}, r, () => {}); // installs res.fail() as the router does
|
|
352
|
+
return r;
|
|
353
|
+
};
|
|
354
|
+
const req = { ip: '203.0.113.77' }; // a unique test IP so the module Map bucket is clean
|
|
355
|
+
let allowed = 0;
|
|
356
|
+
for (let i = 0; i < READ_RATE_LIMIT; i++) {
|
|
357
|
+
const res = mkRes();
|
|
358
|
+
let nexted = false;
|
|
359
|
+
searchReadRateLimit(req, res, () => { nexted = true; });
|
|
360
|
+
if (nexted) allowed++;
|
|
361
|
+
}
|
|
362
|
+
assert.equal(allowed, READ_RATE_LIMIT, 'every call up to the cap must pass through');
|
|
363
|
+
const res = mkRes();
|
|
364
|
+
let nexted = false;
|
|
365
|
+
searchReadRateLimit(req, res, () => { nexted = true; });
|
|
366
|
+
assert.equal(nexted, false, 'the over-cap call must NOT call next()');
|
|
367
|
+
assert.equal(res._status, 429);
|
|
368
|
+
assert.equal(res._json.error.code, 'rate_limited');
|
|
369
|
+
assert.ok(res._headers['Retry-After'], 'a Retry-After header is set');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await t('searchReadRateLimit keys on the request IP only — one caller cannot throttle another', () => {
|
|
373
|
+
const { searchReadRateLimit, READ_RATE_LIMIT } = buildCommunitySearchRoutes._internals;
|
|
374
|
+
const mkRes = () => ({ set() { return this; }, status() { return this; }, json() { return this; } });
|
|
375
|
+
const reqA = { ip: '203.0.113.78' };
|
|
376
|
+
for (let i = 0; i < READ_RATE_LIMIT; i++) searchReadRateLimit(reqA, mkRes(), () => {});
|
|
377
|
+
let nexted = false;
|
|
378
|
+
searchReadRateLimit({ ip: '203.0.113.79' }, mkRes(), () => { nexted = true; });
|
|
379
|
+
assert.equal(nexted, true, 'a different IP is not affected by another IP hitting the cap');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
server.close();
|
|
383
|
+
|
|
384
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
385
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
isRecruiterDiscoverable,
|
|
80
80
|
recruiterReachSql,
|
|
81
81
|
scoutingListableSql,
|
|
82
|
+
scoutingNameOnlySql,
|
|
82
83
|
recruiterSliverVisibleSql,
|
|
83
84
|
isRecruiterSliverVisible,
|
|
84
85
|
} from '../modules/platform-identity/account-visibility.js';
|
|
@@ -353,8 +354,11 @@ function accountStatements(code) {
|
|
|
353
354
|
* That is a decision recorded in the owner file and asserted above — not a
|
|
354
355
|
* reader quietly dropping a term, which is what this regex would otherwise let
|
|
355
356
|
* through unnoticed.
|
|
357
|
+
* scoutingNameOnlySql (task 1002291) is the directory's second tier: it floors on
|
|
358
|
+
* existence like every public answer, but it admits HIDDEN accounts, so it is
|
|
359
|
+
* pinned below to one reader that selects identity columns and nothing else.
|
|
356
360
|
*/
|
|
357
|
-
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
|
|
361
|
+
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|scoutingNameOnlySql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
|
|
358
362
|
|
|
359
363
|
// ===========================================================================
|
|
360
364
|
// Self-tests — the machinery is the guard, so it gets proved too.
|
|
@@ -458,12 +462,36 @@ t('scoutingListableSql is COMPOSED from the rollup AND the reach halves (task 10
|
|
|
458
462
|
+ 'leaderboards join rollupVisibleSql and the opt-out must not empty them');
|
|
459
463
|
});
|
|
460
464
|
|
|
465
|
+
t('scoutingNameOnlySql is the directory\'s SECOND tier: existence AND hidden AND reach (task 1002291)', () => {
|
|
466
|
+
// Privacy spec D2, the "active · public · hide_stats" row's recruiter column:
|
|
467
|
+
// "listed name-only (if not opted out)". Hide decides the SHAPE of a directory
|
|
468
|
+
// row, the reach axis decides whether there is a row at all.
|
|
469
|
+
for (const alias of ['pa', undefined]) {
|
|
470
|
+
const tier = scoutingNameOnlySql(alias);
|
|
471
|
+
assert.ok(tier.includes(accountVisibleSql(alias)),
|
|
472
|
+
'it floors on EXISTENCE like every public read — a private account is never listed, not even by name');
|
|
473
|
+
assert.ok(tier.includes(recruiterReachSql(alias)),
|
|
474
|
+
'and on reach: opting out removes a hidden builder from the directory too');
|
|
475
|
+
assert.match(tier, new RegExp(`AND ${alias ? `${alias}\\.` : ''}hide_stats\\)`),
|
|
476
|
+
'and admits exactly the HIDDEN accounts — the ones the ranked tier leaves out');
|
|
477
|
+
assert.doesNotMatch(tier, /NOT (?:\w+\.)?hide_stats/, 'never the unhidden ones, or a builder would be listed twice');
|
|
478
|
+
}
|
|
479
|
+
// The two tiers PARTITION the reachable, visible accounts: they differ in the
|
|
480
|
+
// hide term and in nothing else, so no builder can fall between them or sit
|
|
481
|
+
// in both.
|
|
482
|
+
const ranked = scoutingListableSql('pa').replace('AND NOT pa.hide_stats)', 'AND <HIDE>)');
|
|
483
|
+
const named = scoutingNameOnlySql('pa').replace('AND pa.hide_stats)', 'AND <HIDE>)');
|
|
484
|
+
assert.ok(ranked.includes('<HIDE>') && named.includes('<HIDE>'), 'both tiers carry the hide term where it is expected');
|
|
485
|
+
assert.equal(named, ranked, 'the name-only tier is the ranked predicate with the hide term flipped — nothing more');
|
|
486
|
+
});
|
|
487
|
+
|
|
461
488
|
t('the alias is the one interpolated thing, and every builder refuses a non-identifier', () => {
|
|
462
489
|
const inject = "pa; DROP TABLE platform_identity_accounts --";
|
|
463
490
|
assert.throws(() => accountVisibleSql(inject), /bare SQL identifier/);
|
|
464
491
|
assert.throws(() => rollupVisibleSql(inject), /bare SQL identifier/);
|
|
465
492
|
assert.throws(() => recruiterReachSql(inject), /bare SQL identifier/);
|
|
466
493
|
assert.throws(() => scoutingListableSql(inject), /bare SQL identifier/);
|
|
494
|
+
assert.throws(() => scoutingNameOnlySql(inject), /bare SQL identifier/);
|
|
467
495
|
});
|
|
468
496
|
|
|
469
497
|
t('the JS ports answer exactly what the SQL asks, row for row', () => {
|
|
@@ -697,6 +725,26 @@ t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (ta
|
|
|
697
725
|
'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
|
|
698
726
|
});
|
|
699
727
|
|
|
728
|
+
t('the NAME-ONLY tier has exactly one reader, and it selects identity and nothing else (task 1002291)', () => {
|
|
729
|
+
// scoutingNameOnlySql admits HIDDEN accounts by design. Joined by a read that
|
|
730
|
+
// returned a number, it would hand out exactly the record hide_stats withholds,
|
|
731
|
+
// while looking adopted to the classification rule above. So the predicate is
|
|
732
|
+
// pinned to its one reader, and that reader's statement to three identity
|
|
733
|
+
// columns and no rollup table.
|
|
734
|
+
const readers = scanned
|
|
735
|
+
.filter((f) => f.rel !== PREDICATE_OWNER && /\bscoutingNameOnlySql\s*\(/.test(f.code))
|
|
736
|
+
.map((f) => f.rel);
|
|
737
|
+
assert.deepEqual(readers, ['modules/platform-identity/scouting-name-only.js'],
|
|
738
|
+
'the name-only tier is the recruiting directory\'s alone');
|
|
739
|
+
const reader = scanned.find((f) => f.rel === readers[0]);
|
|
740
|
+
const statements = accountStatements(reader.code);
|
|
741
|
+
assert.equal(statements.length, 1, 'one statement');
|
|
742
|
+
assert.match(statements[0].sql, /^SELECT github_login, display_name, avatar_url FROM platform_identity_accounts WHERE /,
|
|
743
|
+
'selecting the three identity columns a hidden builder\'s public page already shows — never an id, a count or a date');
|
|
744
|
+
assert.doesNotMatch(reader.code, /platform_identity_builder_project_activity/,
|
|
745
|
+
'and never touching the rollup table: presence in this tier must not depend on activity, or it would say who has built');
|
|
746
|
+
});
|
|
747
|
+
|
|
700
748
|
t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
|
|
701
749
|
const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
|
|
702
750
|
const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
|