@bongos/core 1.19.1016 → 1.19.1018
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 +98 -48
- 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/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/README.md +2 -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 +9 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -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/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/hub-keys.js +51 -32
- 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/my-projects.js +33 -1
- 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/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- 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/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
- 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');
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import { strict as assert } from 'node:assert';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
|
+
// ESM import so the knip ratchet credits these test-facing exports (a
|
|
17
|
+
// createRequire() import does not credit a CJS export).
|
|
18
|
+
import { applicationVouchIdentity, verifyHubApplicationVouch } from '../src/bongos/auth-idp.js';
|
|
16
19
|
|
|
17
20
|
const require = createRequire(import.meta.url);
|
|
18
21
|
const auth = require('../src/bongos/auth.js');
|
|
@@ -194,5 +197,176 @@ t('a logout jti is fresh on first use, seen on replay, and evicted after expiry'
|
|
|
194
197
|
assert.equal(auth.logoutTokenJtiSeenAndRecord(undefined, exp, now), false, 'missing jti is never "seen"');
|
|
195
198
|
});
|
|
196
199
|
|
|
200
|
+
// --- Application vouch (ADR 0201 D2, ADR 0208 D2, task 1002285) -------------
|
|
201
|
+
// The hub's join relay carries a THIRD token kind to the target project: an
|
|
202
|
+
// application vouch — identity AND purpose 'application'. ADR 0208 D2 found the
|
|
203
|
+
// hole this closes: verifyHubAssertion never looked at `purpose`, so any
|
|
204
|
+
// identity payload verified AS A LOGIN, and a captured vouch would have been
|
|
205
|
+
// replayable into POST /auth/device/exchange as a device sign-in.
|
|
206
|
+
|
|
207
|
+
function mintVouch(overrides = {}, opts = {}) {
|
|
208
|
+
return hubKeys.mintHubApplicationVouch(
|
|
209
|
+
{ clientId: 'client-x', githubId: 42, githubLogin: 'octo', handle: 'octo', ...overrides },
|
|
210
|
+
{ signingKey, ...opts },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Sign an ARBITRARY payload with the real hub key — the only way to ask "what
|
|
215
|
+
// does a verifier do with a shape no minter emits today?", which is exactly the
|
|
216
|
+
// window ADR 0208 D2 says to close.
|
|
217
|
+
function signRaw(payload) {
|
|
218
|
+
const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
219
|
+
const input = `${b64u({ alg: 'EdDSA', typ: 'JWT' })}.${b64u(payload)}`;
|
|
220
|
+
return `${input}.${crypto.sign(null, Buffer.from(input), signingKey).toString('base64url')}`;
|
|
221
|
+
}
|
|
222
|
+
const nowS = () => Math.floor(Date.now() / 1000);
|
|
223
|
+
|
|
224
|
+
console.log('\nhub application vouch mint → instance verify (roundtrip):');
|
|
225
|
+
|
|
226
|
+
t('a valid vouch verifies, carrying identity + purpose + handle and NO rank', () => {
|
|
227
|
+
const p = verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'client-x' });
|
|
228
|
+
assert.equal(p.sub, '42');
|
|
229
|
+
assert.equal(p.applicant_login, 'octo');
|
|
230
|
+
assert.equal(p.github_login, undefined,
|
|
231
|
+
'the login rides as applicant_login: every login verifier any core ever shipped demands github_login, '
|
|
232
|
+
+ 'so a core that predates the purpose refusal still cannot take a vouch as a device sign-in');
|
|
233
|
+
assert.equal(p.handle, 'octo');
|
|
234
|
+
assert.equal(p.purpose, 'application');
|
|
235
|
+
assert.equal(p.aud, 'client-x');
|
|
236
|
+
assert.ok(p.jti, 'a vouch always carries a jti — single use is chosen, not optional');
|
|
237
|
+
assert.equal(p.rank, undefined, 'a vouch is identity, never authority (ADR 0016)');
|
|
238
|
+
assert.equal(p.display_name, undefined, 'and not the login assertion’s profile fields either');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
console.log('\nadversarial (vouch) — every one of these MUST be rejected:');
|
|
242
|
+
|
|
243
|
+
t('wrong audience (a vouch for project A cannot vouch at project B)', () => {
|
|
244
|
+
assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: 'other-client' }), /audience/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
t('NO audience configured never verifies — the envelope would otherwise skip the aud check', () => {
|
|
248
|
+
assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey, clientId: null }), /audience/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
t('expired vouch (minted 10 min ago; TTL is ~120s)', () => {
|
|
252
|
+
const stale = mintVouch({}, { nowMs: Date.now() - 10 * 60 * 1000 });
|
|
253
|
+
assert.throws(() => verifyHubApplicationVouch(stale, { publicKey, clientId: 'client-x' }), /expired/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
t('tampered payload (swap sub to another github_id) fails the signature', () => {
|
|
257
|
+
const [h, p, s] = mintVouch().split('.');
|
|
258
|
+
const payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
|
|
259
|
+
payload.sub = '999';
|
|
260
|
+
const forged = `${h}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.${s}`;
|
|
261
|
+
assert.throws(() => verifyHubApplicationVouch(forged, { publicKey, clientId: 'client-x' }), /signature/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
t('a different signing key, alg "none", malformed and a missing public key are all rejected', () => {
|
|
265
|
+
const otherPub = crypto.generateKeyPairSync('ed25519').publicKey;
|
|
266
|
+
assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: otherPub, clientId: 'client-x' }), /signature/);
|
|
267
|
+
const [h, p, s] = mintVouch().split('.');
|
|
268
|
+
const hdr = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
|
|
269
|
+
hdr.alg = 'none';
|
|
270
|
+
const confused = `${Buffer.from(JSON.stringify(hdr)).toString('base64url')}.${p}.${s}`;
|
|
271
|
+
assert.throws(() => verifyHubApplicationVouch(confused, { publicKey, clientId: 'client-x' }), /alg|signature/);
|
|
272
|
+
assert.throws(() => verifyHubApplicationVouch('not-a-jwt', { publicKey, clientId: 'client-x' }), /malformed/);
|
|
273
|
+
assert.throws(() => verifyHubApplicationVouch(mintVouch(), { publicKey: null, clientId: 'client-x' }), /public key/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
t('a vouch with no jti is rejected — one vouch, one application needs something to count', () => {
|
|
277
|
+
const noJti = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60 });
|
|
278
|
+
assert.throws(() => verifyHubApplicationVouch(noJti, { publicKey, clientId: 'client-x' }), /jti/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
t('a vouch with no applicant identity is rejected by the verifier itself, not only by the login match', () => {
|
|
282
|
+
for (const drop of ['sub', 'applicant_login']) {
|
|
283
|
+
const claims = { iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: `no-${drop}` };
|
|
284
|
+
delete claims[drop];
|
|
285
|
+
assert.throws(() => verifyHubApplicationVouch(signRaw(claims), { publicKey, clientId: 'client-x' }), /identity/, `missing ${drop}`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
t('mintHubApplicationVouch refuses without a signing key', () => {
|
|
290
|
+
assert.throws(() => hubKeys.mintHubApplicationVouch({ clientId: 'c', githubId: 1, githubLogin: 'x' }, {}), /signing key/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
console.log('\nlogin / logout / vouch are mutually NON-interchangeable:');
|
|
294
|
+
|
|
295
|
+
t('an APPLICATION VOUCH is NOT accepted as a login assertion — the replay ADR 0208 D2 found open', () => {
|
|
296
|
+
assert.throws(() => auth.verifyHubAssertion(mintVouch(), { publicKey, clientId: 'client-x' }), /identity|purpose/);
|
|
297
|
+
// And the purpose refusal holds on its own: a vouch-shaped token that ALSO carried
|
|
298
|
+
// github_login (the shape a bolted-on purpose would have produced) still fails.
|
|
299
|
+
const both = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', applicant_login: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: 'both' });
|
|
300
|
+
assert.throws(() => auth.verifyHubAssertion(both, { publicKey, clientId: 'client-x' }), /purpose/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
t('verifyHubAssertion refuses ANY purpose on an identity payload, not only this one', () => {
|
|
304
|
+
for (const purpose of ['application', 'anything', '', null]) {
|
|
305
|
+
const token = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: '42', github_login: 'octo', purpose, iat: nowS(), exp: nowS() + 60, jti: `p-${purpose}` });
|
|
306
|
+
assert.throws(() => auth.verifyHubAssertion(token, { publicKey, clientId: 'client-x' }), /purpose/,
|
|
307
|
+
`purpose=${JSON.stringify(purpose)} must not verify as a login`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
t('a LOGIN assertion is NOT accepted as an application vouch (no purpose)', () => {
|
|
312
|
+
assert.throws(() => verifyHubApplicationVouch(mint(), { publicKey, clientId: 'client-x' }), /application vouch/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
t('a LOGOUT token is NOT accepted as an application vouch, nor a vouch as a logout', () => {
|
|
316
|
+
assert.throws(() => verifyHubApplicationVouch(mintLogout(), { publicKey, clientId: 'client-x' }), /application vouch/);
|
|
317
|
+
assert.throws(() => auth.verifyHubLogoutToken(mintVouch(), { publicKey, clientId: 'client-x' }), /logout/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
console.log('\napplicationVouchIdentity — what a project may take from a vouch:');
|
|
321
|
+
|
|
322
|
+
const vouchOpts = (githubLogin, extra = {}) => ({ githubLogin, publicKey, clientId: 'client-x', ...extra });
|
|
323
|
+
|
|
324
|
+
t('a vouch for the login on the request yields its github id and handle (login case-insensitive)', () => {
|
|
325
|
+
assert.deepEqual(applicationVouchIdentity(mintVouch(), vouchOpts('Octo')), { githubId: '42', handle: 'octo' });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
t('a vouch naming a DIFFERENT account than the request is no vouch at all', () => {
|
|
329
|
+
assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('someone-else')), null,
|
|
330
|
+
'otherwise one account’s vouch could be stapled to a request naming another');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
t('one vouch vouches ONE application — the same token twice is vouched once', () => {
|
|
334
|
+
const token = mintVouch();
|
|
335
|
+
assert.ok(applicationVouchIdentity(token, vouchOpts('octo')));
|
|
336
|
+
assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null, 'the replay is unvouched');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
t('an instance with no hub public key or no client id vouches nothing', () => {
|
|
340
|
+
assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { publicKey: null })), null);
|
|
341
|
+
assert.equal(applicationVouchIdentity(mintVouch(), vouchOpts('octo', { clientId: null })), null);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
t('absent, garbage, forged and login-shaped tokens are null — never a throw', () => {
|
|
345
|
+
for (const token of [undefined, null, '', 'garbage', mint(), mintLogout()]) {
|
|
346
|
+
assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
t('LOCKSTEP: every handle the hub can hold survives the instance’s check, and nothing else does', () => {
|
|
351
|
+
// normalizeHandle is the hub's own rule (platform-identity.js); the instance keeps a
|
|
352
|
+
// boundary-forced mirror of it. Drive both over the same inputs so a change to
|
|
353
|
+
// either side that the other does not follow reds here, instead of handles
|
|
354
|
+
// silently arriving as null.
|
|
355
|
+
const { normalizeHandle } = require('../modules/platform-identity/platform-identity.js');
|
|
356
|
+
const inputs = ['octo', 'a', '9', 'a-b', 'builder-123456', 'x'.repeat(39), 'x'.repeat(40), '-a', 'a-', 'a_b', 'A', 'o.o', ''];
|
|
357
|
+
for (const raw of inputs) {
|
|
358
|
+
const hubHolds = normalizeHandle(raw) === raw;
|
|
359
|
+
const got = applicationVouchIdentity(mintVouch({ handle: raw }), vouchOpts('octo'));
|
|
360
|
+
assert.equal(got.handle, hubHolds ? raw : null, `handle ${JSON.stringify(raw)}: hub holds=${hubHolds}`);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
t('a handle outside the canonical charset rides as null — the identity still stands', () => {
|
|
365
|
+
for (const handle of ['<b>octo</b>', 'Octo Cat', 'x'.repeat(60), 42, null]) {
|
|
366
|
+
assert.deepEqual(applicationVouchIdentity(mintVouch({ handle }), vouchOpts('octo')), { githubId: '42', handle: null },
|
|
367
|
+
`handle=${JSON.stringify(handle)}`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
197
371
|
console.log(`\nidp_device_exchange: ${passed} passed, ${failed} failed`);
|
|
198
372
|
if (failed > 0) process.exit(1);
|