@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.
- package/.bongos-core.json +56 -31
- package/docs/adr/0301-a-red-pr-names-its-failing-tests-in-a-commit-status.md +66 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/check-rollup.js +42 -0
- package/modules/lifecycle/github-push.js +11 -9
- package/modules/lifecycle/routes/tasks.js +22 -0
- package/modules/security/artifact-scan/quarantine.js +120 -4
- package/modules/security/artifact-scan/sanitize.js +6 -2
- package/modules/security/tests/mirror.mjs +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +13 -3
- package/scripts/gds/migration-namespace.js +101 -0
- package/scripts/gds/push-path-brief.js +4 -1
- package/scripts/gds/run-unit-tests.js +21 -1
- package/scripts/gds/ship-land.js +26 -0
- package/src/module-api.js +1 -1
- package/tests/artifact_staleness.mjs +6 -1
- package/tests/github_push_merge_driver.mjs +60 -0
- package/tests/government_require_permission.mjs +35 -8
- package/tests/migration_namespace.mjs +138 -0
- package/tests/promote_deps_gate.mjs +93 -0
- package/tests/push_path_brief.mjs +3 -0
- package/tests/quarantine_canonical.mjs +245 -0
- package/tests/search_index_poller.mjs +6 -1
- package/tests/search_isolation.mjs +16 -1
- package/tests/unit_report_status.mjs +101 -0
|
@@ -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
|
-
|
|
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(
|
|
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;
|