@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// tests/provision_resource_caps.mjs
|
|
2
|
+
//
|
|
3
|
+
// Every hosted project's service is capped (task 1004414). A cloud-host project runs its
|
|
4
|
+
// owner's code on the box that also serves the hub, and ADR 0354 lets any signed-in
|
|
5
|
+
// builder host one, so one runaway project must not starve the others or the hub.
|
|
6
|
+
//
|
|
7
|
+
// Covers:
|
|
8
|
+
// • the numbers are the owner's (2026-10-15): MemoryHigh 160M, MemoryMax 256M,
|
|
9
|
+
// CPUQuota 50%, TasksMax 256 — pinned here so a change is a decision, not a drift
|
|
10
|
+
// • a NEW unit carries all four, rendered from the same table
|
|
11
|
+
// • an EXISTING unit takes them live with `systemctl set-property` (no restart, and no
|
|
12
|
+
// rewrite of a unit whose User= predates the per-instance accounts)
|
|
13
|
+
// • `provision.js apply-caps` reaches every ACTIVE cloud-host project, on the control
|
|
14
|
+
// plane, carries on past one that fails, and says which failed
|
|
15
|
+
// • teardown removes the drop-in set-property wrote
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/provision_resource_caps.mjs
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
// ESM named imports, so knip credits these exports to their test (a createRequire read is not)
|
|
24
|
+
import { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps } from '../scripts/gds/provision-caps.js';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const CAPS = { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps };
|
|
28
|
+
const { serviceUnit } = require('../scripts/gds/provision-units.js');
|
|
29
|
+
const PM = require('../scripts/gds/provision-process.js');
|
|
30
|
+
// the REAL shape makeExec answers an allowFail'd failure with: ok:true, softFailed:true
|
|
31
|
+
const { softFailResult } = require('../scripts/gds/provision-config.js');
|
|
32
|
+
|
|
33
|
+
const CLOUD = { id: 18, slug: 'mercury', hosting_shape: 'cloud-host', port: 4100, db_name: 'mercury', target_ref: 'owner/mercury' };
|
|
34
|
+
const OWNER_NUMBERS = ['MemoryHigh=160M', 'MemoryMax=256M', 'CPUQuota=50%', 'TasksMax=256'];
|
|
35
|
+
|
|
36
|
+
test('the caps are the owner’s numbers (2026-10-15)', () => {
|
|
37
|
+
assert.deepEqual(CAPS.capDirectives(), OWNER_NUMBERS);
|
|
38
|
+
assert.ok(Object.isFrozen(CAPS.RESOURCE_CAPS), 'the table cannot be edited at runtime');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a new hosted project’s unit carries all four caps in [Service]', () => {
|
|
42
|
+
const unit = serviceUnit(CLOUD, { etcBase: '/etc' });
|
|
43
|
+
const service = unit.slice(unit.indexOf('[Service]'), unit.indexOf('[Install]'));
|
|
44
|
+
for (const d of OWNER_NUMBERS) assert.match(service, new RegExp(`^${d.replace(/[.*+?^${}()|[\]\\%]/g, '\\$&')}$`, 'm'), d);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an existing unit takes the caps live through set-property', () => {
|
|
48
|
+
assert.equal(CAPS.setCapsCmd(CLOUD, { sudoP: 'sudo ' }),
|
|
49
|
+
'sudo systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256');
|
|
50
|
+
assert.equal(CAPS.setCapsCmd(CLOUD), 'systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function fleet(rows, failSlug = null) {
|
|
54
|
+
const queries = [];
|
|
55
|
+
const ran = [];
|
|
56
|
+
const logs = [];
|
|
57
|
+
const exec = (cmd, opts = {}) => {
|
|
58
|
+
ran.push({ cmd, allowFail: !!opts.allowFail, on: 'control' });
|
|
59
|
+
return cmd.includes(` ${failSlug}.service `) ? softFailResult(new Error(`Unit ${failSlug}.service not loaded.`), false) : { ok: true, stdout: '' };
|
|
60
|
+
};
|
|
61
|
+
const deps = {
|
|
62
|
+
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows }; } },
|
|
63
|
+
controlExec: exec,
|
|
64
|
+
exec: () => { throw new Error('the box executor is for co-tenant; a cloud-host runs on the control plane'); },
|
|
65
|
+
privileged: true,
|
|
66
|
+
log: (s) => logs.push(s),
|
|
67
|
+
};
|
|
68
|
+
return { deps, queries, ran, logs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('apply-caps reaches every active cloud-host project on the control plane', async () => {
|
|
72
|
+
const f = fleet([{ slug: 'mercury' }, { slug: 'venus' }]);
|
|
73
|
+
const r = await CAPS.cmdApplyCaps(f.deps);
|
|
74
|
+
assert.match(f.queries[0].sql, /hosting_shape = 'cloud-host'/);
|
|
75
|
+
assert.match(f.queries[0].sql, /status = 'active'/);
|
|
76
|
+
assert.deepEqual(f.ran.map((x) => x.cmd), [
|
|
77
|
+
'sudo systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256',
|
|
78
|
+
'sudo systemctl set-property venus.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256',
|
|
79
|
+
]);
|
|
80
|
+
assert.ok(f.ran.every((x) => x.allowFail), 'one project that refuses must not stop the rest');
|
|
81
|
+
assert.deepEqual(r, { ok: true, capped: ['mercury', 'venus'], failed: [] });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('apply-caps carries on past a failure and names it', async () => {
|
|
85
|
+
const f = fleet([{ slug: 'mercury' }, { slug: 'venus' }, { slug: 'pluto' }], 'venus');
|
|
86
|
+
const r = await CAPS.cmdApplyCaps(f.deps);
|
|
87
|
+
assert.equal(f.ran.length, 3);
|
|
88
|
+
assert.deepEqual(r, { ok: false, capped: ['mercury', 'pluto'], failed: ['venus'] });
|
|
89
|
+
assert.ok(f.logs.some((l) => /venus/.test(l)), 'the failure is said out loud');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a slug outside the create route’s charset is refused, never run as root', () => {
|
|
93
|
+
for (const slug of ['a;rm -rf /', '../etc', 'Mercury', '-x']) assert.throws(() => CAPS.setCapsCmd({ slug }), /unsafe slug/, slug);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('apply-caps on an empty fleet does nothing and succeeds', async () => {
|
|
97
|
+
const f = fleet([]);
|
|
98
|
+
assert.deepEqual(await CAPS.cmdApplyCaps(f.deps), { ok: true, capped: [], failed: [] });
|
|
99
|
+
assert.equal(f.ran.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('provision.js lists apply-caps as a command', () => {
|
|
103
|
+
const r = spawnSync(process.execPath, ['scripts/gds/provision.js', 'help'], { encoding: 'utf8' });
|
|
104
|
+
assert.equal(r.status, 0, r.stderr);
|
|
105
|
+
assert.match(r.stdout, /apply-caps/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('teardown removes the drop-in set-property wrote, before the reload', () => {
|
|
109
|
+
const cmds = [];
|
|
110
|
+
const exec = (cmd, opts = {}) => { cmds.push(cmd); return { ok: true }; };
|
|
111
|
+
PM.systemd.removeService(CLOUD, { exec, sudoP: 'sudo ' });
|
|
112
|
+
const rm = cmds.indexOf('sudo rm -f /etc/systemd/system.control/mercury.service.d/50-*.conf');
|
|
113
|
+
assert.ok(rm >= 0, cmds.join('\n'));
|
|
114
|
+
assert.ok(cmds.includes('sudo rmdir /etc/systemd/system.control/mercury.service.d'), 'the emptied dir goes too');
|
|
115
|
+
assert.ok(!cmds.some((c) => /rm -rf/.test(c)), 'never a recursive delete');
|
|
116
|
+
assert.ok(rm < cmds.indexOf('sudo systemctl daemon-reload'), 'removed before the reload that forgets the unit');
|
|
117
|
+
});
|
|
@@ -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
|
+
});
|