@bongos/core 1.19.625 → 1.19.627

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.
@@ -35,6 +35,7 @@ import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import vm from 'node:vm';
37
37
  import { fileURLToPath } from 'node:url';
38
+ import { assertCannotSwallow } from './helpers.mjs';
38
39
 
39
40
  const require = createRequire(import.meta.url);
40
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -79,11 +80,9 @@ const ideaRouter = ideaRoutesFactory({});
79
80
  const mountedPaths = ideaRouter.stack.filter((l) => l.route).map((l) => l.route.path);
80
81
 
81
82
  await test('GET /inbox/by-builder/:id is declared BEFORE GET /inbox/:id', () => {
82
- const trail = mountedPaths.indexOf('/inbox/by-builder/:id');
83
- const byId = mountedPaths.indexOf('/inbox/:id');
84
- assert.ok(trail > -1, 'the trail route is mounted');
85
- assert.ok(byId > -1, 'the by-id route is mounted');
86
- assert.ok(trail < byId, `/inbox/by-builder/:id (${trail}) must precede /inbox/:id (${byId}) or :id swallows it`);
83
+ // Order OR a constrained param — either makes the trail route reachable. See
84
+ // assertCannotSwallow in tests/helpers.mjs (task 1003747).
85
+ assertCannotSwallow(assert, mountedPaths, '/inbox/by-builder/:id', '/inbox/:id');
87
86
  });
88
87
 
89
88
  // ---------------------------------------------------------------------------
