@bongos/core 1.19.832 → 1.19.834

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.
@@ -8,6 +8,8 @@
8
8
  // while grandfathering the existing bare files (core ≤ 187; game/demo modules).
9
9
  // 3. stemCollisions catches the real bug: two sources sharing a schema_migrations stem.
10
10
  // 4. nextInNamespace / lineageReport count both forms so an allocator can't reuse a number.
11
+ // 5. numberCollisions catches the SAME-SERIES race stemCollisions is blind to: two
12
+ // different stems on one number, which parallel claims produce (task 1003646).
11
13
  //
12
14
  // No fs/git — the helpers are pure. Run: node tests/migration_namespace.mjs
13
15
 
@@ -150,6 +152,142 @@ t('stemCollisions: namespacing REMOVES the collision', () => {
150
152
  assert.deepEqual(ns.stemCollisions(files), []);
151
153
  });
152
154
 
155
+ // ---- numberCollisions (task 1003646) ---------------------------------------
156
+ // The SAME-SERIES hazard, which stemCollisions is blind to by design: two different
157
+ // stems at one number inside one series. Nothing is skipped — but migrate.sh applies in
158
+ // BASENAME order, so the pair's order is the alphabetical accident of its slug, and a
159
+ // from-scratch replay can order them differently from the merge order prod applied.
160
+ t('numberCollisions: none when each number is used once in its series', () => {
161
+ const files = [
162
+ { source: 'core', basename: 'core_188_a.sql' },
163
+ { source: 'core', basename: 'core_189_b.sql' },
164
+ { source: 'module', moduleKey: 'economy', basename: 'economy_001_x.sql' },
165
+ ];
166
+ assert.deepEqual(ns.numberCollisions(files), []);
167
+ });
168
+
169
+ t('numberCollisions: the SAME number in DIFFERENT series is not a collision', () => {
170
+ // The deliberate tolerance lineageReport documents: separate allocators, separate
171
+ // namespaces, distinct stems. core_012 and economy_012 are unrelated.
172
+ const files = [
173
+ { source: 'core', basename: 'core_012_a.sql' },
174
+ { source: 'instance', basename: '012_b.sql' },
175
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_c.sql' },
176
+ { source: 'module', moduleKey: 'ideas', basename: 'ideas_012_d.sql' },
177
+ ];
178
+ assert.deepEqual(ns.numberCollisions(files), []);
179
+ });
180
+
181
+ t('numberCollisions: catches two stems at one number in ONE series (the 1003646 bug)', () => {
182
+ const files = [
183
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
184
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
185
+ ];
186
+ const c = ns.numberCollisions(files);
187
+ assert.equal(c.length, 1);
188
+ assert.equal(c[0].series, 'module:economy/economy');
189
+ assert.equal(c[0].number, 12);
190
+ assert.deepEqual(c[0].files, ['economy_012_alpha.sql', 'economy_012_beta.sql']);
191
+ });
192
+
193
+ t('numberCollisions: stemCollisions is blind to it — the two detectors are not redundant', () => {
194
+ // If this ever starts failing because stemCollisions caught it, one of the two is
195
+ // doing the other's job and the pair should be revisited.
196
+ const files = [
197
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
198
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
199
+ ];
200
+ assert.deepEqual(ns.stemCollisions(files), [], 'different stems — not a stem collision');
201
+ assert.equal(ns.numberCollisions(files).length, 1, 'but it IS a number collision');
202
+ });
203
+
204
+ t('numberCollisions: core and instance series are checked too, not just modules', () => {
205
+ const files = [
206
+ { source: 'core', basename: 'core_190_a.sql' },
207
+ { source: 'core', basename: 'core_190_b.sql' },
208
+ { source: 'instance', basename: '042_x.sql' },
209
+ { source: 'instance', basename: '042_y.sql' },
210
+ ];
211
+ const c = ns.numberCollisions(files);
212
+ assert.equal(c.length, 2);
213
+ assert.deepEqual(c.map((x) => x.series), ['core/core', 'instance/(bare)']);
214
+ });
215
+
216
+ t('numberCollisions: a RENAMED module\'s two prefixes are two series, not a collision', () => {
217
+ // modules/government/ really holds governance_001…007 beside government_001…007: the
218
+ // key was renamed (RENAMED_MODULE_PREFIXES, ADR 0174) and the new prefix restarted at
219
+ // 001, while the applied files keep the old one because the stem is the
220
+ // schema_migrations key. Keying a series by DIRECTORY reported all seven as collisions
221
+ // — the check would have been wrong seven times and right once. It is keyed by PREFIX
222
+ // for exactly this reason, and the prefixes sort into separate contiguous blocks
223
+ // (governance_ < government_), so their order is fixed on prod and on replay alike.
224
+ const files = [
225
+ { source: 'module', moduleKey: 'government', basename: 'governance_001_tables.sql' },
226
+ { source: 'module', moduleKey: 'government', basename: 'government_001_rename_from_governance.sql' },
227
+ { source: 'module', moduleKey: 'government', basename: 'governance_007_delegate_archon_ops_to_metic.sql' },
228
+ { source: 'module', moduleKey: 'government', basename: 'government_007_amendments.sql' },
229
+ ];
230
+ assert.deepEqual(ns.numberCollisions(files), []);
231
+ });
232
+
233
+ t('numberCollisions: two LEGACY BARE modules numbering from 001 do not collide', () => {
234
+ // game and demo both predate the <key>_ convention, so both hold a bare 001_. They are
235
+ // different directories and different allocators — moduleKey keeps them apart.
236
+ const files = [
237
+ { source: 'module', moduleKey: 'game', basename: '001_initial.sql' },
238
+ { source: 'module', moduleKey: 'demo', basename: '001_demo_pings.sql' },
239
+ ];
240
+ assert.deepEqual(ns.numberCollisions(files), []);
241
+ });
242
+
243
+ t('numberCollisions: the SAME file listed twice is not a collision', () => {
244
+ // Keyed by stem, so a double-scan of one directory cannot manufacture a violation.
245
+ const files = [
246
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
247
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
248
+ ];
249
+ assert.deepEqual(ns.numberCollisions(files), []);
250
+ });
251
+
252
+ t('numberCollisions: the one pre-existing collision is grandfathered', () => {
253
+ // platform_identity_012 x3 is applied on prod. Renaming an applied migration changes
254
+ // its schema_migrations stem, so migrate.sh would RE-RUN it — the same reason
255
+ // LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES exist. It is exempt BY NAME, so a
256
+ // fourth file at that number still fails.
257
+ const three = ['account_privacy', 'project_template', 'recent_ships'].map((s) => ({
258
+ source: 'module', moduleKey: 'platform-identity', basename: `platform_identity_012_${s}.sql`,
259
+ }));
260
+ assert.deepEqual(ns.numberCollisions(three), [], 'the known trio is exempt');
261
+
262
+ // The exemption names STEMS, not the number, so 012 stays CLOSED. A fourth file
263
+ // landing there is the bug this check exists for and must still fail — a bare
264
+ // series/number exemption would have waved it through.
265
+ const four = [...three, {
266
+ source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_012_new.sql',
267
+ }];
268
+ const c4 = ns.numberCollisions(four);
269
+ assert.equal(c4.length, 1, 'a NEW fourth file at the grandfathered number still fails');
270
+ assert.ok(c4[0].files.includes('platform_identity_012_new.sql'),
271
+ 'and the new file is named in the violation');
272
+
273
+ // …but a DIFFERENT number in the same module is not exempt — the grandfather is not
274
+ // a blanket pass for platform-identity.
275
+ const other = [
276
+ { source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_a.sql' },
277
+ { source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_b.sql' },
278
+ ];
279
+ assert.equal(ns.numberCollisions(other).length, 1, '013 in the same module still fails');
280
+ });
281
+
282
+ t('numberCollisions: non-migration filenames are ignored', () => {
283
+ const files = [
284
+ { source: 'module', moduleKey: 'economy', basename: 'README.md' },
285
+ { source: 'module', moduleKey: 'economy', basename: '.keep' },
286
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
287
+ ];
288
+ assert.deepEqual(ns.numberCollisions(files), []);
289
+ });
290
+
153
291
  // ---- nextInNamespace / lineageReport ---------------------------------------
154
292
  t('nextInNamespace: counts bare AND prefixed forms (no number reuse)', () => {
155
293
  assert.equal(ns.nextInNamespace(['003_a.sql', 'core_188_b.sql', 'core_190_c.sql']), 191,
@@ -0,0 +1,93 @@
1
+ // tests/promote_deps_gate.mjs — promote must not advertise dependency-blocked work
2
+ // (task 1003553).
3
+ //
4
+ // POST /tasks/:id/promote flipped backlog -> ready with a bare updateTaskStatus and NO
5
+ // dependency check. Only the CLAIM refused, with DEPS_NOT_SHIPPED — so a manual promote
6
+ // put blocked work into the claimable feed and a builder discovered it by trying to take
7
+ // it. The feed lied.
8
+ //
9
+ // claimTask's own comment already named this as the reason it re-checks: "the
10
+ // auto-promotion triggers keep backlog tasks gated, but a manually-promoted task could
11
+ // end up 'ready' with pending deps — this is the second line of defence." The first line
12
+ // is what was missing.
13
+ //
14
+ // These are WIRING assertions on the source. `db` is module-scoped in routes/tasks.js, so
15
+ // a behavioural test needs a real Postgres and belongs in the integration lane; what can
16
+ // go wrong here without one is the ORDER and the SURVIVAL of the claim-time check, and
17
+ // both are checked below rather than assumed.
18
+ //
19
+ // Run: node tests/promote_deps_gate.mjs
20
+ //
21
+ // NOTE: makeRunner's test() returns a PROMISE, so every call must be awaited. Without
22
+ // the await, summary() runs before any of them and the file reports '0 passed, 0 failed'
23
+ // and EXITS 0 - a suite that looks green while asserting nothing.
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { readFileSync } from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { makeRunner } from './helpers.mjs';
30
+
31
+ const { test, summary } = makeRunner();
32
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const read = (rel) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
34
+
35
+ const ROUTES = read('modules/lifecycle/routes/tasks.js');
36
+ // The promote handler only — so a dependency check somewhere else in this 1,400-line
37
+ // file cannot make these pass.
38
+ const PROMOTE = (() => {
39
+ const start = ROUTES.indexOf("router.post('/tasks/:id/promote'");
40
+ assert.ok(start > 0, 'the promote route must still exist');
41
+ const end = ROUTES.indexOf("router.post('/tasks/:id/demote'", start);
42
+ assert.ok(end > start, 'expected /demote to follow /promote — the slice bound moved');
43
+ return ROUTES.slice(start, end);
44
+ })();
45
+
46
+ await test('promote asks whether the dependencies are done', () => {
47
+ assert.match(PROMOTE, /db\.unsatisfiedDeps\(/,
48
+ 'promote must consult the dependency gate before making a task claimable');
49
+ });
50
+
51
+ await test('it refuses with the SAME shape the claim path uses', () => {
52
+ assert.match(PROMOTE, /deps_not_shipped/);
53
+ assert.match(PROMOTE, /depRefusalRow/,
54
+ 'the refusal rows come from dead-deps.js, so promote and claim describe a blocked task identically');
55
+ assert.match(PROMOTE, /409/, 'a blocked promote is a conflict, not a 400');
56
+ });
57
+
58
+ await test('the check runs BEFORE the status flip — the whole point', () => {
59
+ const gate = PROMOTE.indexOf('db.unsatisfiedDeps(');
60
+ const flip = PROMOTE.indexOf("updateTaskStatus(id, 'ready')");
61
+ assert.ok(gate > 0 && flip > 0, 'both the gate and the flip must be present');
62
+ assert.ok(gate < flip,
63
+ 'checking dependencies after flipping to ready would advertise the task first and refuse second');
64
+ });
65
+
66
+ await test('the abandoned -> backlog restore is NOT gated', () => {
67
+ // A restore lands in backlog, which is pre-workable: nothing can claim it there, so a
68
+ // dependency gate would refuse an undo for no benefit. The restore branch returns
69
+ // before the gate.
70
+ const restore = PROMOTE.indexOf('restoreTask(');
71
+ const gate = PROMOTE.indexOf('db.unsatisfiedDeps(');
72
+ assert.ok(restore > 0 && gate > restore,
73
+ 'the restore branch must return before the dependency gate is reached');
74
+ });
75
+
76
+ await test('the CLAIM-time check still exists — this adds a line of defence, it does not move one', () => {
77
+ // Deleting the claim check "because promote now gates" would be the tempting
78
+ // simplification and a real regression: a task can acquire a dependency after it is
79
+ // promoted, and ADR 0015 puts the gate on the claim.
80
+ const claims = read('modules/lifecycle/db-claims.js');
81
+ assert.match(claims, /unsatisfiedDeps\(/, 'the claim path keeps its own gate');
82
+ assert.match(claims, /DEPS_NOT_SHIPPED/);
83
+ });
84
+
85
+ await test('promote and claim resolve deps through ONE function, not two notions of blocked', () => {
86
+ // If these ever diverge, a task can be promotable and unclaimable at the same time —
87
+ // which is the bug this task fixed, inverted.
88
+ const deps = read('modules/lifecycle/db-deps-criteria.js');
89
+ assert.match(deps, /async function unsatisfiedDeps\(/,
90
+ 'the shared resolver must still live in one place');
91
+ });
92
+
93
+ summary();
@@ -38,6 +38,9 @@ t('server path gets the brief, with every load-bearing phrase', () => {
38
38
  '/builder-ship',
39
39
  'NEVER ask the owner for a PAT',
40
40
  'publish-status',
41
+ // task 1003988: a box that cannot see which test failed re-pushes blind.
42
+ 'merge_driver.failing_tests',
43
+ 'checks_unreadable',
41
44
  ]) {
42
45
  assert.ok(l.includes(must), `line must state: ${must}`);
43
46
  }
@@ -0,0 +1,245 @@
1
+ // tests/quarantine_canonical.mjs — containment compares WHERE a path points, not how it
2
+ // is spelled (task 1003848).
3
+ //
4
+ // Windows hands out 8.3 short names: os.tmpdir() here is C:\Users\GMK1_P~1\AppData\Local\Temp
5
+ // while the same directory reports as C:\Users\GMK1_ppl51\... elsewhere, and path.resolve
6
+ // does not reconcile them. fs.realpathSync.native expands 8.3; plain fs.realpathSync does NOT.
7
+ //
8
+ // This is not only a test bug. siteRefusal() asks isInside(REPO_ROOT, site) to refuse a
9
+ // quarantine site inside the repository — so on the one platform that hands out two
10
+ // spellings, that refusal was evadable by spelling.
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const q = require('../modules/security/artifact-scan/quarantine.js');
21
+
22
+ const SHORT = os.tmpdir();
23
+ const LONG = fs.realpathSync.native(SHORT);
24
+
25
+ test('canonical expands an 8.3 short name — plain realpathSync does not', () => {
26
+ assert.equal(q.canonical(SHORT), LONG);
27
+ // The premise, stated so this test explains itself on a machine where it does not apply:
28
+ // if the two spellings are identical here, the rest is trivially true rather than wrong.
29
+ if (SHORT === LONG) return;
30
+ assert.notEqual(fs.realpathSync(SHORT), LONG, 'this is the fact the obvious fix gets wrong');
31
+ });
32
+
33
+ test('the two spellings of ONE directory contain each other', () => {
34
+ assert.equal(q.isInside(SHORT, path.join(LONG, 'x')), true);
35
+ assert.equal(q.isInside(LONG, path.join(SHORT, 'x')), true);
36
+ });
37
+
38
+ test('a genuinely outside path is still outside — the fix narrows nothing', () => {
39
+ assert.equal(q.isInside(LONG, path.resolve('/definitely/elsewhere')), false);
40
+ // and a sibling whose name merely starts with the root is not inside it: this is what
41
+ // a String.startsWith containment check gets wrong, which is how security/mirror failed.
42
+ assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
43
+ });
44
+
45
+ test('a path that does not exist yet still canonicalises via its nearest real ancestor', () => {
46
+ // The ordinary case: a derived destination is validated BEFORE it is written.
47
+ const notYet = path.join(SHORT, 'no-such-dir-1003848', 'deep', 'leaf.txt');
48
+ assert.equal(q.canonical(notYet), path.join(LONG, 'no-such-dir-1003848', 'deep', 'leaf.txt'));
49
+ assert.equal(q.isInside(SHORT, notYet), true);
50
+ });
51
+
52
+ test('a fully nonexistent absolute path degrades to the lexical answer rather than throwing', () => {
53
+ const nowhere = path.resolve('/no-such-root-1003848/a/b');
54
+ assert.equal(typeof q.canonical(nowhere), 'string');
55
+ assert.equal(q.isInside(path.resolve('/no-such-root-1003848'), nowhere), true);
56
+ });
57
+
58
+ test('canonical is MEMOISED for paths that EXIST — the hot-loop case', () => {
59
+ // mirror.js's header is explicit about staying cheap; an unmemoised realpath here would
60
+ // put ~20,000 sync syscalls on its 5,000-file loop.
61
+ //
62
+ // BOTH OPERANDS MUST EXIST. Only an EXACT resolution is cacheable (see canonical()):
63
+ // an answer reached by walking up to the nearest existing ancestor is deliberately
64
+ // NOT stored, because the leaf can still be created as a link that escapes. So a
65
+ // not-yet-existing path re-reads every call BY DESIGN, and asserting memoisation
66
+ // against one asserts the opposite of the rule.
67
+ //
68
+ // The first version of this test used <tmpdir>/x and passed only because a stray
69
+ // file of that name happened to exist on the author's box; on CI it did not, the
70
+ // walk-up path was taken, and 50 iterations cost ~100 syscalls instead of 2.
71
+ const dir = fs.mkdtempSync(path.join(LONG, 'memo-'));
72
+ try {
73
+ q.clearCanonicalCache();
74
+ const orig = fs.realpathSync.native;
75
+ let calls = 0;
76
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
77
+ try {
78
+ for (let i = 0; i < 50; i += 1) q.isInside(SHORT, dir);
79
+ } finally {
80
+ fs.realpathSync.native = orig;
81
+ }
82
+ assert.ok(calls <= 4, `50 containment checks over existing paths should not cost ${calls} realpath syscalls`);
83
+ } finally {
84
+ fs.rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test('a path that does NOT exist is re-read every time — the cache must not answer for it', () => {
89
+ // The companion rule, and a security property rather than a performance one: a
90
+ // walked-up answer is provisional. Cache it and creating the leaf as an escaping
91
+ // link would keep serving "inside" forever. Pinning it here stops a future
92
+ // optimisation from caching provisional answers to make the test above cheaper.
93
+ const notYet = path.join(LONG, 'no-such-memo-1003848', 'leaf');
94
+ q.clearCanonicalCache();
95
+ const orig = fs.realpathSync.native;
96
+ let calls = 0;
97
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
98
+ try {
99
+ q.canonical(notYet);
100
+ q.canonical(notYet);
101
+ } finally {
102
+ fs.realpathSync.native = orig;
103
+ }
104
+ assert.ok(calls >= 4, `two lookups of a non-existent path must both hit the filesystem, saw ${calls}`);
105
+ });
106
+
107
+ test('a path that does NOT exist yet is never cached — the escape must stay visible', () => {
108
+ // THE SECURITY PROPERTY, and the one the first pass of this fix got wrong.
109
+ //
110
+ // A derived destination is canonicalised BEFORE it is written, so its answer comes from
111
+ // walking up to the nearest existing ancestor. That answer is true only until the path
112
+ // is written. Cache it and the window never closes: create the path as a symlink (a
113
+ // junction on Windows) pointing out of the pen and isInside() keeps returning the
114
+ // pre-creation verdict — INSIDE — so mirror.js's path_escape refusal is silently lost.
115
+ // The containment check this file exists to harden is then evadable one layer down, by
116
+ // its own memoisation.
117
+ //
118
+ // Asserted as the MECHANISM rather than by building a symlink, deliberately: creating
119
+ // one needs privileges this suite does not have on Windows, so a symlink-shaped test
120
+ // would skip on the very platform whose 8.3 spellings motivated the whole change. The
121
+ // mechanism is platform-free — a missing path must cost a fresh filesystem read every
122
+ // time — and modules/security/tests/mirror.mjs covers the real symlink end-to-end
123
+ // wherever symlinks can be made.
124
+ q.clearCanonicalCache();
125
+ const missing = path.join(SHORT, 'not-written-yet-1003848', 'leaf.txt');
126
+
127
+ const orig = fs.realpathSync.native;
128
+ let calls = 0;
129
+ fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
130
+ try {
131
+ q.canonical(missing);
132
+ const afterFirst = calls;
133
+ q.canonical(missing);
134
+ assert.ok(calls > afterFirst,
135
+ 'a non-existent path must be re-read, not served from cache — otherwise a symlink '
136
+ + 'created there afterwards keeps the stale INSIDE verdict');
137
+ } finally {
138
+ fs.realpathSync.native = orig;
139
+ }
140
+ });
141
+
142
+ test('an EXISTING directory is still cached — the fix keeps the memoisation that mattered', () => {
143
+ // The counterweight to the test above: mirror.js's hot loop canonicalises real
144
+ // directories over and over, and those answers are stable (a directory cannot move
145
+ // under a running scan). Only the provisional ones are re-read.
146
+ q.clearCanonicalCache();
147
+ q.canonical(SHORT);
148
+ const orig = fs.realpathSync.native;
149
+ let calls = 0;
150
+ fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
151
+ try {
152
+ for (let i = 0; i < 20; i += 1) q.canonical(SHORT);
153
+ } finally {
154
+ fs.realpathSync.native = orig;
155
+ }
156
+ assert.equal(calls, 0, 'an existing directory resolved once must not be resolved again');
157
+ });
158
+
159
+ test('clearing the cache makes it read the filesystem again', () => {
160
+ q.clearCanonicalCache();
161
+ const orig = fs.realpathSync.native;
162
+ let calls = 0;
163
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
164
+ try { q.canonical(SHORT); } finally { fs.realpathSync.native = orig; }
165
+ assert.ok(calls >= 1, 'a cleared cache must not keep answering from memory');
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // LEXICAL vs PHYSICAL containment (task 1003848, grade round 2).
170
+ //
171
+ // sanitize.js runs three confinement arms in order, each with its own refusal:
172
+ // lexical (does the manifest path climb out?), then physical (does it RESOLVE
173
+ // out?), then lstat (is it a link the manifest called a file?). Canonicalising
174
+ // the lexical arm made it follow the link and fire first, pre-empting the other
175
+ // two — the artifact was still refused, but with the wrong diagnosis, and the two
176
+ // tests pinning those messages went red.
177
+ //
178
+ // The existing cases live in modules/security/tests/sanitize_rebuild.mjs and are
179
+ // SKIPPED wherever symlinks cannot be created — which is this platform, so that
180
+ // regression was invisible locally and only CI saw it. This case therefore falls
181
+ // back to a DIRECTORY JUNCTION, which Windows allows without elevation, so the
182
+ // divergence is pinned on both platforms.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const linkKind = (() => {
186
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'linkprobe-'));
187
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'linktarget-'));
188
+ try {
189
+ for (const kind of ['dir', 'junction']) {
190
+ try { fs.symlinkSync(target, path.join(probe, `l-${kind}`), kind); return kind; }
191
+ catch { /* try the next form */ }
192
+ }
193
+ return null;
194
+ } finally {
195
+ fs.rmSync(probe, { recursive: true, force: true });
196
+ fs.rmSync(target, { recursive: true, force: true });
197
+ }
198
+ })();
199
+
200
+ test('a link that escapes is LEXICALLY inside but PHYSICALLY outside', { skip: linkKind ? false : 'this platform cannot create links' }, () => {
201
+ const pen = fs.mkdtempSync(path.join(os.tmpdir(), 'pen-'));
202
+ const pristine = path.join(pen, 'pristine');
203
+ fs.mkdirSync(pristine);
204
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
205
+ try {
206
+ const link = path.join(pristine, 'escape');
207
+ fs.symlinkSync(outside, link, linkKind);
208
+ q.clearCanonicalCache();
209
+
210
+ // The lexical arm must NOT fire: nothing in the SPELLING of this path climbs
211
+ // out. If it fired, sanitize.js would report "escapes the pristine tree" and
212
+ // never reach the two refusals that describe what is actually wrong.
213
+ assert.equal(q.isInsideLexical(pristine, link), true);
214
+
215
+ // The physical arm is what catches it — this is the containment that matters.
216
+ assert.equal(q.isInside(pristine, link), false);
217
+ } finally {
218
+ fs.rmSync(pen, { recursive: true, force: true });
219
+ fs.rmSync(outside, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ test('on ordinary paths the two predicates agree — the split narrows nothing', () => {
224
+ const inside = path.join(LONG, 'a', 'b');
225
+ assert.equal(q.isInsideLexical(LONG, inside), q.isInside(LONG, inside));
226
+ assert.equal(q.isInsideLexical(LONG, inside), true);
227
+ const climbs = path.join(LONG, '..', 'elsewhere');
228
+ assert.equal(q.isInsideLexical(LONG, climbs), false);
229
+ assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
230
+ assert.equal(q.isInsideLexical(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
231
+ });
232
+
233
+ test('the lexical predicate never touches the filesystem', () => {
234
+ // The whole point: its answer must not depend on what the leaf points at right
235
+ // now. A realpath call here would reintroduce exactly the coupling being removed.
236
+ const orig = fs.realpathSync.native;
237
+ let calls = 0;
238
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
239
+ try {
240
+ q.isInsideLexical(LONG, path.join(LONG, 'whatever', 'deep.txt'));
241
+ } finally {
242
+ fs.realpathSync.native = orig;
243
+ }
244
+ assert.equal(calls, 0, 'isInsideLexical must be pure path arithmetic');
245
+ });
@@ -13,6 +13,7 @@
13
13
  // Run: node tests/search_index_poller.mjs
14
14
 
15
15
  import { strict as assert } from 'node:assert';
16
+ import path from 'node:path';
16
17
  import { createRequire } from 'node:module';
17
18
 
18
19
  const require = createRequire(import.meta.url);
@@ -104,7 +105,11 @@ function fakePool(seq) {
104
105
  await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
105
106
  const called = [];
106
107
  const exec = (_p, args, _o, cb) => {
107
- called.push(args[0].split('/').pop());
108
+ // path.basename, not split('/') — the sweep passes an ABSOLUTE path, and on
109
+ // Windows that is backslash-separated, so splitting on a forward slash returned
110
+ // the whole path and this assertion compared a path against a basename (task
111
+ // 1003848). The production code was never wrong; the test helper was.
112
+ called.push(path.basename(args[0]));
108
113
  cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
109
114
  };
110
115
  const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
@@ -161,6 +161,14 @@ const client = await dbReachable();
161
161
  if (!client) {
162
162
  console.log(' SKIP no reachable Postgres — Layer C runs in the integration gate / on a dev box.');
163
163
  console.log(' (The same scoping cases were proven on the prod DB in a rolled-back transaction at ship time.)');
164
+ // END THE POOL ON THE SKIP PATH TOO (task 1003848). Only the else-branch below
165
+ // ended it, so when Postgres was unreachable the Pool survived with libuv handles
166
+ // still closing from its failed connect — and the process.exit() at the bottom of
167
+ // this file then tore down the loop underneath them. On Windows that surfaces as
168
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`, exit
169
+ // 0xC0000409: a process-teardown crash AFTER '5 passed, 0 failed', which reads as
170
+ // a failing suite in the unit gate while every assertion actually passed.
171
+ if (pool) await pool.end().catch(() => {});
164
172
  } else {
165
173
  try {
166
174
  await client.query('BEGIN');
@@ -204,4 +212,11 @@ if (!client) {
204
212
  }
205
213
 
206
214
  console.log(`\n${passed} passed, ${failed} failed`);
207
- process.exit(failed === 0 ? 0 : 1);
215
+ // EXIT CODE, NOT process.exit() (task 1003848). A hard exit tears the event loop down
216
+ // underneath any handle that is still closing — and pg leaves one behind after a
217
+ // FAILED connect, which is exactly the skip path. On Windows that surfaced as
218
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`
219
+ // and exit 0xC0000409 AFTER "5 passed, 0 failed" — a process-teardown crash that the
220
+ // unit gate can only read as a failing suite. Setting exitCode lets node drain the
221
+ // handles and then exit with the same status.
222
+ process.exitCode = failed === 0 ? 0 : 1;
@@ -0,0 +1,101 @@
1
+ // tests/unit_report_status.mjs — the `unit-report` commit status (task 1003988).
2
+ //
3
+ // WHY. The Cloud Bongos App cannot read check runs without Checks:read, so a
4
+ // builder box saw a red PR and never WHICH test failed. A commit status IS
5
+ // readable, so the unit workflow reports the failing tests as one. Two halves
6
+ // have to agree for that to work — what the runner writes and what the workflow
7
+ // posts — and the context name is shared with the server that reads it back.
8
+ //
9
+ // Run: node tests/unit_report_status.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import fs from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import { describeFailures, STATUS_DESCRIPTION_MAX } from '../scripts/gds/run-unit-tests.js';
15
+ import { redPrLine } from '../scripts/gds/ship-land.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const gh = require('../modules/lifecycle/github-push.js');
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ function test(name, fn) {
23
+ try { fn(); passed++; console.log(` ok ${name}`); }
24
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
25
+ }
26
+
27
+ const workflow = fs
28
+ .readFileSync(new URL('../.github/workflows/unit.yml', import.meta.url), 'utf8')
29
+ .replace(/\r\n/g, '\n');
30
+ const reportJob = workflow.slice(workflow.indexOf('\n report:'));
31
+
32
+ test('describeFailures is empty when nothing failed', () => {
33
+ assert.equal(describeFailures([]), '');
34
+ });
35
+
36
+ test('describeFailures counts and names the failing tests', () => {
37
+ assert.equal(describeFailures(['security']), '1 failed: security');
38
+ assert.equal(describeFailures(['quarantine_canonical', 'security']), '2 failed: quarantine_canonical, security');
39
+ });
40
+
41
+ test('describeFailures fits the GitHub status limit and keeps the true count', () => {
42
+ const many = Array.from({ length: 40 }, (_, i) => `some_long_test_name_${i}`);
43
+ const d = describeFailures(many);
44
+ assert.ok(d.length <= STATUS_DESCRIPTION_MAX, `${d.length} chars`);
45
+ assert.ok(d.startsWith('40 failed: some_long_test_name_0'), d);
46
+ assert.ok(d.endsWith('…'), 'a cut list says it was cut');
47
+ });
48
+
49
+ test('describeFailures is one line — a test name cannot forge a second output key', () => {
50
+ assert.ok(!/[\r\n]/.test(describeFailures(['evil\nfailures=spoofed'])));
51
+ });
52
+
53
+ test('the unit job itself stays read-only; only the report job may write statuses', () => {
54
+ const unitJob = workflow.slice(0, workflow.indexOf('\n report:'));
55
+ assert.ok(workflow.includes('\n report:'), 'report job exists');
56
+ assert.ok(!/statuses:\s*write/.test(unitJob), 'PR-controlled test code never runs beside a status-writing token');
57
+ assert.match(reportJob, /permissions:\n\s+statuses: write/);
58
+ assert.ok(!/uses:\s*actions\/checkout/.test(reportJob), 'the report job checks out no PR code');
59
+ });
60
+
61
+ test('the report job posts the context the server reads back', () => {
62
+ assert.ok(reportJob.includes(`context=${gh.UNIT_REPORT_CONTEXT}`), gh.UNIT_REPORT_CONTEXT);
63
+ assert.notEqual(gh.UNIT_REPORT_CONTEXT, 'unit', 'must never shadow the required `unit` check');
64
+ });
65
+
66
+ test('the status lands on the PR head sha, not the throwaway merge commit', () => {
67
+ assert.ok(reportJob.includes('github.event.pull_request.head.sha || github.sha'));
68
+ });
69
+
70
+ test('builder-controlled text reaches the script through env, never inline interpolation', () => {
71
+ const run = reportJob.slice(reportJob.indexOf('run: |'));
72
+ assert.ok(!run.includes('${{'), 'no ${{ }} inside the run script');
73
+ });
74
+
75
+ test('a cancelled run reports nothing, and a failed post never reds the job', () => {
76
+ assert.match(reportJob, /if: \$\{\{ !cancelled\(\) \}\}/);
77
+ assert.match(reportJob, /\|\| echo "::warning::/);
78
+ });
79
+
80
+ test('redPrLine names the failing tests when the server could read them', () => {
81
+ const l = redPrLine({ reason: 'failing_check:unit-report', failing_tests: '1 failed: security' });
82
+ assert.ok(l.includes('1 failed: security'), l);
83
+ assert.ok(l.includes('node tests/'), 'says how to reproduce it');
84
+ });
85
+
86
+ test('redPrLine says the list is blind, and that a retry cannot help', () => {
87
+ const l = redPrLine({ reason: 'failing_check_unreadable', checks_unreadable: true, failing_tests: null });
88
+ assert.match(l, /cannot see which check failed/);
89
+ assert.match(l, /Checks: read/);
90
+ assert.match(l, /npm test/);
91
+ });
92
+
93
+ test('redPrLine falls back to the check name, and is silent when nothing is red', () => {
94
+ assert.ok(redPrLine({ reason: 'failing_check:unit' }).includes('`unit`'));
95
+ for (const quiet of [null, {}, { reason: 'review_required' }, { reason: 'blocked_required_review_or_pending_check' }]) {
96
+ assert.equal(redPrLine(quiet), null);
97
+ }
98
+ });
99
+
100
+ console.log(`\nunit-report status (task 1003988) tests: ${passed} passed, ${failed} failed`);
101
+ process.exitCode = failed > 0 ? 1 : 0;