@bongos/core 1.21.125 → 1.21.126
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 +70 -40
- 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 +3 -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 +14 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- 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/src/module-api.js +1 -1
- 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_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -87,7 +87,7 @@ function fakeDb() {
|
|
|
87
87
|
async query(sql, params = []) {
|
|
88
88
|
sqlSeen.push(sql);
|
|
89
89
|
if (PRICE_WORD.test(sql)) throw new Error(`the assessment read price: ${sql.slice(0, 120)}`);
|
|
90
|
-
if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
|
|
90
|
+
if (/^SELECT id, module_key, version(, artifact_path)? FROM store_module_versions WHERE id/.test(sql)) {
|
|
91
91
|
const v = CASES.find((c) => String(c.id) === String(params[0]));
|
|
92
92
|
return { rows: v ? [{ id: v.id, module_key: v.module_key, version: v.version }] : [] };
|
|
93
93
|
}
|
|
@@ -162,7 +162,7 @@ test('assessVersion on publish runs the same steps for every case, and never rea
|
|
|
162
162
|
const recordDocs = async (id, { db: d }) => { steps.push(['docs', id]); return { ok: true, signal: d.add(id, 'docs', 'scored', 50) }; };
|
|
163
163
|
const out = [];
|
|
164
164
|
for (const c of CASES) {
|
|
165
|
-
const res = await assessVersion(c.id, { db, recordSecurity, recordDocs });
|
|
165
|
+
const res = await assessVersion(c.id, { db, dispatchTests: async () => null, recordSecurity, recordDocs });
|
|
166
166
|
assert.equal(res.ok, true, c.module_key);
|
|
167
167
|
out.push(shape(res.score));
|
|
168
168
|
}
|
|
@@ -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,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 };
|
|
@@ -494,7 +494,7 @@ await ta('PATCH /detail: a body with only a null modules key is still nothing to
|
|
|
494
494
|
});
|
|
495
495
|
|
|
496
496
|
await ta('PATCH /detail: another builder’s project is a 404, not a module change', async () => {
|
|
497
|
-
// The ownership wall is
|
|
497
|
+
// The ownership wall is requireProjectOwner's, shared with every sibling
|
|
498
498
|
// route — pinned here because this key is new on it.
|
|
499
499
|
detailRow = { id: 41, owner_builder_id: 99, slug: 'not-mine', status: 'active', type: 'game', modules: null, detail: {} };
|
|
500
500
|
lastUpdatedRow = null;
|