@bongos/core 1.19.691 → 1.19.693

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.
@@ -0,0 +1,154 @@
1
+ // tests/grade_body_limit.mjs — the grade body is not capped at 64 KB (task 1003850).
2
+ //
3
+ // WHAT BROKE. A 1,141-file rename produced a grade body over the global 64 KB JSON cap,
4
+ // so POST /tasks/:id/grade came back 413 payload_too_large and fully verified work was
5
+ // stranded at 'completed' with no way to record its grade. --regrade fails identically:
6
+ // the size is deterministic, not transient.
7
+ //
8
+ // WHY THE BODY CANNOT SIMPLY BE TRIMMED. committed_files carries EVERY changed path, and
9
+ // it is not decoration: modules/grading/grader-score.js runs the server-side
10
+ // permission-path pre-pass against that exact list (SR-9 / SEC #863), so a shortened list
11
+ // would quietly weaken a security check rather than just lose detail. The body gets its
12
+ // own parser instead — the same shape memory/sync, sessions, publish-branch and the task
13
+ // visual already use.
14
+ //
15
+ // Two halves, and BOTH are required: the global parser must SKIP this path (otherwise it
16
+ // 413s before the route-scoped one ever runs — the comment in routes.js says exactly
17
+ // this), and the route must mount a larger parser of its own.
18
+
19
+ import test from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
27
+
28
+ const GRADE_PATH = '/tasks/1003850/grade';
29
+
30
+ test('the global 64 KB parser SKIPS the grade path', () => {
31
+ const src = read('src/bongos/routes.js');
32
+
33
+ // The cap itself is still 64 KB for everything else — this test is about the exemption,
34
+ // not about loosening the default.
35
+ assert.match(src, /express\.json\(\{ limit: '64kb' \}\)/,
36
+ 'the global cap should stay 64 KB; only named paths are exempt');
37
+
38
+ // Pull the skip predicate's path patterns straight out of the source and run them, so
39
+ // this asserts the REAL routing decision rather than the presence of a comment.
40
+ const block = src.slice(src.indexOf('const globalJson'), src.indexOf('// Audit middleware'));
41
+ // Regex LITERALS of the form /^...$/ — the body may contain escaped slashes, so the
42
+ // scan has to allow `\.` rather than stopping at the first `/`.
43
+ const patterns = [...block.matchAll(/\/\^(?:\\.|[^/\n])*\$\//g)].map((m) => m[0]);
44
+ assert.ok(patterns.length >= 3, `expected the skip list to carry several path regexes, saw ${patterns.length}`);
45
+
46
+ const matched = patterns.some((lit) => {
47
+ // eslint-disable-next-line no-new-func -- the literal comes from our own source
48
+ const re = new Function(`return ${lit}`)();
49
+ return re.test(GRADE_PATH);
50
+ });
51
+ assert.ok(matched, `no skip-list pattern matches ${GRADE_PATH} — the global parser would 413 the body before the route's own parser runs`);
52
+ });
53
+
54
+ test('the grade route mounts its own, larger parser', () => {
55
+ const src = read('modules/lifecycle/routes/tasks.js');
56
+
57
+ const m = /const GRADE_BODY_LIMIT = '(\d+)(kb|mb)';/.exec(src);
58
+ assert.ok(m, 'GRADE_BODY_LIMIT is gone — the route would fall back to whatever parser ran first');
59
+
60
+ const bytes = Number(m[1]) * (m[2] === 'mb' ? 1024 * 1024 : 1024);
61
+ assert.ok(bytes > 64 * 1024,
62
+ `GRADE_BODY_LIMIT (${m[1]}${m[2]}) must exceed the 64 KB global cap or the exemption buys nothing`);
63
+
64
+ // Mounted ON the route, ahead of the handler.
65
+ assert.match(src, /const gradeUpload = express\.json\(\{ limit: GRADE_BODY_LIMIT \}\);/,
66
+ 'the route-scoped parser is not built from GRADE_BODY_LIMIT');
67
+ // Mounted ON the route. The ORDER (auth first) is asserted separately below; pinning it
68
+ // here too would mean changing the same fact in two places.
69
+ assert.match(src, /router\.post\('\/tasks\/:id\/grade',[^)]*gradeUpload,/,
70
+ 'the grade route must run its own parser before the handler');
71
+ });
72
+
73
+ test('a realistic mass-change body fits the new limit', () => {
74
+ // The shape that broke it: one path per changed file, plus the panel's findings. 1,141
75
+ // paths measured ~38 KB of JSON on their own — comfortably over 64 KB once the grade
76
+ // output is added, and comfortably under 1 MB.
77
+ const files = Array.from({ length: 1200 }, (_, i) => `scripts/gds/some-fairly-long-script-name-${i}.js`);
78
+ const body = JSON.stringify({
79
+ committed_files: files,
80
+ subagent_output: { functional_fidelity: 9, code_quality: 9, notes: 'x'.repeat(20_000) },
81
+ });
82
+ assert.ok(body.length > 64 * 1024,
83
+ 'this fixture is meant to EXCEED the old cap — if it does not, it is not reproducing the bug');
84
+ assert.ok(body.length < 1024 * 1024,
85
+ `a mass-change body (${body.length} bytes) must fit the route limit`);
86
+ });
87
+
88
+ // ── the two findings the panel raised on the first round ────────────────────────────────
89
+
90
+ test('the LARGER parser runs AFTER auth, like every other exempted route here', () => {
91
+ // Raised by the panel, round 1. Mounting the 1 MB parser before requireBuilder lets an
92
+ // UNAUTHENTICATED caller make the server parse up to 1 MB — sixteen times the global cap
93
+ // — before it is rejected. This file already had the right shape twice over; the new
94
+ // route simply diverged from it.
95
+ const src = read('modules/lifecycle/routes/tasks.js');
96
+
97
+ assert.match(src, /router\.post\('\/tasks\/:id\/grade', auth\.requireBuilder, gradeUpload,/,
98
+ 'the grade route must authenticate BEFORE parsing a larger body');
99
+
100
+ // The convention this follows, asserted so the three stay in step.
101
+ assert.match(src, /router\.post\('\/tasks\/:id\/publish-branch', auth\.requireBuilder, publishUpload,/,
102
+ 'publish-branch is the precedent for auth-then-parser');
103
+ });
104
+
105
+ test('computeRawDiffHash does not collapse an uncapturable diff to hash("")', () => {
106
+ // The worse half of the same buffer bug, and the panel caught it: computeDiffText was
107
+ // fixed while its sibling was not. At the default 1 MB buffer a large diff returned null
108
+ // and the old code hashed the EMPTY STRING — one fixed SHA-256 shared by every large
109
+ // diff. shouldReuseGrade compares that hash to the stored grade's, so two different large
110
+ // diffs on one task collided and a stale PASS could be reused on changed code, skipping
111
+ // the panel entirely.
112
+ const src = read('scripts/gds/ship-grade.js');
113
+
114
+ const fn = src.slice(src.indexOf('function computeRawDiffHash'), src.indexOf('function shouldReuseGrade'));
115
+ assert.ok(fn.length > 0, 'computeRawDiffHash is gone');
116
+ assert.match(fn, /maxBuffer: DIFF_CAPTURE_MAX_BYTES/,
117
+ 'computeRawDiffHash must capture at the same size as computeDiffText');
118
+ assert.match(fn, /if \(raw === null\) return null;/,
119
+ 'a failed capture must be "unknown" (null), never hash(\'\')');
120
+ assert.doesNotMatch(fn, /diffHash\(raw \|\| ''\)/,
121
+ 'hashing `raw || \'\'` is the bug: every uncapturable diff hashes identically');
122
+
123
+ // And "unknown" must actually refuse reuse, which is what makes null the safe answer.
124
+ const reuse = src.slice(src.indexOf('function shouldReuseGrade'), src.indexOf('function shouldReuseGrade') + 400);
125
+ assert.match(reuse, /!currentDiffHash/, 'shouldReuseGrade must refuse a null/absent current hash');
126
+ });
127
+
128
+ test('both diff captures use one shared size, not two drifting numbers', () => {
129
+ // The two call sites are the same decision; a second literal is how one gets fixed and
130
+ // the other does not — which is exactly what happened here.
131
+ const src = read('scripts/gds/ship-grade.js');
132
+ assert.match(src, /const DIFF_CAPTURE_MAX_BYTES = /, 'the capture size must be named once');
133
+ const uses = (src.match(/maxBuffer: DIFF_CAPTURE_MAX_BYTES/g) || []).length;
134
+ assert.equal(uses, 2, `both diff captures must share the constant, saw ${uses}`);
135
+ });
136
+
137
+ test('the grade endpoint keeps its full generated description', () => {
138
+ // Panel finding, round 1 — and I doubted it before measuring, which was the wrong
139
+ // instinct. gen-api-docs' commentBlockAbove reads only the `//` lines IMMEDIATELY above
140
+ // `router.post(`, so declaring the parser between the doc-comment and the route collapses
141
+ // this endpoint's description from 1251 chars to 94: the rank line survives and every
142
+ // word about what the endpoint does, and how it fails, is dropped from BOTH
143
+ // docs/api-reference.md and openapi.json. Measured both ways before believing it.
144
+ //
145
+ // Pinned on LENGTH rather than exact prose so ordinary edits to the comment do not red
146
+ // this, while the collapse (which is an order-of-magnitude drop) still does.
147
+ const spec = JSON.parse(read('docs/api/openapi.json'));
148
+ const desc = spec.paths['/tasks/{id}/grade']?.post?.description || '';
149
+ assert.ok(desc.length > 400,
150
+ `the grade endpoint's generated description collapsed to ${desc.length} chars — `
151
+ + 'something now sits between its doc-comment and router.post(');
152
+ assert.match(desc, /[Ss]ubagent grader/,
153
+ 'the description should still explain what the endpoint is');
154
+ });
@@ -5,8 +5,12 @@
5
5
  // drive the pure builder + the generic core rule with no disk I/O.
6
6
  import assert from 'node:assert/strict';
7
7
  import { test } from 'node:test';
8
+ import { readFileSync, readdirSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
8
10
  import classifier from '../modules/lifecycle/task-classifier.js';
9
11
 
12
+ const require = createRequire(import.meta.url);
13
+
10
14
  const { classifyTaskKind, buildBlockerVendorRe } = classifier;
11
15
 
12
16
  test('buildBlockerVendorRe: empty / non-array → null (vanilla has no vendors)', () => {
@@ -39,3 +43,53 @@ test('generic blocker terms still classify without any vendor config', () => {
39
43
  assert.equal(classifyTaskKind('Unblock the release', '').kind, 'blocker-resolution');
40
44
  assert.equal(classifyTaskKind('Rotate the API key', '').kind, 'blocker-resolution');
41
45
  });
46
+
47
+ // ---- the kind vocabulary is ONE list, and everything reads it (task 1003453) --
48
+ //
49
+ // The board's Kind filter hardcoded its options and had gone stale: 'verify' was
50
+ // added to VALID_KINDS (and to the DB CHECK by migration 187) but never to the
51
+ // filter, so verify tasks existed, were claimable, and could not be filtered for.
52
+ // The goals page carried a second copy of the same stale list. Both now build the
53
+ // lens from the vocabulary the server serves, and these pin every hop of that.
54
+
55
+ test('the DB CHECK constraint lists exactly VALID_KINDS', () => {
56
+ // Migration 187's own header records this drift in the other direction — the
57
+ // app gained 'verify', the CHECK did not, and POST /tasks 500'd for months.
58
+ // Comparing the two is the guard neither side had.
59
+ const sql = readFileSync(new URL('../migrations/187_tasks_kind_check_add_verify.sql', import.meta.url), 'utf8');
60
+ const inClause = /CHECK \(kind IN \(([\s\S]*?)\)\)/.exec(sql);
61
+ assert.ok(inClause, 'the CHECK constraint is still written as `kind IN (...)`');
62
+ const fromSql = [...inClause[1].matchAll(/'([a-z-]+)'/g)].map((m) => m[1]);
63
+ assert.deepEqual([...fromSql].sort(), [...classifier.VALID_KINDS].sort(),
64
+ 'the DB CHECK and VALID_KINDS must name the same kinds — this is the drift migration 187 exists to record');
65
+ });
66
+
67
+ test('the lifecycle port serves the vocabulary as a JSON-safe array', () => {
68
+ const lifecycle = require('../modules/lifecycle/lifecycle.js');
69
+ const kinds = lifecycle.TASK_KINDS;
70
+ assert.ok(Array.isArray(kinds), 'an array — a Set does not survive JSON.stringify, which is how it reaches the hall');
71
+ assert.deepEqual([...kinds].sort(), [...classifier.VALID_KINDS].sort());
72
+ assert.ok(kinds.includes('verify'), 'the kind this whole task is about');
73
+ assert.notEqual(kinds, lifecycle.TASK_KINDS, 'a fresh copy each read — a caller must not be able to mutate the vocabulary');
74
+ });
75
+
76
+ // Scans the WHOLE surface — every .js AND .html — because the first cut of this
77
+ // test read .js only and matched a JS array literal, so it could not see the third
78
+ // copy: a static <option> list in ideas.html carrying the identical stale
79
+ // vocabulary. A guard that cannot see one of the places the bug lives is worse
80
+ // than no guard, because it is read as coverage. The grader caught that; this is
81
+ // the shape that would have caught it first.
82
+ test('no hall page carries its own copy of the kind list', () => {
83
+ const dir = new URL('../modules/hall-ui/public/', import.meta.url);
84
+ const pages = readdirSync(dir).filter((f) => f.endsWith('.js') || f.endsWith('.html'));
85
+ assert.ok(pages.length > 20, 'the surface was found — an empty scan must not pass silently');
86
+ // Three kinds in sequence is the signature of a spelled-out vocabulary, in a JS
87
+ // array ('cleanup', 'decision', …) or in markup (value="cleanup" … value="decision").
88
+ const SPELLED = /(['"])cleanup\1[\s\S]{0,80}?(['"])decision\2[\s\S]{0,120}?(['"])blocker-resolution\3/;
89
+ for (const page of pages) {
90
+ if (page === 'dom-utils.js') continue; // the one filler is allowed to name them
91
+ const src = readFileSync(new URL(page, dir), 'utf8');
92
+ assert.doesNotMatch(src, SPELLED,
93
+ `${page} spells out the kind vocabulary — fill it from /me task_kinds via OTB.fillKindFilter instead`);
94
+ }
95
+ });