@@ -160,7 +160,12 @@ await test('ROUTE SHAPE: the triage-gated PATCH /inbox/:id was NOT widened to re
160
160
  assert.ok(row, 'the triage route still exists');
161
161
  assert.equal(row.rank, 'metic+archon', 'PATCH /inbox/:id must keep its triage-level gate');
162
162
  const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
163
- const start = src.indexOf("router.patch('/inbox/:id',");
163
+ // Tolerate an inline param constraint on the path — `'/inbox/:id(\d+)'` (task
164
+ // 1003747, which added one so this route stops swallowing another module's
165
+ // `/inbox/rotting`). Matching the bare literal made this test report the triage
166
+ // route as missing when only its MATCHER had been narrowed. The route key above
167
+ // is canonicalised for the same reason (src/bongos/route-rank-check.js).
168
+ const start = src.search(/router\.patch\('\/inbox\/:id(?:\([^)]*\))?',/);
164
169
  const body = src.slice(start, src.indexOf('router.', start + 20));
165
170
  // Assert on the VALIDATION SCHEMA, not on any mention of the string: that route
166
171
  // already discusses body_md in a comment (about it not being parseable as a
@@ -0,0 +1,135 @@
1
+ // tests/rank_floor_wiring.mjs — the rank floor is WIRED, not merely derivable
2
+ // (task 1003663, goal 1000111, ADR 0270).
3
+ //
4
+ // WHY THIS FILE EXISTS SEPARATELY FROM tests/rank_gate.mjs. That file pins the
5
+ // PURE helpers — `deriveRequiredRank` / `highestRank` — by handing them opts it
6
+ // builds itself. That is necessary and it is not sufficient: the whole defect
7
+ // ADR 0270 repairs was a derivation that was perfectly correct and never
8
+ // REACHED, because the input it needed (`touches[]`) stopped arriving at the
9
+ // call site. A test that constructs the inputs by hand cannot catch that class
10
+ // of bug a second time — it asserts the calculator, not the plumbing.
11
+ //
12
+ // So this file drives the real `createTask` against a SQL-aware client and reads
13
+ // the `requires_rank` actually bound into the INSERT, plus the two PATCH setters
14
+ // that can move a floor input after creation.
15
+ //
16
+ // Run: node --test tests/rank_floor_wiring.mjs
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import { makeSqlAwareClient, lifecycleDbSource } from './helpers.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const { createTask } = require('../modules/lifecycle/db.js');
26
+
27
+ // The INSERT binds ~25 columns positionally, so a hardcoded params[20] would be a
28
+ // silent lie the first time somebody inserts a column. Read the column list out of
29
+ // the statement under test and resolve the index by NAME.
30
+ function boundValue(sql, params, column) {
31
+ const cols = String(sql).slice(sql.indexOf('(') + 1, sql.indexOf(')'))
32
+ .split(',').map((c) => c.trim());
33
+ const i = cols.indexOf(column);
34
+ assert.notEqual(i, -1, `INSERT INTO tasks no longer binds a '${column}' column — update this test`);
35
+ return params[i];
36
+ }
37
+
38
+ // One create against a fake goal row; returns the requires_rank the INSERT bound.
39
+ async function createdFloor({ scopeModules, needsMigration = false, requiresRank = null }) {
40
+ let insert = null;
41
+ const { _client } = makeSqlAwareClient(async (sql, params) => {
42
+ if (/^SELECT scope_modules FROM goals/i.test(sql)) {
43
+ return { rows: [{ scope_modules: scopeModules }] };
44
+ }
45
+ if (/^INSERT INTO tasks/i.test(sql)) {
46
+ insert = { sql, params };
47
+ return { rows: [{ id: '9001' }] };
48
+ }
49
+ return { rows: [] };
50
+ });
51
+
52
+ await createTask({
53
+ versionId: 'BONGOS-V2',
54
+ title: 'a task',
55
+ goalId: '1000111',
56
+ needsMigration,
57
+ requiresRank,
58
+ }, { client: _client });
59
+
60
+ assert.ok(insert, 'createTask never reached its INSERT');
61
+ return boundValue(insert.sql, insert.params, 'requires_rank');
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // createTask — the create path actually reads the goal wall
66
+ // ---------------------------------------------------------------------------
67
+
68
+ test('a goal whose wall reaches protected territory floors the task at metic', async () => {
69
+ // No touches[], not security_sensitive, not a migration — every pre-0270 input
70
+ // is absent, which is precisely the shape that used to come out 'xenos'.
71
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui', 'kernel'] }), 'metic');
72
+ });
73
+
74
+ test('an unprotected wall leaves the task in the open queue', async () => {
75
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'] }), 'xenos');
76
+ });
77
+
78
+ test('needs_migration floors at metic even under an unprotected wall', async () => {
79
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'], needsMigration: true }), 'metic');
80
+ });
81
+
82
+ test('THE REGRESSION: the create path reads the wall, it does not just own a helper', async () => {
83
+ // Mutation guard. If someone drops the goalScopeProtected call from createTask
84
+ // — the exact way this bug happened the first time, by a caller ceasing to
85
+ // supply an input — the pure tests in rank_gate.mjs all still pass and this one
86
+ // fails. That asymmetry is the point of the file.
87
+ assert.equal(await createdFloor({ scopeModules: ['lifecycle'] }), 'metic');
88
+ assert.equal(await createdFloor({ scopeModules: ['government'] }), 'metic');
89
+ });
90
+
91
+ test('an explicit requires_rank still only RAISES, never lowers the derived floor', async () => {
92
+ assert.equal(await createdFloor({ scopeModules: ['kernel'], requiresRank: 'xenos' }), 'metic');
93
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'], requiresRank: 'archon' }), 'archon');
94
+ });
95
+
96
+ test('a missing goal row is the honest false, not a throw', async () => {
97
+ // scope_modules is NOT NULL DEFAULT '{}', but a goal deleted mid-transaction
98
+ // returns no row at all. That must fall through to the other arms, not crash a
99
+ // create.
100
+ assert.equal(await createdFloor({ scopeModules: undefined }), 'xenos');
101
+ assert.equal(await createdFloor({ scopeModules: undefined, needsMigration: true }), 'metic');
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // The PATCH setters — a floor INPUT may be flipped after creation
106
+ // ---------------------------------------------------------------------------
107
+ // These two write through the module-level pool rather than an injectable client,
108
+ // so they are pinned at the source. The assertion is narrow on purpose: it is the
109
+ // RATCHET that matters (raise on true, never lower), not the surrounding SQL.
110
+
111
+ test('updateTaskNeedsMigration ratchets requires_rank UP, exactly like its security_sensitive sibling', () => {
112
+ const src = lifecycleDbSource();
113
+ const setter = src.slice(src.indexOf('async function updateTaskNeedsMigration'));
114
+ const body = setter.slice(0, setter.indexOf('\n}\n') + 1);
115
+
116
+ assert.match(
117
+ body.replace(/\s+/g, ' '),
118
+ /requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\) THEN 'metic' ELSE requires_rank END/,
119
+ 'PATCHing needs_migration=true must raise the floor — otherwise ADR 0270 is closed on the create path and open on the PATCH path'
120
+ );
121
+ // Raise-only: nothing in the setter may write a rank BACK DOWN when the flag
122
+ // clears. An existing metic may be a protected-scope floor or an Archon override.
123
+ assert.doesNotMatch(body, /ELSE 'xenos'/, 'the setter must never lower an existing floor');
124
+ });
125
+
126
+ test('both floor-input setters share one ratchet — neither may drift from the other', () => {
127
+ const src = lifecycleDbSource();
128
+ const ratchet = /requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\)\s+THEN 'metic' ELSE requires_rank END/g;
129
+ const hits = src.match(ratchet) || [];
130
+ assert.equal(
131
+ hits.length, 2,
132
+ `expected the ratchet in exactly 2 setters (security_sensitive + needs_migration), found ${hits.length} — ` +
133
+ 'a new boolean that feeds deriveRequiredRank needs one too, and a removed one is a reopened gap'
134
+ );
135
+ });
@@ -399,6 +399,51 @@ t('empty / null / undefined touches with no sensitivity => xenos (no throw)', ()
399
399
  assert.equal(db.deriveRequiredRank(undefined, undefined), 'xenos');
400
400
  });
401
401
 
402
+ // ---------------------------------------------------------------------------
403
+ // task 1003663 / ADR 0270 — the two touches-FREE inputs. ADR 0049 retired
404
+ // predictive touches[], so at CREATE time the arms above see an empty array on
405
+ // most vectors and the floor never rose: 68 of 88 needs_migration tasks sat at
406
+ // xenos, one of them a ready task to rewrite the auth token store. These pin the
407
+ // replacement, which derives from facts a task carries WITHOUT a prediction.
408
+ // ---------------------------------------------------------------------------
409
+ console.log('\nderiveRequiredRank (the touches-free floors — task 1003663):');
410
+
411
+ t('needs_migration floors at metic with NO touches at all', () => {
412
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: true }), 'metic');
413
+ assert.equal(db.deriveRequiredRank(null, false, { needsMigration: true }), 'metic');
414
+ });
415
+
416
+ t('a protected goal scope floors at metic with NO touches at all', () => {
417
+ assert.equal(db.deriveRequiredRank([], false, { scopeProtected: true }), 'metic');
418
+ assert.equal(db.deriveRequiredRank(null, false, { scopeProtected: true }), 'metic');
419
+ });
420
+
421
+ t('THE REGRESSION: the exact shape of task 1003372 (ready, migration, no touches)', () => {
422
+ // Before ADR 0270 this returned 'xenos' — a Xenos could claim a task to hash
423
+ // the builder_sessions token store at rest. It must never do so again.
424
+ assert.equal(
425
+ db.deriveRequiredRank([], false, { needsMigration: true, scopeProtected: false }),
426
+ 'metic'
427
+ );
428
+ });
429
+
430
+ t('the flags are opt-in: false / absent / non-boolean never RAISE the floor', () => {
431
+ // Only a strict === true raises, so a stray truthy value cannot quietly wall
432
+ // off the open queue (the fail-closed direction here is "stay open").
433
+ assert.equal(db.deriveRequiredRank([], false, {}), 'xenos');
434
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: false, scopeProtected: false }), 'xenos');
435
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: 'yes' }), 'xenos');
436
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: 1 }), 'xenos');
437
+ });
438
+
439
+ t('omitting opts entirely is the pre-0270 two-argument behaviour, unchanged', () => {
440
+ // Every existing call site passes two arguments; none may change meaning.
441
+ assert.equal(db.deriveRequiredRank([], false), 'xenos');
442
+ assert.equal(db.deriveRequiredRank(['public/game/main.js'], false), 'xenos');
443
+ assert.equal(db.deriveRequiredRank(['migrations/145_x.sql'], false), 'metic');
444
+ assert.equal(db.deriveRequiredRank([], true), 'metic');
445
+ });
446
+
402
447
  console.log('\nhighestRank (compose floor with an override; raise-only):');
