@bongos/core 1.21.91 → 1.21.93
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 +72 -62
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
- package/docs/copy-inventory.md +357 -367
- package/docs/copy-registry.json +559 -641
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-inventory.json +15 -18
- package/docs/page-readings.json +1235 -918
- package/modules/autonomy/routes/autonomy.js +14 -5
- package/modules/government/decision-board.js +13 -3
- package/modules/government/decision-rules.js +61 -13
- package/modules/lifecycle/routes/done-when.js +2 -2
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/modules/lifecycle/routes/goals.js +4 -4
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/modules/lifecycle/routes/tasks.js +1 -1
- package/modules/lifecycle/routes/versions.js +1 -1
- package/modules/npm-release/routes/release.js +1 -0
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/provisioning/routes/core-upgrade.js +11 -0
- package/modules/public-landing/public/projects-hall-live.states.json +6 -35
- package/modules/public-landing/public/projects.html +1019 -983
- package/modules/public-landing/public/projects.probes.json +15 -18
- package/modules/public-landing/public/projects.states.json +22 -25
- package/modules/render-deploy/routes/act.js +1 -1
- package/modules/security/routes/reports.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +7 -0
- package/src/bongos/act-alone.js +84 -0
- package/src/bongos/routes/builders.js +6 -1
- package/src/module-api.js +7 -1
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/government_act_alone_buildings.mjs +430 -0
- package/tests/module_api.mjs +1 -0
- package/tests/npm_release_release.mjs +2 -1
- package/tests/onboard.mjs +8 -8
- package/tests/project_invite_ui.mjs +6 -5
- package/tests/projects_hub.mjs +77 -98
- package/tests/projects_hub_app_status.mjs +3 -3
- package/tests/projects_hub_app_step.mjs +8 -8
- package/tests/projects_hub_cloud_hop.mjs +29 -53
- package/tests/projects_hub_look.mjs +29 -39
- package/tests/projects_hub_module_picker.mjs +32 -39
- package/tests/projects_hub_pre_uat.mjs +11 -14
- package/tests/ship_mode.mjs +2 -1
- package/tests/wizard_demo.mjs +30 -25
- package/tests/wizard_draft_resume.mjs +24 -32
- package/tests/wizard_front_door.mjs +91 -243
- package/tests/wizard_intent_resume.mjs +28 -48
- package/tests/wizard_one_page.mjs +201 -0
- package/tests/wizard_physics.mjs +46 -41
- package/tests/wizard_physics_more.mjs +71 -106
- package/tests/wizard_ship_mode.mjs +1 -1
- package/tests/wizard_six_screens.mjs +0 -129
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// tests/government_act_alone_buildings.mjs — "decide by vote" binds on EVERY
|
|
2
|
+
// building's own pages, not only People's (task 1004657, goal 1000111; ADR 0363
|
|
3
|
+
// D5 and its Consequences).
|
|
4
|
+
//
|
|
5
|
+
// What this pins:
|
|
6
|
+
// 1. THE GATE (src/bongos/act-alone.js, on the doorway as requireActAlone):
|
|
7
|
+
// as today it lets the request through; while the row names a sitting it
|
|
8
|
+
// refuses with People's exact shape (409 decided_by_sitting + area, rule,
|
|
9
|
+
// who, what_this_means, open_with); no government module → as today; an
|
|
10
|
+
// unreadable table → 503, fail-closed, like People.
|
|
11
|
+
// 2. THE RECORD (decision-rules AREAS[].acts) matches the source BOTH WAYS:
|
|
12
|
+
// every listed route mounts the gate for its building, and every mounted
|
|
13
|
+
// gate is listed. The charter says why it has no direct route.
|
|
14
|
+
// 3. EVERY BUILDING, THROUGH ITS REAL ROUTER, with the real permission gate
|
|
15
|
+
// and the real checkActAlone over a stubbed table: each listed route is
|
|
16
|
+
// refused while its building is a sitting and reaches its handler as today.
|
|
17
|
+
// 4. NOT A LONE ACT: a brake (fence off, pause), a dry run, and a passed
|
|
18
|
+
// matter's act (which never passes through a route) are never refused.
|
|
19
|
+
//
|
|
20
|
+
// DB-free: the government table read and every write are stubbed by property.
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { test, after } from 'node:test';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
33
|
+
|
|
34
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
35
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
36
|
+
const authMod = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
37
|
+
const { requireActAlone } = require(path.join(ROOT, 'src', 'bongos', 'act-alone.js'));
|
|
38
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
39
|
+
const dr = require(path.join(GOV, 'decision-rules.js'));
|
|
40
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
41
|
+
const govDb = require(path.join(GOV, 'db.js'));
|
|
42
|
+
const express = require('express');
|
|
43
|
+
|
|
44
|
+
async function stub(obj, stubs, fn) {
|
|
45
|
+
const originals = {};
|
|
46
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = obj[k]; obj[k] = v; }
|
|
47
|
+
try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) obj[k] = v; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 1. the gate itself ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function fakeRes() {
|
|
53
|
+
const res = { failed: null };
|
|
54
|
+
res.fail = (code, a, b) => { res.failed = { code, a, b }; return res; };
|
|
55
|
+
return res;
|
|
56
|
+
}
|
|
57
|
+
async function runGate(gate, req = { builder: { id: 42 }, body: {} }) {
|
|
58
|
+
const res = fakeRes();
|
|
59
|
+
let passed = false;
|
|
60
|
+
await gate(req, res, () => { passed = true; });
|
|
61
|
+
return { passed, failed: res.failed };
|
|
62
|
+
}
|
|
63
|
+
async function withPort(port, fn) {
|
|
64
|
+
seams._reset();
|
|
65
|
+
if (port) seams.registerProvider('government', port);
|
|
66
|
+
try { return await fn(); } finally { seams._reset(); registerMainPort(); }
|
|
67
|
+
}
|
|
68
|
+
// The port the routers below are driven through (section 3); restored after
|
|
69
|
+
// each gate test swaps in its own.
|
|
70
|
+
const ATOMS = [
|
|
71
|
+
'autonomy.fence.manage', 'criterion.satisfy', 'criterion.unsatisfy', 'gate_approval.decide', 'override_request.decide',
|
|
72
|
+
'core.pin.move', 'builder.budget.set', 'goal.create', 'goal.reorder', 'version.focus.set', 'project.settings.manage',
|
|
73
|
+
'security.report.adjudicate', 'access_request.review', 'government.manage',
|
|
74
|
+
];
|
|
75
|
+
function registerMainPort() {
|
|
76
|
+
seams.registerProvider('government', { resolveBuilderPermissions: async () => ATOMS, checkActAlone: board.checkActAlone });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('the gate: no government module, or a port without the check, is as today', async () => {
|
|
80
|
+
await withPort(null, async () => assert.equal((await runGate(requireActAlone('release'))).passed, true));
|
|
81
|
+
await withPort({ resolveBuilderPermissions: async () => [] }, async () => assert.equal((await runGate(requireActAlone('release'))).passed, true));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the gate: as today passes, asking for the named building and the live builder', async () => {
|
|
85
|
+
const asked = [];
|
|
86
|
+
await withPort({ checkActAlone: async (area, id) => { asked.push([area, id]); return { ok: true }; } }, async () => {
|
|
87
|
+
assert.equal((await runGate(requireActAlone('treasury'))).passed, true);
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual(asked, [['treasury', 42]]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the gate: a sitting refuses in People\'s exact shape, naming the matter route', async () => {
|
|
93
|
+
const refusal = { ok: false, reason: 'decided_by_sitting', area: 'review', rule: 'majority', who: 'the council', what_this_means: 'Review is decided by the council (majority vote). Put it to them as a matter instead.', open_with: 'POST /government/board/matters' };
|
|
94
|
+
await withPort({ checkActAlone: async () => refusal }, async () => {
|
|
95
|
+
const out = await runGate(requireActAlone('review'));
|
|
96
|
+
assert.equal(out.passed, false);
|
|
97
|
+
assert.deepEqual(out.failed, { code: 'decided_by_sitting', a: 409, b: { area: 'review', rule: 'majority', who: 'the council', what_this_means: refusal.what_this_means, open_with: 'POST /government/board/matters' } });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('the gate: an unreadable table, or an answer it does not know, refuses 503 — fail-closed like People', async () => {
|
|
102
|
+
await withPort({ checkActAlone: async () => { throw new Error('db down'); } }, async () => {
|
|
103
|
+
const out = await runGate(requireActAlone('rules'));
|
|
104
|
+
assert.equal(out.passed, false);
|
|
105
|
+
assert.equal(out.failed.code, 'decision_rule_unreadable');
|
|
106
|
+
assert.equal(out.failed.a.status, 503);
|
|
107
|
+
});
|
|
108
|
+
await withPort({ checkActAlone: async () => ({ ok: false, reason: 'unknown_area' }) }, async () => {
|
|
109
|
+
const out = await runGate(requireActAlone('nowhere'));
|
|
110
|
+
assert.equal(out.passed, false);
|
|
111
|
+
assert.equal(out.failed.code, 'decision_rule_unreadable');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the gate: `unless` lets a non-decision through without asking; a bad call is refused at mount', async () => {
|
|
116
|
+
let asked = 0;
|
|
117
|
+
await withPort({ checkActAlone: async () => { asked += 1; return { ok: false, reason: 'decided_by_sitting' }; } }, async () => {
|
|
118
|
+
const out = await runGate(requireActAlone('automation', { unless: (req) => req.body.enabled === false }), { builder: { id: 1 }, body: { enabled: false } });
|
|
119
|
+
assert.equal(out.passed, true);
|
|
120
|
+
});
|
|
121
|
+
assert.equal(asked, 0);
|
|
122
|
+
assert.throws(() => requireActAlone(''), TypeError);
|
|
123
|
+
assert.throws(() => requireActAlone('rules', { unless: true }), TypeError);
|
|
124
|
+
assert.equal(api.requireActAlone, requireActAlone, 'modules reach it through the doorway');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── 2. the record matches the source, both ways ─────────────────────────────
|
|
128
|
+
|
|
129
|
+
function routeFiles() {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const dir of [path.join(ROOT, 'src', 'bongos', 'routes'), ...fs.readdirSync(path.join(ROOT, 'modules')).map((m) => path.join(ROOT, 'modules', m, 'routes'))]) {
|
|
132
|
+
if (!fs.existsSync(dir)) continue;
|
|
133
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) out.push(path.join(dir, f));
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
const SOURCES = routeFiles().map((f) => ({ file: path.relative(ROOT, f), src: fs.readFileSync(f, 'utf8') }));
|
|
138
|
+
const REG_RE = /router\.(get|post|put|patch|delete)\(\s*'([^']+)'/g;
|
|
139
|
+
|
|
140
|
+
// The middleware text of one registration: from `router.x('path'` to its handler.
|
|
141
|
+
function registration(act) {
|
|
142
|
+
const [method, p] = act.split(' ');
|
|
143
|
+
const hits = [];
|
|
144
|
+
for (const { file, src } of SOURCES) {
|
|
145
|
+
for (const m of src.matchAll(REG_RE)) {
|
|
146
|
+
if (m[1].toUpperCase() !== method || m[2] !== p) continue;
|
|
147
|
+
const rest = src.slice(m.index);
|
|
148
|
+
const end = Math.min(...['(req, res', 'Handler('].map((s) => { const i = rest.indexOf(s); return i === -1 ? Infinity : i; }));
|
|
149
|
+
hits.push({ file, head: rest.slice(0, end), body: rest.slice(0, 3000) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return hits;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('the record: every building but the charter has direct routes; the charter says why it has none', () => {
|
|
156
|
+
for (const a of dr.AREAS) {
|
|
157
|
+
if (a.key === 'charter') {
|
|
158
|
+
assert.equal(a.acts.length, 0);
|
|
159
|
+
assert.match(a.no_direct_route, /amend/);
|
|
160
|
+
assert.equal(dr.resolveAll([]).find((r) => r.area === 'charter').no_direct_route, a.no_direct_route, 'the record rides the table view');
|
|
161
|
+
} else {
|
|
162
|
+
assert.ok(a.acts.length > 0, `${a.key} lists its direct routes`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the record → the source: every listed route mounts its own building\'s gate', () => {
|
|
168
|
+
for (const a of dr.AREAS) {
|
|
169
|
+
for (const act of a.acts) {
|
|
170
|
+
const hits = registration(act);
|
|
171
|
+
assert.equal(hits.length, 1, `${act} is registered exactly once (${hits.map((h) => h.file)})`);
|
|
172
|
+
const { head, body } = hits[0];
|
|
173
|
+
const mounted = head.includes(`requireActAlone('${a.key}'`);
|
|
174
|
+
const inModule = a.key === 'people' && /peopleMayActAlone\(req, res\)/.test(body);
|
|
175
|
+
assert.ok(mounted || inModule, `${act} (${hits[0].file}) defers to the ${a.key} row`);
|
|
176
|
+
if (mounted) assert.ok(head.indexOf('requireActAlone') > head.search(/requirePermission|requireOwnProjectOrPin/), `${act}: the permission gate stays first`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the source → the record: every mounted gate is listed under the building it names', () => {
|
|
182
|
+
let seen = 0;
|
|
183
|
+
for (const { file, src } of SOURCES) {
|
|
184
|
+
for (const m of src.matchAll(/requireActAlone\('([a-z]+)'/g)) {
|
|
185
|
+
const before = [...src.slice(0, m.index).matchAll(REG_RE)].pop();
|
|
186
|
+
assert.ok(before, `${file}: requireActAlone outside a route registration`);
|
|
187
|
+
const act = `${before[1].toUpperCase()} ${before[2]}`;
|
|
188
|
+
const area = dr.areaByKey(m[1]);
|
|
189
|
+
assert.ok(area, `${file}: requireActAlone('${m[1]}') names no building`);
|
|
190
|
+
assert.ok(area.acts.includes(act), `${act} mounts the ${m[1]} gate but is not in decision-rules AREAS.${m[1]}.acts`);
|
|
191
|
+
seen += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const listed = dr.AREAS.reduce((n, a) => n + a.acts.length, 0) - 2; // People's two assignments ask in-module
|
|
195
|
+
assert.equal(seen, listed, 'one mounted gate per listed route');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── 3. every building, through its real router ──────────────────────────────
|
|
199
|
+
|
|
200
|
+
const fakeBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon', github_login: 'someone' }; next(); };
|
|
201
|
+
const realApiRequireBuilder = api.requireBuilder;
|
|
202
|
+
const realAuthRequireBuilder = authMod.requireBuilder;
|
|
203
|
+
api.requireBuilder = fakeBuilder;
|
|
204
|
+
authMod.requireBuilder = fakeBuilder;
|
|
205
|
+
seams._reset();
|
|
206
|
+
registerMainPort();
|
|
207
|
+
|
|
208
|
+
const app = express();
|
|
209
|
+
app.use(express.json());
|
|
210
|
+
app.use(attachFail);
|
|
211
|
+
for (const rel of [
|
|
212
|
+
'modules/autonomy/routes/autonomy.js',
|
|
213
|
+
'modules/lifecycle/routes/done-when.js',
|
|
214
|
+
'modules/lifecycle/routes/gate-approvals.js',
|
|
215
|
+
'modules/lifecycle/routes/tasks.js',
|
|
216
|
+
'modules/lifecycle/routes/goals.js',
|
|
217
|
+
'modules/lifecycle/routes/versions.js',
|
|
218
|
+
'modules/lifecycle/routes/project-settings.js',
|
|
219
|
+
'modules/render-deploy/routes/act.js',
|
|
220
|
+
'modules/npm-release/routes/release.js',
|
|
221
|
+
'modules/provisioning/routes/core-upgrade.js',
|
|
222
|
+
'src/bongos/routes/builders.js',
|
|
223
|
+
'modules/security/routes/reports.js',
|
|
224
|
+
'modules/onboarding/routes/access-requests.js',
|
|
225
|
+
'modules/government/routes/government.js',
|
|
226
|
+
]) app.use(require(path.join(ROOT, rel))());
|
|
227
|
+
const server = http.createServer(app);
|
|
228
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
229
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
230
|
+
after(() => { server.close(); api.requireBuilder = realApiRequireBuilder; authMod.requireBuilder = realAuthRequireBuilder; seams._reset(); });
|
|
231
|
+
|
|
232
|
+
async function call(method, p, body) {
|
|
233
|
+
const res = await fetch(`${BASE}${p}`, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
234
|
+
let json = null; try { json = await res.json(); } catch { /* none */ }
|
|
235
|
+
return { status: res.status, json };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Each listed route as a request that, AS TODAY, stops in its handler's own
|
|
239
|
+
// first check (a bad id or body → 4xx) before any database write — so reaching
|
|
240
|
+
// the handler is visible and nothing is written either way.
|
|
241
|
+
const BAD = 'x';
|
|
242
|
+
const REQUESTS = {
|
|
243
|
+
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
244
|
+
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
245
|
+
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
246
|
+
'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
|
|
247
|
+
'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
|
|
248
|
+
'POST /security/reports/:id/fix': ['POST', `/security/reports/${BAD}/fix`, {}],
|
|
249
|
+
// People's own two ask after their validation (task 1004529), so they carry a
|
|
250
|
+
// valid custom rank; the writes are stubbed (PEOPLE_DB) and counted.
|
|
251
|
+
'POST /government/assignments': ['POST', '/government/assignments', { builder_id: 7, rank_key: 'tester' }],
|
|
252
|
+
'DELETE /government/assignments/:builderId/:rankKey': ['DELETE', '/government/assignments/7/tester'],
|
|
253
|
+
'PATCH /access-requests/:id': ['PATCH', `/access-requests/${BAD}`, { status: 'invited' }],
|
|
254
|
+
'POST /access-requests/invite': ['POST', '/access-requests/invite', {}],
|
|
255
|
+
'POST /goals': ['POST', '/goals', {}],
|
|
256
|
+
'POST /goals/reorder': ['POST', '/goals/reorder', {}],
|
|
257
|
+
'POST /work-categories': ['POST', '/work-categories', {}],
|
|
258
|
+
'PUT /versions/focus': ['PUT', '/versions/focus', { version_id: ' ' }],
|
|
259
|
+
'POST /autonomy/fence': ['POST', '/autonomy/fence', { enabled: 'yes' }],
|
|
260
|
+
'POST /autonomy/fence/goals': ['POST', '/autonomy/fence/goals', { goal_id: 0 }],
|
|
261
|
+
'DELETE /autonomy/fence/goals/:goalId': ['DELETE', `/autonomy/fence/goals/${BAD}`],
|
|
262
|
+
'POST /autonomy/fence/priority': ['POST', '/autonomy/fence/priority', {}],
|
|
263
|
+
'POST /autonomy/builders/:builderId/pause': ['POST', `/autonomy/builders/${BAD}/pause`, { paused: false }],
|
|
264
|
+
'POST /done-when/:criterionId/satisfy': ['POST', `/done-when/${BAD}/satisfy`, {}],
|
|
265
|
+
'POST /done-when/:criterionId/unsatisfy': ['POST', `/done-when/${BAD}/unsatisfy`, {}],
|
|
266
|
+
'POST /gate-approvals/:pr/approve': ['POST', `/gate-approvals/${BAD}/approve`, {}],
|
|
267
|
+
'POST /override-requests/:id/decide': ['POST', `/override-requests/${BAD}/decide`, { decision: 'approve' }],
|
|
268
|
+
'POST /provisioning/instances/:id/core-upgrade': ['POST', `/provisioning/instances/${BAD}/core-upgrade`, { to: '1.2.3' }],
|
|
269
|
+
'POST /provisioning/instances/:id/update-channel': ['POST', `/provisioning/instances/${BAD}/update-channel`, { channel: 'nightly' }],
|
|
270
|
+
'POST /render-deploy/act': ['POST', '/render-deploy/act', { action: 'launch' }],
|
|
271
|
+
'POST /npm-release/release': ['POST', '/npm-release/release', { version: 'v-next' }],
|
|
272
|
+
'PATCH /builders/:id/budget': ['PATCH', `/builders/${BAD}/budget`, { monthly_budget_usd: 5 }],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
test('every listed route has a request in this test (a new act cannot go unexercised)', () => {
|
|
276
|
+
const listed = dr.AREAS.flatMap((a) => a.acts).sort();
|
|
277
|
+
assert.deepEqual(Object.keys(REQUESTS).sort(), listed);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const sitting = (area, rule = 'majority') => ({ area, decided_by: 'rank:council', rule, who: null, set_by_item_id: '30' });
|
|
281
|
+
|
|
282
|
+
let peopleWrites = 0;
|
|
283
|
+
const PEOPLE_DB = {
|
|
284
|
+
getRankByKey: async (k) => ({ rank_key: k, is_system: false, seeded_rank: null }),
|
|
285
|
+
assignRank: async () => { peopleWrites += 1; return true; },
|
|
286
|
+
unassignRank: async () => { peopleWrites += 1; return true; },
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
for (const a of dr.AREAS.filter((x) => x.acts.length)) {
|
|
290
|
+
test(`${a.name}: while a vote decides it, every direct route refuses one builder acting alone; as today each reaches its handler`, async () => {
|
|
291
|
+
peopleWrites = 0;
|
|
292
|
+
// A sitting at THIS building only: the refusal is the building's own row.
|
|
293
|
+
await stub(govDb, { ...PEOPLE_DB, listDecisionRules: async () => [sitting(a.key)], builderHoldsRankActive: async () => true }, async () => {
|
|
294
|
+
for (const act of a.acts) {
|
|
295
|
+
const [method, p, body] = REQUESTS[act];
|
|
296
|
+
const r = await call(method, p, body);
|
|
297
|
+
assert.equal(r.status, 409, `${act} under a vote → 409 (got ${r.status} ${JSON.stringify(r.json)})`);
|
|
298
|
+
assert.equal(r.json.error.code, 'decided_by_sitting', act);
|
|
299
|
+
const d = r.json.error.details || r.json.error;
|
|
300
|
+
assert.equal(d.area, a.key, `${act} names its own building`);
|
|
301
|
+
assert.equal(d.open_with, 'POST /government/board/matters', `${act} points at the sitting`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
assert.equal(peopleWrites, 0, 'nothing was written under the vote');
|
|
305
|
+
// Another building's sitting says nothing here, and with no row at all the
|
|
306
|
+
// route is exactly today's: its own handler answers (its own first check,
|
|
307
|
+
// or — for People's stubbed assignments — the write itself).
|
|
308
|
+
for (const rows of [[], [sitting(a.key === 'rules' ? 'planning' : 'rules')]]) {
|
|
309
|
+
await stub(govDb, { ...PEOPLE_DB, listDecisionRules: async () => rows, builderHoldsRankActive: async () => true }, async () => {
|
|
310
|
+
for (const act of a.acts) {
|
|
311
|
+
const [method, p, body] = REQUESTS[act];
|
|
312
|
+
const r = await call(method, p, body);
|
|
313
|
+
assert.notEqual(r.json && r.json.error && r.json.error.code, 'decided_by_sitting', `${act} as today`);
|
|
314
|
+
const ok = act.includes('/government/assignments') ? r.status < 300 : (r.status >= 400 && r.status < 500 && r.status !== 403);
|
|
315
|
+
assert.ok(ok, `${act} as today reaches its handler (got ${r.status} ${JSON.stringify(r.json)})`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test('decide alone: a member the row seats may still act alone; one it does not seat may not', async () => {
|
|
323
|
+
const [method, p, body] = REQUESTS['PATCH /builders/:id/budget'];
|
|
324
|
+
await stub(govDb, { listDecisionRules: async () => [sitting('treasury', 'decide')], builderHoldsRankActive: async () => true }, async () => {
|
|
325
|
+
assert.equal((await call(method, p, body)).status, 400, 'seated: through to the handler (bad id)');
|
|
326
|
+
});
|
|
327
|
+
await stub(govDb, { listDecisionRules: async () => [sitting('treasury', 'decide')], builderHoldsRankActive: async () => false }, async () => {
|
|
328
|
+
assert.equal((await call(method, p, body)).status, 409, 'not seated: refused');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('the check is cheap on the hot path: one table read, and no config-file read for any building\'s route', async () => {
|
|
333
|
+
let reads = 0;
|
|
334
|
+
let queries = 0;
|
|
335
|
+
const realRead = fs.readFileSync;
|
|
336
|
+
fs.readFileSync = (...args) => { reads += 1; return realRead(...args); };
|
|
337
|
+
try {
|
|
338
|
+
await stub(govDb, { listDecisionRules: async () => { queries += 1; return [sitting('review')]; }, builderHoldsRankActive: async () => true }, async () => {
|
|
339
|
+
for (const area of dr.AREA_KEYS.filter((k) => k !== 'charter')) await board.checkActAlone(area, '42');
|
|
340
|
+
});
|
|
341
|
+
} finally {
|
|
342
|
+
fs.readFileSync = realRead;
|
|
343
|
+
}
|
|
344
|
+
assert.equal(reads, 0, 'no synchronous file read per request (config.js is boot-only)');
|
|
345
|
+
assert.equal(queries, dr.AREA_KEYS.length - 1, 'one table read per check');
|
|
346
|
+
await stub(govDb, { listDecisionRules: async () => [sitting('review')] }, async () => {
|
|
347
|
+
assert.equal((await board.checkActAlone('review', '42')).ok, false, 'and the answer is unchanged');
|
|
348
|
+
assert.equal((await board.checkActAlone('rules', '42')).ok, true);
|
|
349
|
+
assert.equal((await board.checkActAlone('nowhere', '42')).reason, 'unknown_area');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('an unreadable table refuses a building\'s route 503 — never waved through', async () => {
|
|
354
|
+
await stub(govDb, { listDecisionRules: async () => { throw new Error('db down'); } }, async () => {
|
|
355
|
+
const [method, p, body] = REQUESTS['POST /goals'];
|
|
356
|
+
const r = await call(method, p, body);
|
|
357
|
+
assert.equal(r.status, 503);
|
|
358
|
+
assert.equal(r.json.error.code, 'decision_rule_unreadable');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ── 4. not a lone act ───────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
const autoDb = require(path.join(ROOT, 'modules', 'autonomy', 'db.js'));
|
|
365
|
+
|
|
366
|
+
test('the brake is never refused: the fence switched OFF and one runner paused, while a vote decides Automation', async () => {
|
|
367
|
+
const wrote = [];
|
|
368
|
+
await stub(autoDb, {
|
|
369
|
+
setFenceEnabled: async (a) => { wrote.push(['fence', a.enabled]); return { enabled: a.enabled }; },
|
|
370
|
+
setBuilderPause: async (a) => { wrote.push(['pause', a.paused]); return { builder_id: a.builderId, paused: a.paused }; },
|
|
371
|
+
}, async () => {
|
|
372
|
+
await stub(govDb, { listDecisionRules: async () => [sitting('automation', 'unanimous')], builderHoldsRankActive: async () => true }, async () => {
|
|
373
|
+
assert.equal((await call('POST', '/autonomy/fence', { enabled: false, reason: 'stop' })).status, 200);
|
|
374
|
+
assert.equal((await call('POST', '/autonomy/builders/7/pause', { paused: true })).status, 200);
|
|
375
|
+
assert.equal((await call('POST', '/autonomy/fence', { enabled: true })).status, 409, 'switching it ON is a decision');
|
|
376
|
+
assert.equal((await call('POST', '/autonomy/builders/7/pause', { paused: false })).status, 409, 'resuming is a decision');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
assert.deepEqual(wrote, [['fence', false], ['pause', true]]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('a dry run of the pin move only reads, so it is not refused under a vote', async () => {
|
|
383
|
+
await stub(govDb, { listDecisionRules: async () => [sitting('release')], builderHoldsRankActive: async () => true }, async () => {
|
|
384
|
+
const r = await call('POST', `/provisioning/instances/${BAD}/core-upgrade`, { to: '1.2.3', dry_run: true });
|
|
385
|
+
assert.notEqual(r.json && r.json.error && r.json.error.code, 'decided_by_sitting');
|
|
386
|
+
assert.equal((await call('POST', `/provisioning/instances/${BAD}/core-upgrade`, { to: '1.2.3' })).status, 409, 'the real move is');
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('a PASSED matter\'s act is the sitting itself: it runs while the gate would refuse every lone act', async () => {
|
|
391
|
+
const decisionBoard = require(path.join(GOV, 'decision-board.js'));
|
|
392
|
+
require(path.join(ROOT, 'modules', 'autonomy', 'matter-acts.js')).contributeAutomationMatterActs();
|
|
393
|
+
const wrote = [];
|
|
394
|
+
await stub(autoDb, { setFenceEnabled: async (a) => { wrote.push(a.enabled); return { enabled: a.enabled }; } }, async () => {
|
|
395
|
+
await stub(govDb, {
|
|
396
|
+
listDecisionRules: async () => dr.AREA_KEYS.filter((k) => k !== 'charter').map((k) => sitting(k, 'unanimous')),
|
|
397
|
+
builderHoldsRankActive: async () => true,
|
|
398
|
+
getMatter: async () => ({ id: '8', area: 'automation', title: 'Switch it on', action: { type: 'switch_on' }, proposed_by: '7' }),
|
|
399
|
+
}, async () => {
|
|
400
|
+
// the direct route is refused …
|
|
401
|
+
assert.equal((await call('POST', '/autonomy/fence', { enabled: true })).status, 409);
|
|
402
|
+
// … and the passed matter still acts, through the owning module's act
|
|
403
|
+
const out = await decisionBoard.actOnPassedMatter({ id: '40', subject_type: 'automation_matter', subject_id: '8', outcome: 'passed' });
|
|
404
|
+
assert.equal(out.result.done, true, JSON.stringify(out.result));
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
assert.deepEqual(wrote, [true], 'only the passed matter switched it');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('a PASSED Treasury matter sets the limit (core\'s act) while the budget route refuses a lone act', async () => {
|
|
411
|
+
const decisionBoard = require(path.join(GOV, 'decision-board.js'));
|
|
412
|
+
require(path.join(ROOT, 'src', 'bongos', 'routes', 'builders.js'))(); // contributes the Treasury act, as at boot
|
|
413
|
+
const set = [];
|
|
414
|
+
seams.registerProvider('reward', { setBuilderMonthlyBudget: async (id, usd) => { set.push([id, usd]); return { id, monthly_budget_usd: usd }; } });
|
|
415
|
+
try {
|
|
416
|
+
await stub(govDb, {
|
|
417
|
+
listDecisionRules: async () => [sitting('treasury', 'unanimous')],
|
|
418
|
+
builderHoldsRankActive: async () => true,
|
|
419
|
+
getMatter: async () => ({ id: '9', area: 'treasury', title: 'Lu gets $50 a month', action: { type: 'set_budget', builder_id: 7, monthly_usd: 50 }, proposed_by: '7' }),
|
|
420
|
+
}, async () => {
|
|
421
|
+
assert.equal((await call('PATCH', '/builders/7/budget', { monthly_budget_usd: 50 })).status, 409, 'the lone act is refused');
|
|
422
|
+
const out = await decisionBoard.actOnPassedMatter({ id: '41', subject_type: 'treasury_matter', subject_id: '9', outcome: 'passed' });
|
|
423
|
+
assert.equal(out.result.done, true, JSON.stringify(out.result));
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
seams._reset();
|
|
427
|
+
registerMainPort();
|
|
428
|
+
}
|
|
429
|
+
assert.deepEqual(set, [[7, 50]], 'only the passed matter wrote the limit');
|
|
430
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const PUBLISHED_SURFACE = [
|
|
|
22
22
|
'CORE_VERSION',
|
|
23
23
|
'requireBuilder', 'requireRank', 'rankMeetsThreshold', 'requireBfgPrincipal', 'allowBoxScope', 'resolveSession',
|
|
24
24
|
'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
|
|
25
|
+
'requireActAlone', // added by task 1004657 (ADR 0363 D5): a building's own page asks its decision-rules row before one builder acts alone. On the doorway because eight buildings' pages live in six modules and core, and each must refuse the same way People does
|
|
25
26
|
'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
|
|
26
27
|
// added by task 1003575 (ADR 0241 §3): the instance's PROJECT OWNER — its first
|
|
27
28
|
// admitted human builder. Kernel identity like getBuilderById beside it, and
|
|
@@ -180,7 +180,8 @@ test('the route is gated like the deploy page (core.pin.move), never by rank', (
|
|
|
180
180
|
const router = buildRouter();
|
|
181
181
|
const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/release');
|
|
182
182
|
assert.ok(layer.route.methods.post);
|
|
183
|
-
assert.equal(layer.route.stack.length,
|
|
183
|
+
assert.equal(layer.route.stack.length, 4, 'requireBuilder, the permission gate, Release\'s decision rule (task 1004657), then the handler');
|
|
184
|
+
assert.equal(layer.route.stack[2].handle.name, 'actAloneGate');
|
|
184
185
|
const src = fs.readFileSync(new URL('../modules/npm-release/routes/release.js', import.meta.url), 'utf8');
|
|
185
186
|
assert.match(src, /api\.requireBuilder,\s*\n\s*api\.requirePermission\('core\.pin\.move'\)/);
|
|
186
187
|
assert.doesNotMatch(src, /api\.requireRank\(/);
|
package/tests/onboard.mjs
CHANGED
|
@@ -81,15 +81,15 @@ test('every form OFFERS the optional step and names what skipping costs — skil
|
|
|
81
81
|
assert.match(cli, /Press Enter to skip/, 'the terminal form offers the skip in the prompt itself');
|
|
82
82
|
assert.match(cli, /off the public map/, 'and says what a missing description means');
|
|
83
83
|
|
|
84
|
-
// the web form — since task
|
|
85
|
-
//
|
|
86
|
-
//
|
|
84
|
+
// the web form — since task 1004648 the optional step is a field of part 01 of the one
|
|
85
|
+
// page, "Your project", whose label names what the sentence is for (the public map), and
|
|
86
|
+
// the team shape is part 02's Team row
|
|
87
87
|
const hub = read('modules/public-landing/public/projects.html').replace(/\r\n/g, '\n');
|
|
88
|
-
const what = (hub.match(/<
|
|
89
|
-
assert.ok(what, 'the wizard ships the
|
|
90
|
-
assert.match(what, /<label class="wizField"><span>In a sentence <small>\(optional
|
|
91
|
-
'the sentence is offered as optional, and its label says what
|
|
92
|
-
assert.match(hub, /<div class="setRow" data-row="team">[\s\S]*?id="teamCards"/, 'the team shape rides
|
|
88
|
+
const what = (hub.match(/<div class="part" id="part-project"[\s\S]*?\n {14}<\/div>\n/) || [''])[0];
|
|
89
|
+
assert.ok(what, 'the wizard ships the part that carries the sentence');
|
|
90
|
+
assert.match(what, /<label class="wizField"><span>In a sentence <small>\(optional, it goes on the public map\)<\/small><\/span>\s*<input type="text" id="f-desc"/,
|
|
91
|
+
'the sentence is offered as optional, and its label says what it is for');
|
|
92
|
+
assert.match(hub, /<div class="setRow" data-row="team">[\s\S]*?id="teamCards"/, 'the team shape rides part 02\'s Team row');
|
|
93
93
|
assert.doesNotMatch(hub, /id="panel-detail"|id="skipDetail"/, 'the old About panel and its skip control are gone');
|
|
94
94
|
|
|
95
95
|
// the route that receives it
|
|
@@ -452,7 +452,7 @@ test('a skip moves focus on rather than stranding it inside the box it just hid'
|
|
|
452
452
|
|
|
453
453
|
await ui.skip();
|
|
454
454
|
|
|
455
|
-
assert.deepEqual(ui.focused, ['
|
|
455
|
+
assert.deepEqual(ui.focused, ['wizInviteLater'], 'the line saying where inviting lives afterwards (task 1004648)');
|
|
456
456
|
});
|
|
457
457
|
|
|
458
458
|
test('a NEW project starts the step clean — the last one’s pending lines do not follow it', async () => {
|
|
@@ -569,16 +569,17 @@ test('the project page opens the step from its own manage read, which the API al
|
|
|
569
569
|
|
|
570
570
|
// ── the markup + the sheet ───────────────────────────────────────────────────
|
|
571
571
|
|
|
572
|
-
test('the wizard’s invite step ships as real markup
|
|
573
|
-
const panel = (HUB.match(/<
|
|
572
|
+
test('the wizard’s invite step ships as real markup in part 03, with the alive moment after the checklist', () => {
|
|
573
|
+
const panel = (HUB.match(/<div class="part waiting" id="panel-done"[\s\S]*?<\/section>/) || [''])[0];
|
|
574
574
|
assert.ok(panel, '#panel-done ships as real markup');
|
|
575
575
|
assert.match(panel, /<div class="inviteBox" id="wizInvite" hidden>/, 'the step is a real element the script only shows');
|
|
576
576
|
assert.match(panel, /id="wizInviteWho"/, 'the one field');
|
|
577
577
|
assert.match(panel, /data-invite-send="wizInvite"/, 'the sender');
|
|
578
578
|
assert.match(panel, /data-invite-skip="wizInvite"/, 'and the skip is a CONTROL, not "leave it and move on"');
|
|
579
579
|
assert.match(panel, /Optional/, 'the panel says so before it asks');
|
|
580
|
-
assert.ok(panel.indexOf('id="
|
|
581
|
-
'
|
|
580
|
+
assert.ok(panel.indexOf('id="humanSteps"') < panel.indexOf('id="done-h"') && panel.indexOf('id="done-h"') < panel.indexOf('id="wizInvite"'),
|
|
581
|
+
'the mockup\'s order: the checklist, then "is alive", then the invite (task 1004648)');
|
|
582
|
+
assert.match(panel, /Invite your first builders\. They become founding builders\./, 'in the mockup\'s words');
|
|
582
583
|
assert.match(panel, /<p class="samePath" id="wizInviteLater"><\/p>/, 'and the way back afterwards is a slot beside the modules one');
|
|
583
584
|
assert.match(SCRIPT, /\$\('wizInviteLater'\)\.innerHTML = 'You can invite builders any time from ' \+ projectPageLink\('the project’s own page'\) \+ '\.';/,
|
|
584
585
|
'filled with one sentence naming THIS project, the doneModules idiom');
|