@bongos/core 1.21.125 → 1.21.127
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 +106 -51
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-error-report.js +148 -0
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/scripts/gds/module.js +1 -0
- package/scripts/gds/provision-caps.js +63 -0
- package/scripts/gds/provision-process.js +3 -0
- package/scripts/gds/provision-units.js +3 -0
- package/scripts/gds/provision.js +3 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/error-tally.js +188 -0
- package/src/module-loader/loader.js +52 -4
- package/src/platform-server.js +7 -0
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_error_counts.mjs +265 -0
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provision.mjs +2 -0
- package/tests/provision_resource_caps.mjs +117 -0
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -18,6 +18,9 @@ const { composeScore, recomposeScore } = require('../scripts/gds/module-assess-s
|
|
|
18
18
|
const { assessVersion, queueAssessment, QUEUE_MAX } = require('../scripts/gds/module-assess-version.js');
|
|
19
19
|
|
|
20
20
|
const sig = (id, part, outcome, score = null) => ({ id, part, outcome, score });
|
|
21
|
+
// Every assessVersion call here stubs the test-runner dispatch, so a shell with
|
|
22
|
+
// MODULE_TEST_REPO/MODULE_TEST_TOKEN set can never make this suite reach GitHub.
|
|
23
|
+
const noDispatch = async () => null;
|
|
21
24
|
|
|
22
25
|
// ---------------------------------------------------------------------------
|
|
23
26
|
// composeScore — the rule, pure
|
|
@@ -98,7 +101,7 @@ function fakeDb({ versions = [{ id: 7, module_key: 'weather', version: '1.0.0' }
|
|
|
98
101
|
const db = {
|
|
99
102
|
signals, scores,
|
|
100
103
|
async query(sql, params = []) {
|
|
101
|
-
if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
|
|
104
|
+
if (/^SELECT id, module_key, version(, artifact_path)? FROM store_module_versions WHERE id/.test(sql)) {
|
|
102
105
|
return { rows: versions.filter((v) => String(v.id) === String(params[0])) };
|
|
103
106
|
}
|
|
104
107
|
if (/SELECT DISTINCT ON \(part\)/.test(sql)) {
|
|
@@ -158,7 +161,7 @@ test('assessVersion on publish: Tests pending, the Security gate, Docs, then the
|
|
|
158
161
|
const seen = [];
|
|
159
162
|
const recordSecurity = async (id, { db: d }) => { seen.push(id); const row = d.add(id, 'security', 'passed'); return { ok: true, signal: row }; };
|
|
160
163
|
const recordDocs = async (id, { db: d }) => { seen.push(`docs:${id}`); return { ok: true, signal: d.add(id, 'docs', 'scored', 20) }; };
|
|
161
|
-
const res = await assessVersion(7, { db, recordSecurity, recordDocs });
|
|
164
|
+
const res = await assessVersion(7, { db, dispatchTests: noDispatch, recordSecurity, recordDocs });
|
|
162
165
|
assert.equal(res.ok, true);
|
|
163
166
|
assert.deepEqual(seen, [7, 'docs:7'], 'Security, then Docs');
|
|
164
167
|
assert.equal(res.docs.part, 'docs');
|
|
@@ -173,11 +176,18 @@ test('assessVersion on publish: Tests pending, the Security gate, Docs, then the
|
|
|
173
176
|
test('re-assessing never hides a real Tests score behind a newer "pending"', async () => {
|
|
174
177
|
const db = fakeDb();
|
|
175
178
|
db.add(7, 'tests', 'scored', 85);
|
|
176
|
-
const res = await assessVersion(7, { db, recordSecurity: async (id, { db: d }) => ({ ok: true, signal: d.add(id, 'security', 'passed') }), recordDocs: async () => null });
|
|
179
|
+
const res = await assessVersion(7, { db, dispatchTests: noDispatch, recordSecurity: async (id, { db: d }) => ({ ok: true, signal: d.add(id, 'security', 'passed') }), recordDocs: async () => null });
|
|
177
180
|
assert.equal(db.signals.filter((s) => s.part === 'tests').length, 1);
|
|
178
181
|
assert.equal(res.score.overall, 85);
|
|
179
182
|
});
|
|
180
183
|
|
|
184
|
+
test('assessVersion hands the version to the test-runner dispatch before recording Tests pending', async () => {
|
|
185
|
+
const db = fakeDb();
|
|
186
|
+
const sent = [];
|
|
187
|
+
await assessVersion(7, { db, dispatchTests: async (ver, { db: d }) => { sent.push(ver.id); assert.equal(d, db); return null; }, recordSecurity: async () => null, recordDocs: async () => null });
|
|
188
|
+
assert.deepEqual(sent, [7]);
|
|
189
|
+
});
|
|
190
|
+
|
|
181
191
|
test('assessVersion on an unknown version writes nothing', async () => {
|
|
182
192
|
const db = fakeDb({ versions: [] });
|
|
183
193
|
const res = await assessVersion(99, { db, recordSecurity: async () => { throw new Error('must not run'); } });
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// tests/module_error_counts.mjs — instances count each store module's runtime
|
|
2
|
+
// errors per day and send the daily count with the builder sign-in on the box
|
|
3
|
+
// (task 1004637): the loader's attribution, the on-disk tally, and the sender.
|
|
4
|
+
// Hermetic — temp dirs, a fake store client, an in-process express app.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const loader = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
|
|
17
|
+
const { seed, record, readTally, start, startDailySend, _reset } = require(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'));
|
|
18
|
+
// the in-memory tally is read the way tooling reads it: through the loader's snapshot
|
|
19
|
+
const tally = { seed, record, readTally, start, startDailySend, _reset, snapshot: () => loader.loaderSnapshot().errorCounts };
|
|
20
|
+
const sender = require(path.join(ROOT, 'scripts', 'gds', 'module-error-report.js'));
|
|
21
|
+
const express = require('express');
|
|
22
|
+
|
|
23
|
+
function tmpDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
|
|
24
|
+
|
|
25
|
+
function fixtureRoot(spec) {
|
|
26
|
+
const root = tmpDir('bongos-errmods-');
|
|
27
|
+
for (const [key, def] of Object.entries(spec)) {
|
|
28
|
+
const d = path.join(root, key);
|
|
29
|
+
fs.mkdirSync(d, { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(d, 'module.json'), JSON.stringify({
|
|
31
|
+
key, title: key, description: 'd', version: def.version === undefined ? '1.2.0' : def.version,
|
|
32
|
+
coreVersion: '^1.0.0', contributes: def.contributes || {},
|
|
33
|
+
}));
|
|
34
|
+
for (const [rel, content] of Object.entries(def.files || {})) {
|
|
35
|
+
const fp = path.join(d, rel);
|
|
36
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
37
|
+
fs.writeFileSync(fp, content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return root;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TODAY = new Date().toISOString().slice(0, 10);
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// The tally
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test('seed gives every exactly-versioned module a 0 today; record counts only those', () => {
|
|
50
|
+
tally._reset();
|
|
51
|
+
const n = tally.seed([
|
|
52
|
+
{ key: 'alpha', manifest: { version: '1.2.0' } },
|
|
53
|
+
{ key: 'loose', manifest: { version: '^1.0.0' } },
|
|
54
|
+
{ key: 'none', manifest: {} },
|
|
55
|
+
]);
|
|
56
|
+
assert.equal(n, 1);
|
|
57
|
+
assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 0 }, 'a clean day is a 0, not a missing row');
|
|
58
|
+
assert.equal(tally.record('alpha'), true);
|
|
59
|
+
assert.equal(tally.record('alpha'), true);
|
|
60
|
+
assert.equal(tally.record('loose'), false, 'no exact version, nothing to file it against');
|
|
61
|
+
assert.equal(tally.record('ghost'), false, 'a stray key never invents a module');
|
|
62
|
+
assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 2 });
|
|
63
|
+
tally._reset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('nothing touches disk until start(); start merges the file (higher count wins) and prunes old days', () => {
|
|
67
|
+
tally._reset();
|
|
68
|
+
const dir = tmpDir('bongos-tally-');
|
|
69
|
+
const file = path.join(dir, 'module-error-counts.json');
|
|
70
|
+
tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
|
|
71
|
+
tally.record('alpha');
|
|
72
|
+
assert.equal(fs.existsSync(file), false);
|
|
73
|
+
fs.writeFileSync(file, JSON.stringify({ days: {
|
|
74
|
+
[TODAY]: { 'alpha@1.2.0': 5 },
|
|
75
|
+
'2000-01-01': { 'alpha@1.0.0': 9 },
|
|
76
|
+
} }));
|
|
77
|
+
tally.start({ file });
|
|
78
|
+
const onDisk = tally.readTally(file);
|
|
79
|
+
assert.equal(onDisk[TODAY]['alpha@1.2.0'], 5, 'a restart mid-day keeps the morning count');
|
|
80
|
+
assert.equal(onDisk['2000-01-01'], undefined, 'days past the window are pruned');
|
|
81
|
+
tally._reset();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the tally on disk holds counts only — key@version and a number, nothing else', () => {
|
|
85
|
+
tally._reset();
|
|
86
|
+
const file = path.join(tmpDir('bongos-tally-'), 'c.json');
|
|
87
|
+
tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
|
|
88
|
+
tally.record('alpha');
|
|
89
|
+
tally.start({ file });
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
91
|
+
assert.deepEqual(Object.keys(raw), ['days']);
|
|
92
|
+
for (const row of Object.values(raw.days)) {
|
|
93
|
+
for (const [k, v] of Object.entries(row)) {
|
|
94
|
+
assert.match(k, /^[a-z0-9-]+@\d+\.\d+\.\d+$/);
|
|
95
|
+
assert.ok(Number.isInteger(v));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
tally._reset();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// The loader attributes a 500 to the module that answered it
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async function withServer(app, fn) {
|
|
106
|
+
const server = http.createServer(app);
|
|
107
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
108
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
109
|
+
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('a module route that throws or answers 500 counts against it; a 4xx, a 503 and a fall-through do not', async () => {
|
|
113
|
+
loader._reset();
|
|
114
|
+
const root = fixtureRoot({
|
|
115
|
+
alpha: {
|
|
116
|
+
contributes: { routes: ['main'] },
|
|
117
|
+
files: { 'routes/main.js': `
|
|
118
|
+
const express = require(${JSON.stringify(require.resolve('express'))});
|
|
119
|
+
module.exports = () => {
|
|
120
|
+
const r = express.Router();
|
|
121
|
+
r.get('/alpha/boom', () => { throw new Error('boom'); });
|
|
122
|
+
r.get('/alpha/fail', (req, res) => res.status(500).json({ error: 'x' }));
|
|
123
|
+
r.get('/alpha/bad', (req, res) => res.status(400).json({ error: 'x' }));
|
|
124
|
+
r.get('/alpha/off', (req, res) => res.status(503).json({ error: 'not set up' }));
|
|
125
|
+
r.get('/alpha/pass', (req, res, next) => next(new Error('handed on')));
|
|
126
|
+
return r;
|
|
127
|
+
};` },
|
|
128
|
+
},
|
|
129
|
+
beta: { contributes: {} },
|
|
130
|
+
});
|
|
131
|
+
const app = express();
|
|
132
|
+
const router = express.Router();
|
|
133
|
+
loader.mountModuleRoutes(router, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
|
|
134
|
+
router.get('/kernel/boom', () => { throw new Error('kernel'); });
|
|
135
|
+
app.use(router);
|
|
136
|
+
app.use((err, req, res, next) => res.status(500).json({ error: 'internal' })); // eslint-disable-line no-unused-vars
|
|
137
|
+
|
|
138
|
+
await withServer(app, async (base) => {
|
|
139
|
+
for (const p of ['/alpha/boom', '/alpha/fail', '/alpha/bad', '/alpha/off', '/alpha/pass', '/kernel/boom']) {
|
|
140
|
+
await (await fetch(base + p)).text();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await new Promise((r) => setImmediate(r));
|
|
144
|
+
const day = tally.snapshot()[TODAY];
|
|
145
|
+
assert.equal(day['alpha@1.2.0'], 3, 'boom + fail + next(err) — not the 400, not the 503, not the kernel route');
|
|
146
|
+
assert.equal(day['beta@1.2.0'], 0, 'an enabled module with no routes still files its clean day');
|
|
147
|
+
loader._reset();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the wrapped router still exposes the module’s own routes to tooling', () => {
|
|
151
|
+
loader._reset();
|
|
152
|
+
const root = fixtureRoot({
|
|
153
|
+
alpha: {
|
|
154
|
+
contributes: { routes: ['main'] },
|
|
155
|
+
files: { 'routes/main.js': `
|
|
156
|
+
const express = require(${JSON.stringify(require.resolve('express'))});
|
|
157
|
+
module.exports = () => { const r = express.Router(); r.get('/alpha/x', (q, s) => s.end()); return r; };` },
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const used = [];
|
|
161
|
+
loader.mountModuleRoutes({ use: (r) => used.push(r) }, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
|
|
162
|
+
assert.equal(used.length, 1);
|
|
163
|
+
const paths = (used[0].stack || []).filter((l) => l.route).map((l) => l.route.path);
|
|
164
|
+
assert.deepEqual(paths, ['/alpha/x']);
|
|
165
|
+
loader._reset();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a poller that throws on start counts, and a running poller can report through reportError', async () => {
|
|
169
|
+
loader._reset();
|
|
170
|
+
const root = fixtureRoot({
|
|
171
|
+
broken: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { throw new Error("x"); } };' } },
|
|
172
|
+
rejecting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { return Promise.reject(new Error("x")); } };' } },
|
|
173
|
+
reporting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start(deps) { deps.reportError(); deps.reportError(); } };' } },
|
|
174
|
+
});
|
|
175
|
+
const opts = { roots: [root], core: '1.0.0' };
|
|
176
|
+
loader.mountModuleRoutes({ use: () => {} }, { isEnabled: () => true }, opts);
|
|
177
|
+
const origErr = console.error;
|
|
178
|
+
console.error = () => {};
|
|
179
|
+
try {
|
|
180
|
+
loader.startModulePollers({}, { isEnabled: () => true }, opts);
|
|
181
|
+
await new Promise((r) => setImmediate(r));
|
|
182
|
+
} finally { console.error = origErr; }
|
|
183
|
+
const day = tally.snapshot()[TODAY];
|
|
184
|
+
assert.equal(day['broken@1.2.0'], 1);
|
|
185
|
+
assert.equal(day['rejecting@1.2.0'], 1);
|
|
186
|
+
assert.equal(day['reporting@1.2.0'], 2);
|
|
187
|
+
loader._reset();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// The sender
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function fakeStore({ enabled = true, known = ['alpha'], post = () => ({ ok: true, status: 200 }) } = {}) {
|
|
195
|
+
const calls = [];
|
|
196
|
+
const call = async (method, p, opts = {}) => {
|
|
197
|
+
calls.push({ method, p, json: opts.json });
|
|
198
|
+
if (p === '/store/usage-reporting') return { ok: true, status: 200, data: { enabled } };
|
|
199
|
+
if (p.startsWith('/store/modules/latest')) {
|
|
200
|
+
return { ok: true, status: 200, data: { latest: Object.fromEntries(known.map((k) => [k, { version: '1.2.0' }])) } };
|
|
201
|
+
}
|
|
202
|
+
return post(p, opts.json);
|
|
203
|
+
};
|
|
204
|
+
return { call, calls };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('pendingDays: finished, unsent days inside the store window, oldest first', () => {
|
|
208
|
+
const days = { '2026-10-14': {}, '2026-10-15': {}, '2026-10-12': {}, '2026-09-01': {} };
|
|
209
|
+
assert.deepEqual(sender.pendingDays(days, { '2026-10-12': true }, '2026-10-15'), ['2026-10-14']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('sendPending sends every count for a store module, zeros included, and skips modules the store does not know', async () => {
|
|
213
|
+
const { call, calls } = fakeStore();
|
|
214
|
+
const days = { '2026-10-14': { 'alpha@1.2.0': 0, 'core-thing@3.0.0': 4 } };
|
|
215
|
+
const out = await sender.sendPending({ days, today: '2026-10-15', call });
|
|
216
|
+
const posts = calls.filter((c) => c.method === 'POST');
|
|
217
|
+
assert.equal(posts.length, 1);
|
|
218
|
+
assert.equal(posts[0].p, '/store/modules/alpha/versions/1.2.0/error-count');
|
|
219
|
+
assert.deepEqual(posts[0].json, { day: '2026-10-14', count: 0 }, 'a clean day goes as a 0');
|
|
220
|
+
assert.deepEqual(out.sentDays, ['2026-10-14']);
|
|
221
|
+
assert.equal(out.skipped, 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('an opted-out builder sends nothing, and the days are marked done', async () => {
|
|
225
|
+
const { call, calls } = fakeStore({ enabled: false });
|
|
226
|
+
const out = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 2 } }, today: '2026-10-15', call });
|
|
227
|
+
assert.equal(calls.filter((c) => c.method === 'POST').length, 0);
|
|
228
|
+
assert.equal(out.optedOut, true);
|
|
229
|
+
assert.deepEqual(out.sentDays, ['2026-10-14']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a refusal for good (4xx) is done; a server error leaves the day for the next run', async () => {
|
|
233
|
+
const refused = fakeStore({ post: () => ({ ok: false, status: 403 }) });
|
|
234
|
+
const a = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: refused.call });
|
|
235
|
+
assert.deepEqual(a.sentDays, ['2026-10-14']);
|
|
236
|
+
const down = fakeStore({ post: () => ({ ok: false, status: 502 }) });
|
|
237
|
+
const b = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: down.call });
|
|
238
|
+
assert.deepEqual(b.sentDays, []);
|
|
239
|
+
assert.equal(b.retryLater, true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('main records the days it sent, and a second run sends nothing again', async () => {
|
|
243
|
+
const dir = tmpDir('bongos-sender-');
|
|
244
|
+
const tallyFile = path.join(dir, 'counts.json');
|
|
245
|
+
const sentFile = path.join(dir, 'sent.json');
|
|
246
|
+
fs.writeFileSync(tallyFile, JSON.stringify({ days: { '2026-10-14': { 'alpha@1.2.0': 3 }, '2026-10-15': { 'alpha@1.2.0': 1 } } }));
|
|
247
|
+
const first = fakeStore();
|
|
248
|
+
const log = () => {};
|
|
249
|
+
assert.equal(await sender.main([], { log, request: first.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
|
|
250
|
+
assert.equal(first.calls.filter((c) => c.method === 'POST').length, 1, 'today is not finished, so only yesterday goes');
|
|
251
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(sentFile, 'utf8')).sent, { '2026-10-14': true });
|
|
252
|
+
const second = fakeStore();
|
|
253
|
+
assert.equal(await sender.main([], { log, request: second.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
|
|
254
|
+
assert.equal(second.calls.length, 0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('the server runs the sender as a child process, never in its own process', () => {
|
|
258
|
+
tally._reset();
|
|
259
|
+
const ran = [];
|
|
260
|
+
const timers = tally.startDailySend({ spawnImpl: (s) => ran.push(s) });
|
|
261
|
+
assert.equal(timers.length, 2, 'a first run soon after boot, then every few hours');
|
|
262
|
+
tally._reset();
|
|
263
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'), 'utf8');
|
|
264
|
+
assert.doesNotMatch(src, /require\([^)]*cli-lib/, 'the session plumbing never loads in the server');
|
|
265
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// tests/module_test_runner.mjs — the separate testing environment for a published
|
|
2
|
+
// module's own tests (task 1004460): the control plane dispatches a run to a
|
|
3
|
+
// private repo's GitHub Actions and PULLS the result back, accepting it only from
|
|
4
|
+
// the run whose run-name carries the pending row's nonce. No network, no DB:
|
|
5
|
+
// GitHub is a fake fetch, the DB an append-only fake.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_test_runner.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import zlib from 'node:zlib';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const runner = require('../scripts/gds/module-test-runner.js');
|
|
21
|
+
const { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName } = runner;
|
|
22
|
+
|
|
23
|
+
const CFG = { repo: 'owner/module-tests', token: 't0ken', coreRepo: 'owner/core' };
|
|
24
|
+
const VER = { id: 7, module_key: 'weather', version: '1.0.0', artifact_path: 'weather/weather-1.0.0.tgz' };
|
|
25
|
+
const quiet = { warn() {}, error() {} };
|
|
26
|
+
|
|
27
|
+
// A minimal zip writer (stored or deflated), enough to stand in for an artifact.
|
|
28
|
+
function makeZip(entries, { deflate = true } = {}) {
|
|
29
|
+
const locals = []; const centrals = []; let offset = 0;
|
|
30
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
31
|
+
const raw = Buffer.from(data);
|
|
32
|
+
const body = deflate ? zlib.deflateRawSync(raw) : raw;
|
|
33
|
+
const n = Buffer.from(name);
|
|
34
|
+
const lh = Buffer.alloc(30); lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(deflate ? 8 : 0, 8);
|
|
35
|
+
lh.writeUInt32LE(body.length, 18); lh.writeUInt32LE(raw.length, 22); lh.writeUInt16LE(n.length, 26);
|
|
36
|
+
const ch = Buffer.alloc(46); ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(deflate ? 8 : 0, 10);
|
|
37
|
+
ch.writeUInt32LE(body.length, 20); ch.writeUInt32LE(raw.length, 24); ch.writeUInt16LE(n.length, 28); ch.writeUInt32LE(offset, 42);
|
|
38
|
+
locals.push(lh, n, body); centrals.push(ch, n); offset += 30 + n.length + body.length;
|
|
39
|
+
}
|
|
40
|
+
const cd = Buffer.concat(centrals);
|
|
41
|
+
const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0);
|
|
42
|
+
eocd.writeUInt16LE(Object.keys(entries).length, 8); eocd.writeUInt16LE(Object.keys(entries).length, 10);
|
|
43
|
+
eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
|
|
44
|
+
return Buffer.concat([...locals, cd, eocd]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fakeDb() {
|
|
48
|
+
const signals = []; let clock = 0;
|
|
49
|
+
return {
|
|
50
|
+
signals,
|
|
51
|
+
async query(sql, params = []) {
|
|
52
|
+
if (/ORDER BY measured_at DESC, id DESC LIMIT 1/.test(sql) && !/CROSS JOIN LATERAL/.test(sql)) {
|
|
53
|
+
const mine = signals.filter((s) => String(s.version_id) === String(params[0]) && s.part === 'tests');
|
|
54
|
+
return { rows: mine.length ? [mine[mine.length - 1]] : [] };
|
|
55
|
+
}
|
|
56
|
+
if (/CROSS JOIN LATERAL/.test(sql)) {
|
|
57
|
+
assert.match(sql, /FROM store_module_versions v/, 'driven from the versions, not the whole signal history');
|
|
58
|
+
const cur = {};
|
|
59
|
+
for (const s of signals.filter((x) => x.part === 'tests')) cur[s.version_id] = s;
|
|
60
|
+
return { rows: Object.values(cur).filter((r) => r.outcome === 'pending' && r.detail && r.detail.runner) };
|
|
61
|
+
}
|
|
62
|
+
if (/INSERT INTO module_assessment_signals/.test(sql)) {
|
|
63
|
+
signals.push({ id: signals.length + 1, version_id: params[0], part: 'tests', outcome: 'pending', reason: params[1], detail: JSON.parse(params[2]), t: ++clock });
|
|
64
|
+
return { rows: [] };
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`unexpected SQL: ${sql.slice(0, 80)}`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A fake GitHub: records every call, answers by route.
|
|
72
|
+
function fakeGitHub(routes = {}) {
|
|
73
|
+
const calls = [];
|
|
74
|
+
const fetchImpl = async (url, init = {}) => {
|
|
75
|
+
const method = init.method || 'GET';
|
|
76
|
+
calls.push({ method, url, body: init.body ? JSON.parse(init.body) : null, auth: init.headers && init.headers.Authorization });
|
|
77
|
+
for (const [pattern, answer] of Object.entries(routes)) {
|
|
78
|
+
const [m, re] = pattern.split(' ');
|
|
79
|
+
if (m === method && new RegExp(re).test(url)) {
|
|
80
|
+
const a = typeof answer === 'function' ? answer(url, init) : answer;
|
|
81
|
+
if (a instanceof Response) return a;
|
|
82
|
+
return new Response(a === null ? null : JSON.stringify(a), { status: a === null ? 204 : 200 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return new Response('{"message":"not found"}', { status: 404 });
|
|
86
|
+
};
|
|
87
|
+
return { calls, fetchImpl };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dispatchRoutes = {
|
|
91
|
+
'POST /git/blobs$': { sha: 'blob1' },
|
|
92
|
+
'POST /git/trees$': { sha: 'tree1' },
|
|
93
|
+
'POST /git/commits$': { sha: 'commit1' },
|
|
94
|
+
'POST /git/refs$': { ref: 'x' },
|
|
95
|
+
'POST /dispatches$': null,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
test('unconfigured: no runner, so Tests stays plain pending and nothing is sent', async () => {
|
|
100
|
+
assert.equal(runnerConfig({}), null);
|
|
101
|
+
assert.equal(runnerConfig({ MODULE_TEST_REPO: 'a/b' }), null, 'a repo with no token is not configured');
|
|
102
|
+
const db = fakeDb();
|
|
103
|
+
assert.equal(await maybeDispatch(VER, { db, env: {} }), null);
|
|
104
|
+
assert.equal(db.signals.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('config: owner/name is required, the core repo falls back to git origin', () => {
|
|
108
|
+
assert.throws(() => runnerConfig({ MODULE_TEST_REPO: 'nope', MODULE_TEST_TOKEN: 'x' }), /owner\/name/);
|
|
109
|
+
const c = runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x' }, { origin: () => 'o/core' });
|
|
110
|
+
assert.deepEqual(c, { repo: 'o/r', token: 'x', coreRepo: 'o/core', ref: 'main' });
|
|
111
|
+
assert.equal(runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x', MODULE_TEST_REF: 'trunk' }, { origin: () => 'o/core' }).ref, 'trunk');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('dispatch pushes the tarball to a throwaway branch, dispatches, and records the nonce', async () => {
|
|
115
|
+
const db = fakeDb();
|
|
116
|
+
const gh = fakeGitHub(dispatchRoutes);
|
|
117
|
+
const r = await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef: () => 'abc123', log: quiet });
|
|
118
|
+
assert.match(r.nonce, /^[0-9a-f]{32}$/);
|
|
119
|
+
assert.equal(r.branch, `module-test/${r.nonce}`);
|
|
120
|
+
assert.deepEqual(gh.calls.map((c) => `${c.method} ${c.url.replace('https://api.github.com/repos/owner/module-tests', '')}`), [
|
|
121
|
+
'POST /git/blobs', 'POST /git/trees', 'POST /git/commits', 'POST /git/refs', 'POST /actions/workflows/module-tests.yml/dispatches']);
|
|
122
|
+
assert.deepEqual(gh.calls[2].body.parents, [], 'an orphan commit: the branch carries only the tarball');
|
|
123
|
+
assert.equal(gh.calls[3].body.ref, `refs/heads/module-test/${r.nonce}`);
|
|
124
|
+
assert.equal(gh.calls[4].body.ref, 'main');
|
|
125
|
+
assert.deepEqual(gh.calls[4].body.inputs, { version_id: '7', module_key: 'weather', nonce: r.nonce, core_repo: 'owner/core', core_ref: 'abc123' });
|
|
126
|
+
assert.equal(db.signals.length, 1);
|
|
127
|
+
assert.equal(db.signals[0].outcome, 'pending');
|
|
128
|
+
assert.equal(db.signals[0].detail.runner.nonce, r.nonce);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a version with a real Tests result, or a run already in flight, is not dispatched again', async () => {
|
|
132
|
+
for (const existing of [{ outcome: 'scored', detail: {} }, { outcome: 'pending', detail: { runner: { nonce: 'a'.repeat(32) } } }]) {
|
|
133
|
+
const db = fakeDb();
|
|
134
|
+
db.signals.push({ id: 1, version_id: 7, part: 'tests', ...existing });
|
|
135
|
+
const gh = fakeGitHub(dispatchRoutes);
|
|
136
|
+
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef: () => 'c', log: quiet }), null);
|
|
137
|
+
assert.equal(gh.calls.length, 0);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a plain pending (from before the runner was set up) is dispatched', async () => {
|
|
142
|
+
const db = fakeDb();
|
|
143
|
+
db.signals.push({ id: 1, version_id: 7, part: 'tests', outcome: 'pending', detail: {} });
|
|
144
|
+
const gh = fakeGitHub(dispatchRoutes);
|
|
145
|
+
assert.ok(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef: () => 'c', log: quiet }));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a failed dispatch deletes the branch, records nothing and never throws', async () => {
|
|
149
|
+
const db = fakeDb();
|
|
150
|
+
const gh = fakeGitHub({ ...dispatchRoutes, 'POST /dispatches$': () => new Response('{"message":"nope"}', { status: 422 }), 'DELETE /git/refs/heads/module-test/': null });
|
|
151
|
+
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef: () => 'c', log: quiet }), null);
|
|
152
|
+
assert.equal(gh.calls.at(-1).method, 'DELETE');
|
|
153
|
+
assert.equal(db.signals.length, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
test('readZipEntry reads deflated and stored entries, and refuses an oversized one', () => {
|
|
158
|
+
const z = makeZip({ 'other.txt': 'x', 'signal.json': '{"a":1}' });
|
|
159
|
+
assert.equal(readZipEntry(z, 'signal.json').toString(), '{"a":1}');
|
|
160
|
+
assert.equal(readZipEntry(makeZip({ 'signal.json': 'plain' }, { deflate: false }), 'signal.json').toString(), 'plain');
|
|
161
|
+
assert.equal(readZipEntry(z, 'missing.json'), null);
|
|
162
|
+
assert.throws(() => readZipEntry(makeZip({ 'signal.json': 'x'.repeat(2000) }), 'signal.json', { maxBytes: 1000 }), /over 1000 bytes/);
|
|
163
|
+
assert.throws(() => readZipEntry(Buffer.from('not a zip at all, definitely not'), 'signal.json'), /not a zip/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('validateSignal holds the payload to its run and to the stored shape', () => {
|
|
167
|
+
const base = { version_id: '7', nonce: 'n', outcome: 'scored', score: 80, sample_size: 5, reason: 'r', detail: { files: [{ file: 'tests/a.mjs', passed: true, extra: 'dropped' }] } };
|
|
168
|
+
const ok = validateSignal(base, { versionId: 7, nonce: 'n' });
|
|
169
|
+
assert.deepEqual(ok, { outcome: 'scored', score: 80, sample_size: 5, reason: 'r', detail: { files: [{ file: 'tests/a.mjs', passed: true }] } });
|
|
170
|
+
assert.throws(() => validateSignal({ ...base, nonce: 'other' }, { versionId: 7, nonce: 'n' }), /different version or run/);
|
|
171
|
+
assert.throws(() => validateSignal({ ...base, version_id: '8' }, { versionId: 7, nonce: 'n' }), /different version or run/);
|
|
172
|
+
assert.throws(() => validateSignal({ ...base, score: 101 }, { versionId: 7, nonce: 'n' }), /0–100/);
|
|
173
|
+
assert.throws(() => validateSignal({ ...base, outcome: 'passed' }, { versionId: 7, nonce: 'n' }), /unknown outcome/);
|
|
174
|
+
assert.equal(validateSignal({ ...base, outcome: 'no_data', score: 99 }, { versionId: 7, nonce: 'n' }).score, null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
const NONCE = 'b'.repeat(32);
|
|
179
|
+
function pendingDb(at = new Date().toISOString()) {
|
|
180
|
+
const db = fakeDb();
|
|
181
|
+
db.signals.push({ id: 1, version_id: 7, part: 'tests', outcome: 'pending', detail: { runner: { nonce: NONCE, branch: `module-test/${NONCE}`, dispatched_at: at } } });
|
|
182
|
+
return db;
|
|
183
|
+
}
|
|
184
|
+
const recorded = [];
|
|
185
|
+
const insert = async (id, s) => { recorded.push({ id, ...s }); };
|
|
186
|
+
const recompose = async () => {};
|
|
187
|
+
|
|
188
|
+
test('collect takes only the run whose run-name carries the nonce, and only its artifact of that name', async () => {
|
|
189
|
+
recorded.length = 0;
|
|
190
|
+
const payload = { version_id: '7', nonce: NONCE, outcome: 'scored', score: 50, sample_size: 2, reason: '1 of 2 failed', detail: { files: [] } };
|
|
191
|
+
const forged = { ...payload, score: 100 };
|
|
192
|
+
const gh = fakeGitHub({
|
|
193
|
+
'GET /workflows/module-tests.yml/runs': { runs: [
|
|
194
|
+
{ id: 900, display_title: runName(8, 'c'.repeat(32)), status: 'completed', conclusion: 'success' },
|
|
195
|
+
{ id: 901, display_title: runName(7, NONCE), status: 'completed', conclusion: 'success' },
|
|
196
|
+
] },
|
|
197
|
+
// run 900 (another version's run) uploads an artifact claiming version 7's name
|
|
198
|
+
'GET /runs/900/artifacts': { artifacts: [{ name: artifactName(7, NONCE), archive_download_url: 'https://dl/900', expired: false }] },
|
|
199
|
+
'GET /runs/901/artifacts': { artifacts: [{ name: 'tarball-x', archive_download_url: 'https://dl/no' }, { name: artifactName(7, NONCE), archive_download_url: 'https://dl/901', expired: false }] },
|
|
200
|
+
'GET ^https://dl/900': () => new Response(makeZip({ 'signal.json': JSON.stringify(forged) })),
|
|
201
|
+
'GET ^https://dl/901': () => new Response(null, { status: 302, headers: { location: 'https://blob/901' } }),
|
|
202
|
+
'GET ^https://blob/901': () => new Response(makeZip({ 'signal.json': JSON.stringify(payload) })),
|
|
203
|
+
'DELETE /git/refs/heads/': null,
|
|
204
|
+
});
|
|
205
|
+
const res = await collectResults({ db: pendingDb(), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose });
|
|
206
|
+
assert.deepEqual(res, [{ version_id: 7, status: 'recorded', outcome: 'scored' }]);
|
|
207
|
+
assert.equal(recorded[0].score, 50, 'the forged artifact in run 900 was never read');
|
|
208
|
+
assert.deepEqual(recorded[0].detail.runner, { nonce: NONCE, run_id: 901 });
|
|
209
|
+
assert.ok(!gh.calls.some((c) => c.url.includes('/runs/900/')), 'another run is never even listed');
|
|
210
|
+
const blob = gh.calls.find((c) => c.url === 'https://blob/901');
|
|
211
|
+
assert.equal(blob.auth, undefined, 'the token is not sent past the redirect');
|
|
212
|
+
assert.equal(gh.calls.at(-1).method, 'DELETE', 'the branch is deleted once the result is in');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('collect waits on an unfinished run, and gives up on one past the age limit', async () => {
|
|
216
|
+
recorded.length = 0;
|
|
217
|
+
const gh = fakeGitHub({ 'GET /runs\\?': { runs: [{ id: 1, display_title: runName(7, NONCE), status: 'in_progress' }] }, 'DELETE /git/refs/': null });
|
|
218
|
+
assert.deepEqual(await collectResults({ db: pendingDb(), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose }), [{ version_id: 7, status: 'waiting' }]);
|
|
219
|
+
assert.equal(recorded.length, 0);
|
|
220
|
+
const old = new Date(Date.now() - 7 * 3600_000).toISOString();
|
|
221
|
+
const res = await collectResults({ db: pendingDb(old), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose });
|
|
222
|
+
assert.deepEqual(res, [{ version_id: 7, status: 'expired', outcome: 'not_scored' }]);
|
|
223
|
+
assert.match(recorded[0].reason, /did not finish in time/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a run that never started is waited on, then expires as not_scored', async () => {
|
|
227
|
+
recorded.length = 0;
|
|
228
|
+
const gh = fakeGitHub({ 'GET /runs\\?': { runs: [] }, 'DELETE /git/refs/': null });
|
|
229
|
+
assert.deepEqual(await collectResults({ db: pendingDb(), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose }), [{ version_id: 7, status: 'waiting' }]);
|
|
230
|
+
const old = new Date(Date.now() - 7 * 3600_000).toISOString();
|
|
231
|
+
const res = await collectResults({ db: pendingDb(old), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose });
|
|
232
|
+
assert.deepEqual(res, [{ version_id: 7, status: 'expired', outcome: 'not_scored' }]);
|
|
233
|
+
assert.match(recorded[0].reason, /never started/);
|
|
234
|
+
assert.equal(gh.calls.at(-1).method, 'DELETE', 'its branch is still cleaned up');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('the sweeper keeps going while a run is out or a sweep fails, and stops once nothing waits', async () => {
|
|
238
|
+
const tick = (res) => sweepTick({ db: {}, cfg: CFG, log: quiet, collect: async () => { if (res instanceof Error) throw res; return res; } });
|
|
239
|
+
assert.equal(await tick([{ version_id: 7, status: 'waiting' }, { version_id: 8, status: 'recorded' }]), true);
|
|
240
|
+
assert.equal(await tick(new Error('GitHub down')), true, 'a failed sweep is retried, not abandoned');
|
|
241
|
+
assert.equal(await tick([{ version_id: 7, status: 'recorded' }]), false);
|
|
242
|
+
assert.equal(await tick([]), false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a run that ended with no result artifact records not_scored, never a zero', async () => {
|
|
246
|
+
recorded.length = 0;
|
|
247
|
+
const gh = fakeGitHub({ 'GET /runs\\?': { runs: [{ id: 5, display_title: runName(7, NONCE), status: 'completed', conclusion: 'failure' }] }, 'GET /runs/5/artifacts': { artifacts: [] }, 'DELETE /git/refs/': null });
|
|
248
|
+
await collectResults({ db: pendingDb(), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose });
|
|
249
|
+
assert.equal(recorded[0].outcome, 'not_scored');
|
|
250
|
+
assert.equal(recorded[0].score, null);
|
|
251
|
+
assert.match(recorded[0].reason, /ended \(failure\) without a result/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('nothing pending: collect does not call GitHub', async () => {
|
|
255
|
+
const gh = fakeGitHub({});
|
|
256
|
+
assert.deepEqual(await collectResults({ db: fakeDb(), cfg: CFG, fetchImpl: gh.fetchImpl, insert, recompose }), []);
|
|
257
|
+
assert.equal(gh.calls.length, 0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
test('--tarball keeps the gate: outside the testing environment it refuses before reading anything', () => {
|
|
262
|
+
let out;
|
|
263
|
+
try {
|
|
264
|
+
execFileSync(process.execPath, ['scripts/gds/module-assess-tests.js', '--tarball', 'does-not-exist.tgz', '--key', 'weather', '--json'],
|
|
265
|
+
{ cwd: ROOT, env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot }, encoding: 'utf8', stdio: 'pipe' });
|
|
266
|
+
} catch (e) { out = e; }
|
|
267
|
+
assert.ok(out, 'it exits non-zero');
|
|
268
|
+
assert.match(out.stderr, /separate testing environment/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('the workflow template: no permissions for the job that runs module code, nonce in the run-name, no inputs in shell', () => {
|
|
272
|
+
const yml = fs.readFileSync(path.join(ROOT, 'scripts/gds/module-test-runner/module-tests.yml'), 'utf8');
|
|
273
|
+
assert.match(yml, /^permissions: \{\}$/m);
|
|
274
|
+
assert.match(yml, /^run-name: module-tests \$\{\{ inputs\.version_id \}\} \$\{\{ inputs\.nonce \}\}$/m);
|
|
275
|
+
assert.equal(runName(7, 'n'), 'module-tests 7 n', 'collect matches the template\'s run-name');
|
|
276
|
+
const testJob = yml.slice(yml.indexOf('\n test:'));
|
|
277
|
+
assert.match(testJob, /permissions: \{\}/);
|
|
278
|
+
assert.match(testJob, /MODULE_TEST_SANDBOX=1/);
|
|
279
|
+
// Module code runs as an unprivileged user whose packets the firewall rejects off loopback.
|
|
280
|
+
const cut = testJob.indexOf('Cut the network');
|
|
281
|
+
const run = testJob.indexOf("Run the module's own tests");
|
|
282
|
+
assert.ok(cut > testJob.indexOf('npm ci') && cut < run, 'the network is cut after the install and before module code runs');
|
|
283
|
+
assert.match(testJob, /--uid-owner moduletest -o lo -j ACCEPT/);
|
|
284
|
+
assert.match(testJob, /--uid-owner moduletest -j REJECT/);
|
|
285
|
+
assert.match(testJob.slice(run), /sudo -u moduletest -H env -i /, 'module code runs as moduletest with a cleared environment');
|
|
286
|
+
assert.match(testJob, /name: "module-tests-\$\{\{ inputs\.version_id \}\}-\$\{\{ inputs\.nonce \}\}"/);
|
|
287
|
+
assert.equal(artifactName(7, 'n'), 'module-tests-7-n');
|
|
288
|
+
// A `run:` block never interpolates an input (script injection): inputs reach shell only via env.
|
|
289
|
+
for (const block of yml.split(/\n\s+run: /).slice(1)) {
|
|
290
|
+
const body = block.split(/\n\s+- /)[0];
|
|
291
|
+
assert.doesNotMatch(body, /\$\{\{/, `a run block interpolates an expression: ${body.slice(0, 60)}`);
|
|
292
|
+
}
|
|
293
|
+
assert.doesNotMatch(yml, /secrets\./, 'the workflow reads no secrets');
|
|
294
|
+
});
|
|
@@ -239,7 +239,7 @@ test('the OWNER READ self-heals the projection, with the row it just read (task
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
test('a REFUSED read heals NOTHING — the repair only ever rides an own-scoped request', async () => {
|
|
242
|
-
//
|
|
242
|
+
// requireProjectOwner answers 404 for someone else's instance, and the heal must sit
|
|
243
243
|
// BEHIND that gate: an unauthenticated or wrong-owner read must never make the platform
|
|
244
244
|
// write a catalog row on a stranger's behalf.
|
|
245
245
|
instance = { ...BASE_ROW, owner_builder_id: 99 };
|
package/tests/provision.mjs
CHANGED
|
@@ -637,6 +637,8 @@ t('systemd manager: emits byte-identical commands + unit paths to the inline str
|
|
|
637
637
|
'sudo rm -f /etc/systemd/system/demo.service [allowFail]',
|
|
638
638
|
'sudo systemctl disable --now demo-db-backup.timer [allowFail]',
|
|
639
639
|
'sudo rm -f /etc/systemd/system/demo-db-backup.service /etc/systemd/system/demo-db-backup.timer [allowFail]',
|
|
640
|
+
'sudo rm -f /etc/systemd/system.control/demo.service.d/50-*.conf [allowFail]', // the caps drop-in (task 1004414)
|
|
641
|
+
'sudo rmdir /etc/systemd/system.control/demo.service.d [allowFail]',
|
|
640
642
|
'sudo systemctl daemon-reload [allowFail]',
|
|
641
643
|
'sudo systemctl reset-failed demo.service demo-db-backup.service [allowFail]',
|
|
642
644
|
], 'including reset-failed — a removed unit\'s FAILED state outlives its file (task 1003940)');
|