403
448
 
404
449
  t('picks the higher-authority rank either way', () => {
@@ -0,0 +1,215 @@
1
+ // tests/route_shadow_guard.mjs — the guard that keeps a `:param` route from
2
+ // swallowing a literal route mounted after it (task 1003747).
3
+ //
4
+ // GET /inbox/rotting answered 400 bad_id in production for releases: `ideas`
5
+ // mounts GET /inbox/:id before `lifecycle` mounts GET /inbox/rotting, so the param
6
+ // matched first and ended the request before the rot handler was reached. The
7
+ // whole 30-day rot cadence was dead, and the hall's only caller swallowed the
8
+ // error into an empty section that looked like "nothing is rotting".
9
+ //
10
+ // The guard is tested on SYNTHETIC mount sequences, not only the live tree: a scan
11
+ // that passes because the tree is clean today proves nothing about what it
12
+ // REFUSES. The live-tree case at the bottom is what proves the real fix holds.
13
+ //
14
+ // Run: node tests/route_shadow_guard.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const {
21
+ checkNoRouteShadowing,
22
+ findShadowPairs,
23
+ mountSequence,
24
+ isBareParam,
25
+ isConstrainedParam,
26
+ } = require('../scripts/gds/route-shadow-guard.js');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ try { fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
33
+ }
34
+
35
+ const r = (module_, file, method, route) => ({ module: module_, file, method, route });
36
+
37
+ // --- THE DEFECT, EXACTLY AS IT SHIPPED -------------------------------------
38
+
39
+ test('catches the real pair: GET /inbox/:id mounted before GET /inbox/rotting', () => {
40
+ const pairs = findShadowPairs([
41
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
42
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
43
+ ]);
44
+ assert.equal(pairs.length, 1);
45
+ assert.equal(pairs[0].early.route, '/inbox/:id');
46
+ assert.equal(pairs[0].later.route, '/inbox/rotting');
47
+ });
48
+
49
+ test('the check hard-fails on that sequence, and names the fall-through fix', () => {
50
+ const out = checkNoRouteShadowing({
51
+ sequence: [
52
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
53
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
54
+ ],
55
+ });
56
+ assert.equal(out.ok, false);
57
+ assert.equal(out.hardFail, true);
58
+ assert.equal(out.violations.length, 1);
59
+ assert.match(out.violations[0], /\/inbox\/rotting/);
60
+ assert.match(out.violations[0], /never reaches its handler/);
61
+ assert.match(out.violations[0], /\\d\+/, 'points at constraining the param');
62
+ });
63
+
64
+ // --- WHAT THE FIX DOES -----------------------------------------------------
65
+
66
+ test('a CONSTRAINED param shadows nothing — this is what the fix relies on', () => {
67
+ const pairs = findShadowPairs([
68
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id(\\d+)'),
69
+ r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
70
+ ]);
71
+ assert.deepEqual(pairs, []);
72
+ });
73
+
74
+ test('order matters: a literal mounted BEFORE the param is fine', () => {
75
+ const pairs = findShadowPairs([
76
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/awaiting-nod'),
77
+ r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
78
+ ]);
79
+ assert.deepEqual(pairs, [], 'declaring literals first is the in-file convention and stays legal');
80
+ });
81
+
82
+ // --- WHAT MUST NOT BE FLAGGED ---------------------------------------------
83
+
84
+ test('different methods never compete', () => {
85
+ assert.deepEqual(findShadowPairs([
86
+ r('a', 'a.js', 'get', '/inbox/:id'),
87
+ r('b', 'b.js', 'patch', '/inbox/rotting'),
88
+ ]), []);
89
+ });
90
+
91
+ test('different depths never compete', () => {
92
+ assert.deepEqual(findShadowPairs([
93
+ r('a', 'a.js', 'get', '/inbox/:id'),
94
+ r('b', 'b.js', 'get', '/inbox/rotting/detail'),
95
+ ]), []);
96
+ });
97
+
98
+ test('two params at the same position are one shape, not a shadow', () => {
99
+ assert.deepEqual(findShadowPairs([
100
+ r('a', 'a.js', 'get', '/inbox/:id'),
101
+ r('b', 'b.js', 'get', '/inbox/:ideaId'),
102
+ ]), []);
103
+ });
104
+
105
+ test('a differing earlier literal segment means the routes never overlap', () => {
106
+ assert.deepEqual(findShadowPairs([
107
+ r('a', 'a.js', 'get', '/inbox/:id'),
108
+ r('b', 'b.js', 'get', '/blockers/rotting'),
109
+ ]), []);
110
+ });
111
+
112
+ test('the param must sit WHERE the literal is', () => {
113
+ assert.deepEqual(findShadowPairs([
114
+ r('a', 'a.js', 'get', '/tasks/:id/water'),
115
+ r('b', 'b.js', 'get', '/tasks/rotting/water'),
116
+ ]).length, 1, 'same position, so it does shadow');
117
+ assert.deepEqual(findShadowPairs([
118
+ r('a', 'a.js', 'get', '/tasks/:id/water'),
119
+ r('b', 'b.js', 'get', '/tasks/:id/rotting'),
120
+ ]), [], 'literal in a position the param does not occupy');
121
+ });
122
+
123
+ // --- SEGMENT CLASSIFIERS ---------------------------------------------------
124
+
125
+ test('bare vs constrained param', () => {
126
+ assert.equal(isBareParam(':id'), true);
127
+ assert.equal(isBareParam(':id(\\d+)'), false);
128
+ assert.equal(isBareParam('rotting'), false);
129
+ assert.equal(isConstrainedParam(':id(\\d+)'), true);
130
+ assert.equal(isConstrainedParam(':id'), false);
131
+ });
132
+
133
+ // --- SCAN INTEGRITY --------------------------------------------------------
134
+
135
+ test('an empty enumeration is a scan DEFECT, never a clean pass', () => {
136
+ const out = checkNoRouteShadowing({ sequence: [] });
137
+ assert.equal(out.ok, false);
138
+ assert.equal(out.hardFail, true);
139
+ assert.match(out.violations[0], /scan defect/);
140
+ });
141
+
142
+ // --- THE LIVE TREE ---------------------------------------------------------
143
+
144
+ test('the real repo enumerates routes and has no shadowing pair', () => {
145
+ const seq = mountSequence();
146
+ assert.ok(seq.length > 100, `expected a real mount sequence, got ${seq.length}`);
147
+ const out = checkNoRouteShadowing();
148
+ assert.equal(out.hardFail, false, `live tree has shadowing pairs:\n${out.violations.join('\n')}`);
149
+ });
150
+
151
+ test('the live tree still declares the rot feed and the constrained idea read', () => {
152
+ const seq = mountSequence();
153
+ const rot = seq.find((x) => x.route === '/inbox/rotting' && x.method === 'get');
154
+ assert.ok(rot, 'GET /inbox/rotting must still be declared');
155
+ const idea = seq.find((x) => x.route.startsWith('/inbox/:id') && x.method === 'get');
156
+ assert.ok(idea, 'GET /inbox/:id must still be declared');
157
+ assert.ok(isConstrainedParam(idea.route.split('/').filter(Boolean)[1]),
158
+ `GET ${idea.route} must constrain its id, or it eats /inbox/rotting again`);
159
+ });
160
+
161
+ // --- THE BEHAVIOUR THE FIX DEPENDS ON -------------------------------------
162
+ //
163
+ // The static guard above proves the DECLARATION is constrained. This proves the
164
+ // constraint actually routes that way in the Express version we ship — and it is
165
+ // not a formality: Express 5 REMOVED inline param regexes, so an upgrade would
166
+ // silently turn `:id(\d+)` back into a path that eats /inbox/rotting, with the
167
+ // static check still passing because the declaration text is unchanged. This is
168
+ // the test that goes red on that upgrade.
169
+
170
+ async function behaviour() {
171
+ const express = require('express');
172
+ const http = require('node:http');
173
+ const app = express();
174
+
175
+ // Mounted in the SAME order the real loader mounts them: `ideas` (the param
176
+ // route) before `lifecycle` (the literal).
177
+ const ideas = express.Router();
178
+ ideas.get('/inbox/:id(\\d+)', (req, res) => res.json({ handler: 'idea', id: req.params.id }));
179
+ const lifecycle = express.Router();
180
+ lifecycle.get('/inbox/rotting', (_req, res) => res.json({ handler: 'rot' }));
181
+ app.use(ideas);
182
+ app.use(lifecycle);
183
+
184
+ const server = http.createServer(app);
185
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
186
+ const base = `http://127.0.0.1:${server.address().port}`;
187
+ try {
188
+ const rot = await fetch(`${base}/inbox/rotting`);
189
+ const rotBody = await rot.json();
190
+ test('GET /inbox/rotting reaches the ROT handler despite being mounted second', () => {
191
+ assert.equal(rot.status, 200);
192
+ assert.equal(rotBody.handler, 'rot');
193
+ });
194
+
195
+ const idea = await fetch(`${base}/inbox/1234`);
196
+ const ideaBody = await idea.json();
197
+ test('a numeric id still reaches the idea handler', () => {
198
+ assert.equal(idea.status, 200);
199
+ assert.equal(ideaBody.handler, 'idea');
200
+ assert.equal(ideaBody.id, '1234');
201
+ });
202
+
203
+ const junk = await fetch(`${base}/inbox/not-a-number`);
204
+ test('an unmatched non-numeric segment 404s instead of 400 — the accepted trade-off', () => {
205
+ assert.equal(junk.status, 404);
206
+ });
207
+ } finally {
208
+ await new Promise((resolve) => server.close(resolve));
209
+ }
210
+ }
211
+
212
+ await behaviour();
213
+
214
+ console.log(`\n${passed} passed, ${failed} failed`);
215
+ if (failed > 0) process.exit(1);