@bongos/core 1.21.124 → 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 +91 -41
- package/bin/bongos.js +9 -0
- 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 +4 -0
- 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/onboarding/howto-guide.js +259 -0
- 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/core-pin-sync.js +233 -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/core_pin_sync.mjs +237 -0
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/howto_guide.mjs +96 -0
- 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
|
@@ -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;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// tests/provisioning_project_owner_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// requireProjectOwner — the one owner-or-archon gate on the 14 own-scoped
|
|
4
|
+
// /provisioning/instances/:id routes (task 1004159, ADR 0328 decision 2). It replaced three
|
|
5
|
+
// hand-written copies of the rule and gave teardown, domain and github-app the archon
|
|
6
|
+
// override they lacked.
|
|
7
|
+
//
|
|
8
|
+
// Covers:
|
|
9
|
+
// • the gate itself: the owner and an archon are admitted; a metic is NOT (the gate is a
|
|
10
|
+
// plain rank test, never provisioning.fleet.manage, whose floor is metic); a missing row
|
|
11
|
+
// and a foreign row are the same 404; archonOverride:false; a bad id is a 400 that never
|
|
12
|
+
// reaches the loader; a loader failure is an opaque 500
|
|
13
|
+
// • the three WIDENED routes: an archon acts on another builder's project, and the intent,
|
|
14
|
+
// the event's actor and the event's owner all say so — never 'api:self'
|
|
15
|
+
// • the gate runs before the body validator, so a stranger's malformed request is a 404
|
|
16
|
+
// • the structure: every /provisioning/instances/:id route in the three files mounts the
|
|
17
|
+
// gate (force-teardown excepted), and the old copies are gone
|
|
18
|
+
//
|
|
19
|
+
// DB-free: data access is stubbed on the cached module object (the provisioning_restart.mjs
|
|
20
|
+
// pattern), over the real router and the real error envelope.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/provisioning_project_owner_gate.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test, before, after } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
|
|
35
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
36
|
+
const provisioning = require(path.join(ROOT, 'modules/provisioning/provisioning.js'));
|
|
37
|
+
const { requireProjectOwner, projectActor } = require(path.join(ROOT, 'modules/provisioning/route-helpers.js'));
|
|
38
|
+
|
|
39
|
+
const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
40
|
+
const ARCHON = { id: 999, github_login: 'fleet-support', rank: 'archon' };
|
|
41
|
+
const METIC = { id: 55, github_login: 'stranger', rank: 'metic' };
|
|
42
|
+
const ROW = { id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'cloud-host', domain: null, target_ref: 'owner/mercury' };
|
|
43
|
+
|
|
44
|
+
// ── the gate, called directly ───────────────────────────────────────────────────────
|
|
45
|
+
function fakeRes() {
|
|
46
|
+
return { out: null, fail(code, opts) { this.out = { code, status: typeof opts === 'number' ? opts : opts.status, message: opts && opts.message }; } };
|
|
47
|
+
}
|
|
48
|
+
async function runGate(gate, { builder, id = '18' }) {
|
|
49
|
+
const req = { params: { id }, builder, method: 'POST', path: `/provisioning/instances/${id}/teardown` };
|
|
50
|
+
const res = fakeRes();
|
|
51
|
+
let nexted = false;
|
|
52
|
+
await gate(req, res, () => { nexted = true; });
|
|
53
|
+
return { req, res, nexted };
|
|
54
|
+
}
|
|
55
|
+
const loads = [];
|
|
56
|
+
const loader = (rows) => async (id) => { loads.push(id); return rows[id] ? { ...rows[id] } : null; };
|
|
57
|
+
|
|
58
|
+
test('the owner is admitted and the row rides on req.project', async () => {
|
|
59
|
+
const { req, res, nexted } = await runGate(requireProjectOwner(loader({ 18: ROW })), { builder: OWNER });
|
|
60
|
+
assert.equal(nexted, true);
|
|
61
|
+
assert.equal(res.out, null);
|
|
62
|
+
assert.equal(req.project.slug, 'mercury');
|
|
63
|
+
assert.equal(projectActor(req), 'api:self');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an archon is admitted on another builder’s project, and the actor names that archon', async () => {
|
|
67
|
+
const { req, nexted } = await runGate(requireProjectOwner(loader({ 18: ROW })), { builder: ARCHON });
|
|
68
|
+
assert.equal(nexted, true);
|
|
69
|
+
assert.equal(projectActor(req), 'api:archon:999');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a metic is NOT admitted: the gate is a rank test, not provisioning.fleet.manage (floor metic)', async () => {
|
|
73
|
+
const { req, res, nexted } = await runGate(requireProjectOwner(loader({ 18: ROW })), { builder: METIC });
|
|
74
|
+
assert.equal(nexted, false);
|
|
75
|
+
assert.deepEqual([res.out.code, res.out.status], ['instance_not_found', 404]);
|
|
76
|
+
assert.equal(req.project, undefined, 'a refused request carries no row');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a missing row is the same 404 a foreign one gets — for an archon too', async () => {
|
|
80
|
+
for (const builder of [OWNER, ARCHON]) {
|
|
81
|
+
const { res, nexted } = await runGate(requireProjectOwner(loader({})), { builder });
|
|
82
|
+
assert.equal(nexted, false);
|
|
83
|
+
assert.deepEqual([res.out.code, res.out.status], ['instance_not_found', 404]);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('archonOverride:false refuses an archon on a foreign project and still admits the owner', async () => {
|
|
88
|
+
const gate = requireProjectOwner(loader({ 18: ROW }), { archonOverride: false });
|
|
89
|
+
assert.equal((await runGate(gate, { builder: ARCHON })).res.out.status, 404);
|
|
90
|
+
assert.equal((await runGate(gate, { builder: OWNER })).nexted, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a malformed id is 400 bad_id and never reaches the loader', async () => {
|
|
94
|
+
loads.length = 0;
|
|
95
|
+
const { res, nexted } = await runGate(requireProjectOwner(loader({ 18: ROW })), { builder: OWNER, id: 'not-a-number' });
|
|
96
|
+
assert.equal(nexted, false);
|
|
97
|
+
assert.deepEqual([res.out.code, res.out.status], ['bad_id', 400]);
|
|
98
|
+
assert.deepEqual(loads, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a loader that throws is an opaque 500 — neither the 404 nor the error text', async () => {
|
|
102
|
+
const gate = requireProjectOwner(async () => { throw new Error('relation "provisioning_instances" does not exist'); });
|
|
103
|
+
const { res, nexted } = await runGate(gate, { builder: OWNER });
|
|
104
|
+
assert.equal(nexted, false);
|
|
105
|
+
assert.deepEqual(res.out, { code: 'instance_read_failed', status: 500, message: 'internal error' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── the three widened routes, over the real router ──────────────────────────────────
|
|
109
|
+
let builder = OWNER;
|
|
110
|
+
let row = null;
|
|
111
|
+
const events = [];
|
|
112
|
+
const enqueues = [];
|
|
113
|
+
const manifests = [];
|
|
114
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { ...builder }; next(); };
|
|
115
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
116
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' }, identity: { productName: 'Cloud Bongos' } });
|
|
117
|
+
provisioning.getInstanceById = async (_db, id) => (row && String(id) === String(row.id) ? { ...row } : null);
|
|
118
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
119
|
+
provisioning.enqueueTeardown = async (_db, id, requestedBy) => { enqueues.push({ action: 'teardown', id, requestedBy }); return { created: true, superseded: false }; };
|
|
120
|
+
provisioning.enqueueIntent = async (_db, id, action, requestedBy) => { enqueues.push({ action, id, requestedBy }); return { intent: { id: 9, action, state: 'pending' }, created: true }; };
|
|
121
|
+
provisioning.getOpenIntent = async () => null;
|
|
122
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
123
|
+
provisioning.updateInstanceDomain = async (_db, id, domain) => ({ ...row, id, domain });
|
|
124
|
+
provisioning.createOAuthManifest = async (_db, m) => { manifests.push(m); return { id: 1, ...m }; };
|
|
125
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
126
|
+
|
|
127
|
+
const express = require('express');
|
|
128
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
129
|
+
let server, base;
|
|
130
|
+
before(async () => {
|
|
131
|
+
const app = express();
|
|
132
|
+
app.use(express.json());
|
|
133
|
+
app.use(attachFail);
|
|
134
|
+
app.use('/api/bongos', require(path.join(ROOT, 'modules/provisioning/routes/provisioning.js'))());
|
|
135
|
+
server = app.listen(0);
|
|
136
|
+
await new Promise((r) => server.once('listening', r));
|
|
137
|
+
base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
138
|
+
});
|
|
139
|
+
after(() => server.close());
|
|
140
|
+
|
|
141
|
+
function post(p, body = {}) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const payload = JSON.stringify(body);
|
|
144
|
+
const req = http.request(`${base}${p}`, {
|
|
145
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
146
|
+
}, (res) => {
|
|
147
|
+
let d = '';
|
|
148
|
+
res.on('data', (c) => { d += c; });
|
|
149
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d), raw: d }));
|
|
150
|
+
});
|
|
151
|
+
req.on('error', reject);
|
|
152
|
+
req.end(payload);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function as(who, inst) {
|
|
156
|
+
builder = who;
|
|
157
|
+
row = inst;
|
|
158
|
+
events.length = 0; enqueues.length = 0; manifests.length = 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
test('teardown: an archon tears down another builder’s project, audited as the archon', async () => {
|
|
162
|
+
as(ARCHON, { ...ROW });
|
|
163
|
+
const r = await post('/provisioning/instances/18/teardown');
|
|
164
|
+
assert.equal(r.status, 200, r.raw);
|
|
165
|
+
assert.deepEqual(enqueues, [{ action: 'teardown', id: 18, requestedBy: 'api:archon:999' }]);
|
|
166
|
+
assert.equal(events.length, 1);
|
|
167
|
+
assert.equal(events[0].actor, 'api:archon:999');
|
|
168
|
+
assert.equal(events[0].ownerBuilderId, 7, 'the event belongs to the project’s owner, not the archon');
|
|
169
|
+
assert.match(events[0].detail, /teardown requested by archon fleet-support/);
|
|
170
|
+
assert.doesNotMatch(events[0].detail, /by owner/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('teardown: the owner’s own request still reads as the owner’s', async () => {
|
|
174
|
+
as(OWNER, { ...ROW });
|
|
175
|
+
const r = await post('/provisioning/instances/18/teardown');
|
|
176
|
+
assert.equal(r.status, 200, r.raw);
|
|
177
|
+
assert.equal(enqueues[0].requestedBy, 'api:self');
|
|
178
|
+
assert.equal(events[0].actor, 'api:self');
|
|
179
|
+
assert.equal(events[0].detail, 'teardown requested by owner');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('domain: an archon sets another builder’s address, and the intent and events name the archon', async () => {
|
|
183
|
+
as(ARCHON, { ...ROW });
|
|
184
|
+
const r = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
185
|
+
assert.equal(r.status, 200, r.raw);
|
|
186
|
+
assert.deepEqual(enqueues, [{ action: 'domain-attach', id: 18, requestedBy: 'api:archon:999' }]);
|
|
187
|
+
assert.ok(events.length >= 1);
|
|
188
|
+
for (const e of events) {
|
|
189
|
+
assert.equal(e.actor, 'api:archon:999');
|
|
190
|
+
assert.equal(e.ownerBuilderId, 7);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('domain: an archon’s re-kick of a stopped address is recorded as the archon’s', async () => {
|
|
195
|
+
as(ARCHON, { ...ROW, domain: 'mercury.example.com', last_verified_up: false });
|
|
196
|
+
const r = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
197
|
+
assert.equal(r.status, 200, r.raw);
|
|
198
|
+
assert.equal(r.body.queued, true);
|
|
199
|
+
assert.equal(enqueues[0].requestedBy, 'api:archon:999');
|
|
200
|
+
assert.equal(events[0].actor, 'api:archon:999');
|
|
201
|
+
assert.match(events[0].detail, /archon re-kick/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('github-app: an archon mints the link for another builder’s project, audited as the archon', async () => {
|
|
205
|
+
as(ARCHON, { ...ROW, domain: 'mercury.example.com' });
|
|
206
|
+
const r = await post('/provisioning/instances/18/github-app');
|
|
207
|
+
assert.equal(r.status, 200, r.raw);
|
|
208
|
+
assert.equal(manifests.length, 1);
|
|
209
|
+
assert.equal(manifests[0].requestedBy, 7, 'requested_by is the owner every later manifest event is filed under');
|
|
210
|
+
assert.equal(events.length, 1);
|
|
211
|
+
assert.equal(events[0].actor, 'api:archon:999');
|
|
212
|
+
assert.equal(events[0].ownerBuilderId, 7);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('github-app: the owner’s own mint is still api:self', async () => {
|
|
216
|
+
as(OWNER, { ...ROW, domain: 'mercury.example.com' });
|
|
217
|
+
const r = await post('/provisioning/instances/18/github-app');
|
|
218
|
+
assert.equal(r.status, 200, r.raw);
|
|
219
|
+
assert.equal(events[0].actor, 'api:self');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('the three widened routes stay closed to a non-archon stranger: 404, nothing queued or recorded', async () => {
|
|
223
|
+
for (const [p, body] of [['teardown', {}], ['domain', { domain: 'mercury.example.com' }], ['github-app', {}]]) {
|
|
224
|
+
as(METIC, { ...ROW, domain: 'mercury.example.com' });
|
|
225
|
+
const r = await post(`/provisioning/instances/18/${p}`, body);
|
|
226
|
+
assert.equal(r.status, 404, `${p}: ${r.raw}`);
|
|
227
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
228
|
+
assert.deepEqual([enqueues.length, events.length, manifests.length], [0, 0, 0], p);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('the gate runs before the body validator: a stranger’s malformed request is the same 404', async () => {
|
|
233
|
+
as(METIC, { ...ROW });
|
|
234
|
+
const bad = await post('/provisioning/instances/18/domain', { domain: 'not a hostname' });
|
|
235
|
+
assert.equal(bad.status, 404, bad.raw);
|
|
236
|
+
as(OWNER, { ...ROW });
|
|
237
|
+
const own = await post('/provisioning/instances/18/domain', { domain: 'not a hostname' });
|
|
238
|
+
assert.equal(own.status, 400, 'the owner still gets the validator’s answer');
|
|
239
|
+
assert.equal(own.body.error.code, 'bad_domain');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── the structure ───────────────────────────────────────────────────────────────────
|
|
243
|
+
const ROUTE_FILES = ['provisioning.js', 'disconnect.js', 'env-manifest.js']
|
|
244
|
+
.map((f) => path.join(ROOT, 'modules/provisioning/routes', f));
|
|
245
|
+
const read = (f) => fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
246
|
+
|
|
247
|
+
test('every /provisioning/instances/:id route in the three files mounts the gate (force-teardown excepted)', () => {
|
|
248
|
+
const declared = [];
|
|
249
|
+
for (const f of ROUTE_FILES) {
|
|
250
|
+
for (const line of read(f).split('\n')) {
|
|
251
|
+
const m = /router\.(get|post|patch|put|delete)\('(\/provisioning\/instances\/:id[^']*)'/.exec(line);
|
|
252
|
+
if (!m || m[2].endsWith('/force-teardown')) continue;
|
|
253
|
+
declared.push(`${m[1]} ${m[2]}`);
|
|
254
|
+
assert.match(line, /requireBuilder, (?:\w+, )?requireProjectOwner\(loadInstance\), async \(req, res\) =>/, `${m[1]} ${m[2]}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
assert.equal(declared.length, 14, `the 14 own-scoped routes, found: ${declared.join(' · ')}`);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('the three old copies of the rule are gone', () => {
|
|
261
|
+
const dir = path.join(ROOT, 'modules/provisioning');
|
|
262
|
+
const src = fs.readdirSync(dir, { recursive: true }).map(String)
|
|
263
|
+
.filter((f) => f.endsWith('.js') && !f.split(path.sep).includes('tests'))
|
|
264
|
+
.map((f) => read(path.join(dir, f))).join('\n');
|
|
265
|
+
assert.doesNotMatch(src, /function instanceAccessDenied|function resolveOwnInstance/);
|
|
266
|
+
for (const f of ROUTE_FILES) {
|
|
267
|
+
// `inst.` only: the create route's re-request check compares its NEW row, which no gate can load
|
|
268
|
+
assert.doesNotMatch(read(f), /\binst\.owner_builder_id\) [!=]== String\(req\.builder\.id\)/, `${path.basename(f)} compares owners inline`);
|
|
269
|
+
assert.doesNotMatch(read(f), /rank [!=]== 'archon'/, `${path.basename(f)} tests the rank inline`);
|
|
270
|
+
}
|
|
271
|
+
});
|