@bongos/core 1.24.64 → 1.24.66
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 +41 -31
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/ui-look-before-you-ship.md +1 -0
- package/modules/government/protected-surfaces.json +16 -0
- package/modules/grading/grader-no-artifact.js +28 -1
- package/modules/ui-design/kit/README.md +1 -1
- package/modules/ui-design/kit/lib.js +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/scripts/bongos/cli-lib.js +69 -6
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/bongos/ship-grade-steps.js +3 -1
- package/src/bongos/prelaunch-gate.js +34 -18
- package/src/module-api.js +1 -1
- package/tests/cli_sessions.mjs +82 -0
- package/tests/community_proof_db.mjs +334 -0
- package/tests/effective_visibility_predicate.mjs +135 -6
- package/tests/fixtures/privacy-matrix.mjs +69 -0
- package/tests/government_protected_surfaces.mjs +74 -0
- package/tests/grade_unavailable_display.mjs +47 -0
- package/tests/prelaunch_gate.mjs +22 -0
- package/tests/privacy_matrix.mjs +7 -57
- package/tests/recall_hints.mjs +45 -0
- package/tests/session_start_freshness.mjs +97 -0
- package/tests/ui_design_kit.mjs +4 -1
- package/tests/ui_design_kit_fit.mjs +30 -0
|
@@ -294,7 +294,9 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
|
|
|
294
294
|
// spawned here) so formatGradeOutcome stays unit-testable without a subprocess.
|
|
295
295
|
function formatOutageDiagnostic(g, cliFinding) {
|
|
296
296
|
const out = [];
|
|
297
|
-
|
|
297
|
+
// task 1005101: a single-shot outage (trivial diff / no-artifact) has no
|
|
298
|
+
// panel workers; its dead shots ride signals.outage_shots in the same shape.
|
|
299
|
+
const dead = (g.signals?.workers || g.signals?.outage_shots || []).filter((w) => w && w.error);
|
|
298
300
|
const detailed = dead.filter((w) => w.exit_code != null || w.stderr_tail);
|
|
299
301
|
if (detailed.length) {
|
|
300
302
|
out.push('');
|
|
@@ -248,25 +248,13 @@ function prelaunchGate() {
|
|
|
248
248
|
if (body.length > 4096) req.destroy(); // a password is not a payload
|
|
249
249
|
});
|
|
250
250
|
req.on('end', () => {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const returnTo = relativeReturn(params.get('return'));
|
|
258
|
-
if (!ok) {
|
|
259
|
-
return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: true, returnTo }));
|
|
251
|
+
// A plain event-emitter callback: a throw here is uncaught and kills the
|
|
252
|
+
// process, so any failure answers 400 instead (task 1004380).
|
|
253
|
+
try {
|
|
254
|
+
answerSubmit(req, res, body);
|
|
255
|
+
} catch {
|
|
256
|
+
if (!res.headersSent) res.status(400).type('text').send('Bad request');
|
|
260
257
|
}
|
|
261
|
-
res.cookie(COOKIE, expectedToken(), {
|
|
262
|
-
httpOnly: true,
|
|
263
|
-
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
264
|
-
sameSite: 'lax',
|
|
265
|
-
path: '/',
|
|
266
|
-
...domainOpts(req),
|
|
267
|
-
// no maxAge/expires — a SESSION cookie, by the owner's choice
|
|
268
|
-
});
|
|
269
|
-
res.redirect(303, returnTo || '/');
|
|
270
258
|
});
|
|
271
259
|
return undefined;
|
|
272
260
|
}
|
|
@@ -283,6 +271,34 @@ function prelaunchGate() {
|
|
|
283
271
|
};
|
|
284
272
|
}
|
|
285
273
|
|
|
274
|
+
// Compare fixed-length digests: a character-length check is not a byte-length
|
|
275
|
+
// check (a non-ASCII guess passed it, then timingSafeEqual threw on the byte
|
|
276
|
+
// mismatch — task 1004380), and digesting both sides also hides the length.
|
|
277
|
+
function passwordMatches(given) {
|
|
278
|
+
const digest = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest();
|
|
279
|
+
return crypto.timingSafeEqual(digest(given), digest(password()));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function answerSubmit(req, res, body) {
|
|
283
|
+
const params = new URLSearchParams(body);
|
|
284
|
+
const given = params.get('password') || '';
|
|
285
|
+
const ok = passwordMatches(given);
|
|
286
|
+
// re-validated on the way back: the field is visitor-supplied
|
|
287
|
+
const returnTo = relativeReturn(params.get('return'));
|
|
288
|
+
if (!ok) {
|
|
289
|
+
return res.status(401).type('html').send(formHtml({ productName: productName(), wrong: true, returnTo }));
|
|
290
|
+
}
|
|
291
|
+
res.cookie(COOKIE, expectedToken(), {
|
|
292
|
+
httpOnly: true,
|
|
293
|
+
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
294
|
+
sameSite: 'lax',
|
|
295
|
+
path: '/',
|
|
296
|
+
...domainOpts(req),
|
|
297
|
+
// no maxAge/expires — a SESSION cookie, by the owner's choice
|
|
298
|
+
});
|
|
299
|
+
return res.redirect(303, returnTo || '/');
|
|
300
|
+
}
|
|
301
|
+
|
|
286
302
|
module.exports = {
|
|
287
303
|
prelaunchGate, enabled, isExempt, tokenMatches, expectedToken, warnIfLegacyEnv,
|
|
288
304
|
COOKIE, HMAC_LABEL, SUBMIT_PATH, LEGACY_SUBMIT_PATH, SUBMIT_PATHS,
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.66'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/cli_sessions.mjs
CHANGED
|
@@ -272,3 +272,85 @@ test('login names the other instances and how to move between them', () => {
|
|
|
272
272
|
const fnStart = src.indexOf('function logOtherInstances');
|
|
273
273
|
assert.ok(!/\/builder-/.test(src.slice(fnStart, fnStart + 700)), 'no slash command in terminal guidance');
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
// ── 4. Every command reads the session for the instance it TARGETS (task 1005117) ───────────
|
|
277
|
+
//
|
|
278
|
+
// THE BUG. The store above kept every instance's session, but every command still read the ONE
|
|
279
|
+
// active pointer. So signing in to a second instance (toast.cloudbongos.com) mid-run rewrote that
|
|
280
|
+
// pointer, and a run steered at the first instance by <PREFIX>_API_BASE then sent the SECOND
|
|
281
|
+
// instance's token to the first — every claim and ship 401'd until a re-paste. The fix keys the
|
|
282
|
+
// read by target: when a command's target host differs from the active pointer's, the store's
|
|
283
|
+
// session for the target wins. With no known target, or nothing stored for it, the single active
|
|
284
|
+
// file is read exactly as before — that is the backward compatibility.
|
|
285
|
+
|
|
286
|
+
test('a run targeting instance A uses A\'s session even after signing in to B', () => {
|
|
287
|
+
const { out, status } = inSandbox(`
|
|
288
|
+
await lib.saveSession({ token:'TOKEN-A', api_base:'https://a.example.com', builder:{github_login:'x'} });
|
|
289
|
+
await lib.saveSession({ token:'TOKEN-B', api_base:'https://b.example.com', builder:{github_login:'x'} });
|
|
290
|
+
process.env[ic.envPrefix() + '_API_BASE'] = 'https://a.example.com';
|
|
291
|
+
const s = await lib.loadSession();
|
|
292
|
+
const sync = lib.loadSessionSync();
|
|
293
|
+
const tok = lib.readSessionToken();
|
|
294
|
+
console.log(JSON.stringify({ async: s.token, sync: sync.token, raw: tok.token, base: tok.apiBase }));`);
|
|
295
|
+
assert.equal(status, 0, out);
|
|
296
|
+
const r = JSON.parse(out.trim().split('\n').pop());
|
|
297
|
+
assert.equal(r.async, 'TOKEN-A', 'loadSession sent the last sign-in\'s token to another instance — the bug');
|
|
298
|
+
assert.equal(r.sync, 'TOKEN-A');
|
|
299
|
+
assert.equal(r.raw, 'TOKEN-A');
|
|
300
|
+
assert.equal(r.base, 'https://a.example.com');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('with no target, or nothing stored for it, the single active file is read as before', () => {
|
|
304
|
+
const { out, status } = inSandbox(`
|
|
305
|
+
// A pre-store session: only the active pointer exists.
|
|
306
|
+
const active = ic.configPath(ic.SESSION_FILENAMES[0]);
|
|
307
|
+
fs.mkdirSync(path.dirname(active), { recursive: true });
|
|
308
|
+
fs.writeFileSync(active, JSON.stringify({ token:'OLD', api_base:'https://a.example.com' }));
|
|
309
|
+
const plain = (await lib.loadSession()).token;
|
|
310
|
+
process.env[ic.envPrefix() + '_API_BASE'] = 'https://elsewhere.example.com';
|
|
311
|
+
const unstored = (await lib.loadSession()).token;
|
|
312
|
+
console.log(JSON.stringify({ plain, unstored }));`);
|
|
313
|
+
assert.equal(status, 0, out);
|
|
314
|
+
const r = JSON.parse(out.trim().split('\n').pop());
|
|
315
|
+
assert.equal(r.plain, 'OLD');
|
|
316
|
+
assert.equal(r.unstored, 'OLD', 'an unstored target must fall back to the active file, never to no session');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('signing in to B still archives the ACTIVE session, not the targeted one', () => {
|
|
320
|
+
const { out, status } = inSandbox(`
|
|
321
|
+
await lib.saveSession({ token:'TOKEN-A', api_base:'https://a.example.com' });
|
|
322
|
+
await lib.saveSession({ token:'TOKEN-B', api_base:'https://b.example.com' });
|
|
323
|
+
process.env[ic.envPrefix() + '_API_BASE'] = 'https://a.example.com';
|
|
324
|
+
await lib.saveSession({ token:'TOKEN-C', api_base:'https://c.example.com' });
|
|
325
|
+
const b = await lib.loadStoredSession('https://b.example.com');
|
|
326
|
+
console.log(JSON.stringify({ b: b && b.token, hosts: lib.listStoredSessions().map(h=>h.host) }));`);
|
|
327
|
+
assert.equal(status, 0, out);
|
|
328
|
+
const r = JSON.parse(out.trim().split('\n').pop());
|
|
329
|
+
assert.equal(r.b, 'TOKEN-B');
|
|
330
|
+
assert.deepEqual(r.hosts, ['a.example.com', 'b.example.com', 'c.example.com']);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// The bug report's own case: a BRANDED checkout (config/branding.json → its publicOrigin) whose
|
|
334
|
+
// active pointer was rewritten by a sign-in to another instance. Driven purely, with the brand and
|
|
335
|
+
// the instance-config probe injected, so it never depends on this checkout's real branding.
|
|
336
|
+
const BRAND_A = { domains: { publicOrigin: 'https://a.example.com' }, repo: { envPrefix: 'ZZTEST' } };
|
|
337
|
+
|
|
338
|
+
test('a branded checkout targets its own publicOrigin, a neutral one targets nothing', () => {
|
|
339
|
+
assert.equal(lib.sessionTargetBase({ env: {}, brand: BRAND_A, hasInstanceConfig: () => true }), 'https://a.example.com');
|
|
340
|
+
assert.equal(lib.sessionTargetBase({ env: {}, brand: BRAND_A, hasInstanceConfig: () => false }), null);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the injected brand\'s OWN env prefix steers the target', () => {
|
|
344
|
+
const prefix = ic.envPrefix(BRAND_A);
|
|
345
|
+
const env = { [`${prefix}_API_BASE`]: 'https://override.example.com' };
|
|
346
|
+
assert.equal(lib.sessionTargetBase({ env, brand: BRAND_A, hasInstanceConfig: () => true }), 'https://override.example.com');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('a branded checkout whose active pointer belongs to another instance reads its OWN stored session', () => {
|
|
350
|
+
const opts = { env: {}, brand: BRAND_A, hasInstanceConfig: () => true };
|
|
351
|
+
const strayB = { token: 'TOKEN-B', api_base: 'https://b.example.com' };
|
|
352
|
+
assert.equal(lib.targetStorePath(strayB, opts), path.join(lib.sessionStoreDir(), 'a.example.com.json'));
|
|
353
|
+
// Already on target, or no target: keep the active session, read nothing.
|
|
354
|
+
assert.equal(lib.targetStorePath({ token: 'A', api_base: 'https://a.example.com' }, opts), null);
|
|
355
|
+
assert.equal(lib.targetStorePath(strayB, { ...opts, hasInstanceConfig: () => false }), null);
|
|
356
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// tests/community_proof_db.mjs — PROOF: the privacy matrix and the window math on every
|
|
2
|
+
// community surface, against real Postgres (task 1002985, goal 1000110 CT9;
|
|
3
|
+
// docs/specs/bongos-v1-community-tab.md D3, D4, D5, D7 and D8).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS ADDS. tests/privacy_matrix.mjs proves every cell DB-free, through an
|
|
6
|
+
// interpreter that reads each statement's WHERE text, and each surface's own _db test
|
|
7
|
+
// proves its arithmetic on a fixture of its own. Neither runs ONE fixture through EVERY
|
|
8
|
+
// surface on real SQL. This does: one account per D2 row with the cells of
|
|
9
|
+
// tests/fixtures/privacy-matrix.mjs, one shared snapshot history, and the real routers
|
|
10
|
+
// over HTTP — the leaderboard in every window, search by name and by craft, the builder
|
|
11
|
+
// of the week, the crowns and the movers — each read signed out and signed in, and the
|
|
12
|
+
// two bodies compared byte for byte.
|
|
13
|
+
//
|
|
14
|
+
// THE HISTORY. PUB's credits step up one hour after every window edge, by 1, 2, 4 … 256,
|
|
15
|
+
// one work a step, so each window's total is a bitmask naming exactly the snapshots it
|
|
16
|
+
// counted (510 = every step after the 365-day edge, 192 = the week's two). Every account
|
|
17
|
+
// the matrix keeps off the boards gains 1000 a step: had any predicate let one through,
|
|
18
|
+
// it would top that board and take that crown, so its absence is never vacuous.
|
|
19
|
+
//
|
|
20
|
+
// THE CLOCK is frozen at NOW, a Wednesday in August 2097, because the routes take no
|
|
21
|
+
// `now`. Every read is global, and those windows hold no real builder's snapshot, so a
|
|
22
|
+
// shared database's own rows move nowhere in them (the crowns and movers _db precedent).
|
|
23
|
+
//
|
|
24
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the INTEGRATION
|
|
25
|
+
// set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
|
|
26
|
+
//
|
|
27
|
+
// Run: DATABASE_URL=... node tests/community_proof_db.mjs
|
|
28
|
+
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import http from 'node:http';
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { KEYS, STATE, MATRIX } from './fixtures/privacy-matrix.mjs';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const { Pool } = require('pg');
|
|
39
|
+
const express = require('express');
|
|
40
|
+
|
|
41
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
43
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
44
|
+
|
|
45
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
46
|
+
try {
|
|
47
|
+
await pool.query('SELECT 1');
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.log(`community_proof_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
50
|
+
await pool.end().catch(() => {});
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// The fixture
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const PREFIX = 't1002985-';
|
|
59
|
+
const TAG = 't1002985-craft'; // every fixture row's only craft; the board reads filter on it
|
|
60
|
+
const ID = Object.fromEntries(KEYS.map((k, i) => [k, 90298501 + i]));
|
|
61
|
+
// '-acct' keeps one handle from being a substring of another (priv / privd / privdh)
|
|
62
|
+
const HANDLE = Object.fromEntries(KEYS.map((k) => [k, `${PREFIX}${k.toLowerCase()}-acct`]));
|
|
63
|
+
const NAME = Object.fromEntries(KEYS.map((k) => [k, `T1002985 ${k} Acct`]));
|
|
64
|
+
const IDS = Object.values(ID);
|
|
65
|
+
const CLIENT = 't1002985-p1';
|
|
66
|
+
const ORIGIN = 'https://t1002985-p1.example';
|
|
67
|
+
const PROJECT = 'T1002985 Alpha';
|
|
68
|
+
|
|
69
|
+
const NOW = '2097-08-14T12:00:00.000Z';
|
|
70
|
+
const HISTORY = '2095-06-01T00:00:00.000Z'; // the first snapshot written: when history began
|
|
71
|
+
// Every edge a surface reads, in time order. The routes' own answers are checked against
|
|
72
|
+
// these below, so a fixture that stopped straddling them would fail, not pass vacuously.
|
|
73
|
+
const EDGE = {
|
|
74
|
+
lastYear: '2096-01-01T00:00:00.000Z',
|
|
75
|
+
d365: '2096-08-14T12:00:00.000Z',
|
|
76
|
+
year: '2097-01-01T00:00:00.000Z',
|
|
77
|
+
month: '2097-07-01T00:00:00.000Z',
|
|
78
|
+
d30: '2097-07-15T12:00:00.000Z',
|
|
79
|
+
monthEnd: '2097-08-01T00:00:00.000Z',
|
|
80
|
+
week: '2097-08-05T00:00:00.000Z',
|
|
81
|
+
d7: '2097-08-07T12:00:00.000Z',
|
|
82
|
+
weekEnd: '2097-08-12T00:00:00.000Z',
|
|
83
|
+
};
|
|
84
|
+
const day = (iso) => iso.slice(0, 10);
|
|
85
|
+
|
|
86
|
+
// PUB's numbers per window, read off its steps: the sum of the steps after the edge.
|
|
87
|
+
const PUB_IN = {
|
|
88
|
+
all: [511, 9], // every step
|
|
89
|
+
'365d': [510, 8], // 2 … 256: the 365-day edge falls after the first step
|
|
90
|
+
'30d': [496, 5], // 16 … 256
|
|
91
|
+
'7d': [384, 2], // 128 + 256
|
|
92
|
+
week: [192, 2], // 64 + 128: the step after the week's end is the next week's
|
|
93
|
+
month: [24, 2], // 8 + 16: the step after the month's end is August's
|
|
94
|
+
year: [508, 7], // 4 … 256, the year so far
|
|
95
|
+
lastYear: [3, 2], // 1 + 2
|
|
96
|
+
};
|
|
97
|
+
// OPTOUT: 5 credits and one work at the 7-day edge plus two hours, so inside every rolling
|
|
98
|
+
// window, the week and the year so far, and in neither July nor last year.
|
|
99
|
+
const OPTOUT_IN = [5, 1];
|
|
100
|
+
|
|
101
|
+
// The shared rule this history is built for: the boards, the crowns and the movers show
|
|
102
|
+
// PUB and OPTOUT and nobody else (a crown or a card IS stats — the board's rule).
|
|
103
|
+
const SHOWN = KEYS.filter((k) => MATRIX[k].board === 'listed');
|
|
104
|
+
assert.deepEqual(SHOWN, ['PUB', 'OPTOUT'], 'the matrix changed — re-plan this history before trusting it');
|
|
105
|
+
for (const k of KEYS) {
|
|
106
|
+
for (const col of ['week', 'crowns', 'movers']) assert.equal(MATRIX[k][col], MATRIX[k].board, `${k}.${col} no longer follows the board`);
|
|
107
|
+
}
|
|
108
|
+
const KEPT_OFF = KEYS.filter((k) => !SHOWN.includes(k));
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// The frozen clock, the doorway and the real routers
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// Every Date the modules make from here on is a FrozenDate. A Date made before the swap
|
|
115
|
+
// would fail pg's `instanceof Date`, so fixture times go to SQL as ISO strings.
|
|
116
|
+
const RealDate = Date;
|
|
117
|
+
const clock = { now: RealDate.parse(NOW) };
|
|
118
|
+
class FrozenDate extends RealDate {
|
|
119
|
+
constructor(...args) {
|
|
120
|
+
if (args.length) super(...args);
|
|
121
|
+
else super(clock.now);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static now() {
|
|
125
|
+
return clock.now;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
globalThis.Date = FrozenDate;
|
|
129
|
+
const after = (iso, hours) => new RealDate(RealDate.parse(iso) + hours * 3600 * 1000).toISOString();
|
|
130
|
+
|
|
131
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
132
|
+
require.cache[moduleApiPath] = {
|
|
133
|
+
id: moduleApiPath,
|
|
134
|
+
filename: moduleApiPath,
|
|
135
|
+
loaded: true,
|
|
136
|
+
exports: {
|
|
137
|
+
pool,
|
|
138
|
+
corsPublicGet: () => (_req, _res, next) => next(),
|
|
139
|
+
logger: () => ({ error: (o, m) => console.error(`route error: ${m}`, o), info: () => {}, warn: () => {} }),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
143
|
+
const app = express();
|
|
144
|
+
app.use(express.json());
|
|
145
|
+
app.use(attachFail);
|
|
146
|
+
for (const f of ['community-search', 'community-leaderboard']) {
|
|
147
|
+
app.use('/api/bongos', require(`../modules/platform-identity/routes/${f}.js`)());
|
|
148
|
+
}
|
|
149
|
+
const server = app.listen(0, '127.0.0.1');
|
|
150
|
+
await new Promise((r) => server.once('listening', r));
|
|
151
|
+
const port = server.address().port;
|
|
152
|
+
|
|
153
|
+
const SIGNED_IN = { Authorization: 'Bearer t1002985-viewer', Cookie: 'bongos_session=t1002985-viewer' };
|
|
154
|
+
const SEEN = []; // every body served, for the sweep at the end
|
|
155
|
+
function get(path, headers) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path, headers, agent: false }, (res) => {
|
|
158
|
+
const chunks = [];
|
|
159
|
+
res.on('data', (c) => chunks.push(c));
|
|
160
|
+
res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
|
|
161
|
+
});
|
|
162
|
+
req.on('error', reject);
|
|
163
|
+
req.end();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// A surface read signed out and signed in: the same bytes to both (spec D3, D8).
|
|
167
|
+
async function read(path) {
|
|
168
|
+
const anon = await get(path, {});
|
|
169
|
+
const signed = await get(path, SIGNED_IN);
|
|
170
|
+
assert.equal(anon.status, 200, `${path} → ${anon.status}: ${anon.text.slice(0, 300)}`);
|
|
171
|
+
assert.equal(signed.text, anon.text, `${path}: a signed-in viewer got a different body`);
|
|
172
|
+
SEEN.push({ path, text: anon.text });
|
|
173
|
+
return JSON.parse(anon.text);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
177
|
+
let pass = 0;
|
|
178
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
179
|
+
const who = (w) => w && [w.handle, w.credits, w.tasks_shipped];
|
|
180
|
+
const mine = (rows) => rows.filter((r) => r.handle.startsWith(PREFIX));
|
|
181
|
+
|
|
182
|
+
async function cleanup() {
|
|
183
|
+
await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
184
|
+
await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
185
|
+
await q('DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
186
|
+
await q('DELETE FROM platform_identity_projects WHERE origin = $1', [ORIGIN]).catch(() => {});
|
|
187
|
+
await q('DELETE FROM platform_identity_sso_clients WHERE client_id = $1', [CLIENT]).catch(() => {});
|
|
188
|
+
await q('DELETE FROM platform_identity_weekly_features WHERE week_start = $1::date', [day(EDGE.week)]).catch(() => {});
|
|
189
|
+
await q(
|
|
190
|
+
`DELETE FROM platform_identity_period_features
|
|
191
|
+
WHERE (period = 'month' AND period_start = $1::date) OR (period = 'year' AND period_start = $2::date)`,
|
|
192
|
+
[day(EDGE.month), day(EDGE.lastYear)],
|
|
193
|
+
).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// One (project, builder) row, seen since history began, and its cumulative snapshots.
|
|
197
|
+
async function history(k, steps) {
|
|
198
|
+
const [, credits, ships] = steps[steps.length - 1];
|
|
199
|
+
await q(
|
|
200
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, disciplines, first_seen_at)
|
|
201
|
+
VALUES ($1, $2, $3, $4, 0, $5, $6)`,
|
|
202
|
+
[CLIENT, ID[k], credits, ships, [TAG], HISTORY],
|
|
203
|
+
);
|
|
204
|
+
for (const [at, c, s] of steps) {
|
|
205
|
+
await q(
|
|
206
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
|
|
207
|
+
VALUES ($1, $2, $3, $4, 0, $5)`,
|
|
208
|
+
[CLIENT, ID[k], c, s, at],
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// From zero at HISTORY, one step `hours` after each edge, `size(i)` credits and one work each.
|
|
213
|
+
function stepsAfterEachEdge(hours, size) {
|
|
214
|
+
let credits = 0;
|
|
215
|
+
return [[HISTORY, 0, 0], ...Object.values(EDGE).map((edge, i) => {
|
|
216
|
+
credits += size(i);
|
|
217
|
+
return [after(edge, hours), credits, i + 1];
|
|
218
|
+
})];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await q('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
|
223
|
+
for (const f of [
|
|
224
|
+
'platform_identity_001_tables.sql', 'platform_identity_004_projects.sql', 'platform_identity_005_builder_activity.sql',
|
|
225
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
226
|
+
'platform_identity_011_project_visibility.sql', 'platform_identity_012_account_privacy.sql',
|
|
227
|
+
'platform_identity_019_terms_acceptance.sql', 'platform_identity_020_visibility_pull.sql',
|
|
228
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_023_builder_search_trgm.sql',
|
|
229
|
+
'platform_identity_032_weekly_features.sql', 'platform_identity_033_period_features.sql',
|
|
230
|
+
]) await q(migrationSql(f));
|
|
231
|
+
await cleanup();
|
|
232
|
+
|
|
233
|
+
for (const k of KEYS) {
|
|
234
|
+
const s = STATE[k];
|
|
235
|
+
await q(
|
|
236
|
+
`INSERT INTO platform_identity_accounts
|
|
237
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, recruiter_discoverable, terms_accepted_at)
|
|
238
|
+
VALUES ($1, $2, $3, $2, $4, $5, $6, $7, now())`,
|
|
239
|
+
[ID[k], HANDLE[k], NAME[k], s.profile_state, s.account_visibility, s.hide_stats, s.recruiter_discoverable],
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin) VALUES ($1, 'x', $2, $3)`, [CLIENT, PROJECT, ORIGIN]);
|
|
243
|
+
await q(
|
|
244
|
+
`INSERT INTO platform_identity_projects (origin, name, description, featured, status, visibility, visibility_source)
|
|
245
|
+
VALUES ($1, $2, 'described', false, 'active', 'public', 'control-plane')`,
|
|
246
|
+
[ORIGIN, PROJECT],
|
|
247
|
+
);
|
|
248
|
+
await history('PUB', stepsAfterEachEdge(1, (i) => 2 ** i)); // first, so its HISTORY row is the first snapshot
|
|
249
|
+
await history('OPTOUT', [[HISTORY, 0, 0], [after(EDGE.d7, 2), ...OPTOUT_IN]]);
|
|
250
|
+
for (const k of KEPT_OFF) await history(k, stepsAfterEachEdge(2, () => 1000));
|
|
251
|
+
|
|
252
|
+
// 1 — The leaderboard in every window (D3): exactly the shown accounts, the numbers exact.
|
|
253
|
+
for (const [w, edge] of [['all', null], ['365d', EDGE.d365], ['30d', EDGE.d30], ['7d', EDGE.d7]]) {
|
|
254
|
+
const board = await read(`/api/bongos/community/leaderboard?window=${w}&discipline=${TAG}&limit=100`);
|
|
255
|
+
same(board.since, edge, `${w}: the window opens on the edge the history straddles`);
|
|
256
|
+
same(board.results.map((r) => [r.place, r.handle, r.credits, r.tasks_shipped]),
|
|
257
|
+
[[1, HANDLE.PUB, ...PUB_IN[w]], [2, HANDLE.OPTOUT, ...OPTOUT_IN]],
|
|
258
|
+
`${w} board: PUB ${PUB_IN[w][0]} and OPTOUT ${OPTOUT_IN[0]}; the five accounts kept off, each ahead of both, are absent`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2 — Search (D4): findable by name per the matrix; a craft is stats, so the craft filter
|
|
262
|
+
// drops the hidden account the name search lists.
|
|
263
|
+
const byName = new Map(mine((await read(`/api/bongos/community/search?q=${PREFIX}&limit=50`)).results).map((r) => [r.handle, r]));
|
|
264
|
+
for (const k of KEYS) {
|
|
265
|
+
const r = byName.get(HANDLE[k]);
|
|
266
|
+
const got = !r ? 'absent' : r.disciplines === null ? 'name-only' : 'listed';
|
|
267
|
+
same(got, MATRIX[k].search, `search by name, ${STATE[k].label}: ${MATRIX[k].search}`);
|
|
268
|
+
if (got === 'listed') same(r.disciplines, [TAG], `${k}'s crafts are its own`);
|
|
269
|
+
}
|
|
270
|
+
const byCraft = mine((await read(`/api/bongos/community/search?q=${PREFIX}&discipline=${TAG}&limit=50`)).results);
|
|
271
|
+
same(byCraft.map((r) => r.handle).sort(), KEYS.filter((k) => MATRIX[k].search === 'listed').map((k) => HANDLE[k]).sort(),
|
|
272
|
+
'search by craft: the hidden account found by name is absent');
|
|
273
|
+
|
|
274
|
+
// 3 — The builder of the week (D5): the last full Monday-to-Monday week, crowned at render.
|
|
275
|
+
const bow = await read('/api/bongos/community/builder-of-the-week');
|
|
276
|
+
same([bow.week.start, bow.week.end], [day(EDGE.week), day(EDGE.weekEnd)], 'the crowned week is the one the history straddles');
|
|
277
|
+
same(who(bow.winner), [HANDLE.PUB, ...PUB_IN.week], 'builder of the week: PUB’s 192 and two works');
|
|
278
|
+
same(mine(bow.past), [], 'no fixture account in the past weeks');
|
|
279
|
+
|
|
280
|
+
// 4 — The crowns: the month and last year crowned at render, the year so far live.
|
|
281
|
+
const crowns = await read('/api/bongos/community/crowns');
|
|
282
|
+
same(who(crowns.week.winner), [HANDLE.PUB, ...PUB_IN.week], 'the home’s week is the same crown');
|
|
283
|
+
same([crowns.month.start, crowns.month.end, crowns.month.first_crown], [day(EDGE.month), day(EDGE.monthEnd), null], 'the crowned month is July, the history straddling both ends');
|
|
284
|
+
same(who(crowns.month.winner), [HANDLE.PUB, ...PUB_IN.month], 'builder of the month: PUB’s 24 and two works');
|
|
285
|
+
same([crowns.year.start, crowns.year.since], [day(EDGE.year), day(EDGE.year)], 'the year so far, history having begun before it');
|
|
286
|
+
same(who(crowns.year.winner), [HANDLE.PUB, ...PUB_IN.year], 'builder of the year so far: PUB’s 508 and seven works');
|
|
287
|
+
same(crowns.year.last && [crowns.year.last.start, ...who(crowns.year.last.winner)], [day(EDGE.lastYear), HANDLE.PUB, ...PUB_IN.lastYear],
|
|
288
|
+
'builder of last year: PUB’s 3 and two works');
|
|
289
|
+
|
|
290
|
+
// 5 — The movers (D7): the last 30 days, rolling.
|
|
291
|
+
const movers = await read('/api/bongos/community/movers');
|
|
292
|
+
same([movers.window.start, movers.window.end], [EDGE.d30, NOW], 'the 30 days end now and open on the 30-day edge');
|
|
293
|
+
same(who(movers.rising), [HANDLE.PUB, ...PUB_IN['30d']], 'rising builder: PUB, 15 before the 30 days (under 100) and 496 in them');
|
|
294
|
+
same(movers.projects.map((p) => [p.name, p.origin, p.tasks_shipped, p.credits]), [[PROJECT, ORIGIN, 6, 501]],
|
|
295
|
+
'projects on the move: PUB’s five works and OPTOUT’s one; none of the kept-off accounts’ works count');
|
|
296
|
+
|
|
297
|
+
// 6 — A winner who hides (D8): gone from every surface on the next read, and no pinned
|
|
298
|
+
// period is handed to anyone else; only the live reads move on to OPTOUT.
|
|
299
|
+
await q('UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1', [ID.PUB]);
|
|
300
|
+
for (const w of ['all', '365d', '30d', '7d']) {
|
|
301
|
+
const board = await read(`/api/bongos/community/leaderboard?window=${w}&discipline=${TAG}&limit=100`);
|
|
302
|
+
same(board.results.map((r) => [r.place, r.handle, r.credits, r.tasks_shipped]), [[1, HANDLE.OPTOUT, ...OPTOUT_IN]], `${w} board after PUB hides: OPTOUT alone`);
|
|
303
|
+
}
|
|
304
|
+
const hidName = mine((await read(`/api/bongos/community/search?q=${PREFIX}&limit=50`)).results).find((r) => r.handle === HANDLE.PUB);
|
|
305
|
+
same(hidName && hidName.disciplines, null, 'search by name: PUB is still found, now name-only');
|
|
306
|
+
same(mine((await read(`/api/bongos/community/search?q=${PREFIX}&discipline=${TAG}&limit=50`)).results).map((r) => r.handle), [HANDLE.OPTOUT],
|
|
307
|
+
'search by craft: PUB is gone');
|
|
308
|
+
same((await read('/api/bongos/community/builder-of-the-week')).winner, null, 'the week PUB won shows nobody, though OPTOUT earned in it');
|
|
309
|
+
const hidCrowns = await read('/api/bongos/community/crowns');
|
|
310
|
+
same([hidCrowns.week.winner, hidCrowns.month.winner, hidCrowns.year.last], [null, null, null], 'the week, July and last year stay PUB’s and show nobody');
|
|
311
|
+
same(hidCrowns.year.winner, null, 'the year so far shows nobody while the leader’s memo is warm');
|
|
312
|
+
const hidMovers = await read('/api/bongos/community/movers');
|
|
313
|
+
same(who(hidMovers.rising), [HANDLE.OPTOUT, ...OPTOUT_IN], 'rising builder: OPTOUT, without waiting for the memo');
|
|
314
|
+
same(hidMovers.projects.map((p) => [p.name, p.tasks_shipped, p.credits]), [[PROJECT, 1, 5]], 'PUB’s works leave the project’s totals');
|
|
315
|
+
clock.now += 31 * 1000; // past every 30s memo; no snapshot sits within 31s of an edge
|
|
316
|
+
same(who((await read('/api/bongos/community/crowns')).year.winner), [HANDLE.OPTOUT, ...OPTOUT_IN], 'once the memo lapses, the year so far is OPTOUT’s');
|
|
317
|
+
|
|
318
|
+
// 7 — The sweep: an account kept off the boards is named by no body served above, but
|
|
319
|
+
// the name search the matrix allows it.
|
|
320
|
+
for (const k of KEPT_OFF) {
|
|
321
|
+
for (const r of SEEN) {
|
|
322
|
+
if (r.path.startsWith('/api/bongos/community/search?') && !r.path.includes('discipline=') && MATRIX[k].search !== 'absent') continue;
|
|
323
|
+
assert.ok(!r.text.includes(HANDLE[k]) && !r.text.includes(NAME[k]), `${k} was named by GET ${r.path}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
same(SEEN.length, 19, 'the sweep read all 19 bodies served, each identical signed out and signed in, and no kept-off account is named where the matrix says absent');
|
|
327
|
+
|
|
328
|
+
console.log(`\ncommunity_proof_db.mjs: ${pass} passed`);
|
|
329
|
+
} finally {
|
|
330
|
+
await cleanup();
|
|
331
|
+
globalThis.Date = RealDate;
|
|
332
|
+
await new Promise((r) => server.close(r));
|
|
333
|
+
await pool.end().catch(() => {});
|
|
334
|
+
}
|