@bongos/core 1.21.92 → 1.21.94
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 +86 -56
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +1 -0
- package/docs/api/openapi.json +38 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +354 -354
- package/docs/copy-registry.json +420 -420
- package/docs/design/project-startup-direction.md +7 -0
- package/docs/module-api-changelog.md +9 -0
- package/docs/page-readings.json +963 -992
- 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/github-setup.js +129 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +3 -0
- package/modules/provisioning/routes/callback-page.js +66 -1
- package/modules/provisioning/routes/core-upgrade.js +11 -0
- package/modules/provisioning/routes/github-setup.js +113 -0
- package/modules/provisioning/routes/provisioning.js +11 -45
- package/modules/public-landing/public/projects.html +105 -57
- 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/scripts/gds/oauth-secret.js +4 -2
- package/scripts/gds/provision-github-setup.js +148 -0
- package/scripts/gds/provision.js +19 -5
- 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/github_one_trip.mjs +297 -0
- 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/projects_hub_dns_ready.mjs +2 -1
- package/tests/projects_hub_pre_uat.mjs +17 -9
- package/tests/provision.mjs +51 -9
- package/tests/provisioning_github_app_routes.mjs +94 -8
- package/tests/ship_mode.mjs +2 -1
- package/tests/wizard_github_feedback.mjs +8 -5
|
@@ -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\(/);
|
|
@@ -165,7 +165,8 @@ test('every door to the address on the page asks addressReady', () => {
|
|
|
165
165
|
const SCRIPT = HUB;
|
|
166
166
|
assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">View<\/a>'\)/, 'the row\'s View');
|
|
167
167
|
assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">Open site →<\/a>'\)/, 'the manage page\'s Open site');
|
|
168
|
-
|
|
168
|
+
// task 1004649: "Builders can sign in" opens its door only on a resolving address
|
|
169
|
+
assert.match(SCRIPT, /var open = live\.instStatus === 'active' && live\.addrReady;\n\s*return '<li class="sdone" data-onboard-step="first-sign-in">/, 'the sign-in step\'s door waits for the address');
|
|
169
170
|
assert.match(SCRIPT, /live\.addrReady = addressReady\(inst\); renderSteps\(\);/, 'and unlock on the tick the lookup answers');
|
|
170
171
|
assert.equal((SCRIPT.match(/inst\.status !== 'active' \|\| !addressReady\(inst\)\) \{ (vis|join)Runs\(''\); return; \}/g) || []).length, 2,
|
|
171
172
|
'the browser\'s own manifest reads wait for the name too');
|
|
@@ -185,14 +185,18 @@ test('the 33-minute hand-off keeps a snag honest and keeps its flow', () => {
|
|
|
185
185
|
assert.match(SCRIPT, /progressMarkup\(1, 1, m\.slice\(5\), 'isErr', watchFlow\)/, 'the session-expiry message keeps its flow too');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
-
test('
|
|
188
|
+
test('"Builders can sign in" ticks on verified sign-in, and its door opens only when the project answers (task 1004649)', () => {
|
|
189
|
+
// the one-page checklist (task 1004649): the step's tick is the server's 'done';
|
|
190
|
+
// the door to the project still waits for a resolving address (task 1004505)
|
|
189
191
|
const first = fn('renderStepFirstSignIn');
|
|
192
|
+
assert.match(first, /<b>Builders can sign in<\/b>/);
|
|
193
|
+
assert.match(first, /if \(live\.manifest !== 'done'\) \{\n\s*return '<li class="slocked"/, 'locked until the platform has seen sign-in work');
|
|
194
|
+
assert.match(first, /var open = live\.instStatus === 'active' && live\.addrReady;/, 'the door gates on status and a resolving address');
|
|
195
|
+
assert.match(first, /\(open \? '<a class="ghbtn" href="https:\/\/' \+ esc\(domain\)/, 'and only then links the address');
|
|
196
|
+
// the hall is the "is alive" block's way in; the step survives only for the local hall
|
|
190
197
|
const hall = fn('renderStepEnterHall');
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
assert.match(body, /class="slocked"/, `${name} has a locked rendering`);
|
|
194
|
-
}
|
|
195
|
-
assert.match(first, /Unlocks once your project is answering and sign-in above is wired/);
|
|
198
|
+
assert.match(hall, /if \(domain\) return '';/);
|
|
199
|
+
assert.match(hall, /bongos dev/);
|
|
196
200
|
});
|
|
197
201
|
|
|
198
202
|
test('the domainless build is standing, not "Live"', () => {
|
|
@@ -208,7 +212,7 @@ test('the fallback checklist carries every human step the canonical plan has', (
|
|
|
208
212
|
test('the manifest step acknowledges code_received, offers a restart after a stall, and rides the CREATED record', () => {
|
|
209
213
|
// 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
|
|
210
214
|
assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
|
|
211
|
-
assert.match(SCRIPT, /GitHub’s
|
|
215
|
+
assert.match(SCRIPT, /Approved on GitHub\. The platform is placing the app’s keys on your project/);
|
|
212
216
|
assert.match(SCRIPT, /manifestStale \? '<button type="button" class="linklike" data-action="manifest-start">/, 'a stalled wait gets a restart door');
|
|
213
217
|
assert.match(SCRIPT, /\(Date\.now\(\) - manifestStartedAt\) > 90000/, 'after ~90 seconds');
|
|
214
218
|
assert.match(SCRIPT, /function rememberManifest\(stateToken, status, appSlug\)/);
|
|
@@ -610,10 +614,14 @@ test('teardown copy is state-aware: a queued project is told nothing was built',
|
|
|
610
614
|
|
|
611
615
|
test('founder voice on the Device Flow step and the manual OAuth fallback', () => {
|
|
612
616
|
const df = fn('renderStepDeviceFlow');
|
|
613
|
-
|
|
617
|
+
// the mockup's words (task 1004649): one title, in every branch
|
|
618
|
+
assert.match(df, /var title = '<b>Command-line sign-in is on<\/b>';/, 'the outcome-first title');
|
|
614
619
|
assert.doesNotMatch(df, /<b>Enable Device Flow — one checkbox<\/b>/);
|
|
615
620
|
assert.match(df, /the <code style="font-family:var\(--font-mono\)">bongos setup<\/code> command builders run on their own machine/, 'the command is glossed, not bare-coded');
|
|
616
|
-
|
|
621
|
+
// GitHub has no manifest field and no API for device flow (task 1004649's spike), so the
|
|
622
|
+
// step is OPTIONAL by design and never holds the project back
|
|
623
|
+
assert.equal((df.match(/Optional — turn it on later\./g) || []).length, 2, 'the not-yet branches say it is optional');
|
|
624
|
+
assert.equal((df.match(/class="[^"]*soptional/g) || []).length, 3, 'every branch is marked optional, so "connected" never waits on it');
|
|
617
625
|
assert.equal((df.match(/<strong>Enable Device Flow<\/strong>/g) || []).length, 2, 'GitHub’s own checkbox label stays verbatim in the instructions');
|
|
618
626
|
const manual = fn('manualOauthDetails');
|
|
619
627
|
assert.match(manual, /The platform team places the generated credentials; this step ticks itself once they land\.<\/p><\/details>/);
|
package/tests/provision.mjs
CHANGED
|
@@ -1740,13 +1740,54 @@ await ta('drainOAuthManifests (--apply): claims a code_received row, places cred
|
|
|
1740
1740
|
};
|
|
1741
1741
|
const r = await P.drainOAuthManifests(deps);
|
|
1742
1742
|
assert.deepEqual(r, { converted: 1, errors: 0 });
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
assert.
|
|
1746
|
-
assert.
|
|
1743
|
+
// task 1004649: the slug lands the moment the creds are placed (still 'converting'),
|
|
1744
|
+
// so the callback page can send the founder on to the install before sign-in is checked
|
|
1745
|
+
assert.deepEqual(resolved.map((x) => x.status), ['converting', 'done']);
|
|
1746
|
+
assert.deepEqual(resolved[0].patch, { app_id: 3, app_slug: 'app-3' });
|
|
1747
|
+
assert.equal(resolved[1].patch.app_slug, 'app-3');
|
|
1748
|
+
assert.equal(resolved[1].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
|
|
1747
1749
|
assert.ok(events.includes('oauth-manifest-done'));
|
|
1748
1750
|
});
|
|
1749
1751
|
|
|
1752
|
+
await ta('the sign-in check reads the JSON health answer, never the root /healthz "ok" (task 1004577)', async () => {
|
|
1753
|
+
// The REAL answers of the two endpoints (src/bongos/serve-internal.js answers GET /healthz
|
|
1754
|
+
// with a plain "ok"; src/bongos/routes/healthz.js answers /api/bongos/healthz with JSON).
|
|
1755
|
+
// The old probe hit /healthz, never parsed, and recorded every working setup as FAILED.
|
|
1756
|
+
const real = (cmd) => (/\/api\/bongos\/healthz/.test(cmd) ? '{"ok":true,"auth_configured":true}'
|
|
1757
|
+
: /\/healthz/.test(cmd) ? 'ok' : '');
|
|
1758
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1759
|
+
const probes = [];
|
|
1760
|
+
const deps = {
|
|
1761
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1762
|
+
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 7, appSlug: 'mercury-app' }),
|
|
1763
|
+
exec: (cmd) => { if (/healthz/.test(cmd)) probes.push(cmd); return { ok: true, stdout: real(cmd) }; },
|
|
1764
|
+
writeFile: () => ({ ok: true }),
|
|
1765
|
+
};
|
|
1766
|
+
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1767
|
+
assert.equal(r.ok, true, 'a project whose JSON health says auth_configured is verified');
|
|
1768
|
+
assert.ok(probes.length >= 1 && probes.every((c) => /\/api\/bongos\/healthz/.test(c)), 'only the JSON endpoint is asked');
|
|
1769
|
+
assert.equal(P.authHealthzCmd({ port: 4100 }), 'curl -sS --max-time 5 http://127.0.0.1:4100/api/bongos/healthz');
|
|
1770
|
+
// and a plain "ok" is no verdict either way: it never reads as configured
|
|
1771
|
+
const plain = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, { ...deps, exec: () => ({ ok: true, stdout: 'ok' }) });
|
|
1772
|
+
assert.equal(plain.ok, false, 'a bare "ok" never confirms sign-in');
|
|
1773
|
+
const oauthSecret = require('../scripts/gds/oauth-secret.js');
|
|
1774
|
+
assert.deepEqual(oauthSecret.parseHealthzAuth('ok'), { ok: false, authConfigured: false, parseError: true });
|
|
1775
|
+
assert.match(readFileSync(new URL('../scripts/gds/oauth-secret.js', import.meta.url), 'utf8'), /127\.0\.0\.1:\$\{port\}\/api\/bongos\/healthz/, 'the manual placement probes the same JSON answer');
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
await ta('the slug is recorded only AFTER the credentials are placed (task 1004649)', async () => {
|
|
1779
|
+
const order = [];
|
|
1780
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1781
|
+
await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, {
|
|
1782
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1783
|
+
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 7, appSlug: 'mercury-app' }),
|
|
1784
|
+
exec: (cmd) => { if (/systemctl restart/.test(cmd)) order.push('restart'); return { ok: true, stdout: '{"auth_configured":true}' }; },
|
|
1785
|
+
writeFile: (p) => { if (/web\.env$/.test(p)) order.push('write'); return { ok: true }; },
|
|
1786
|
+
onCredsPlaced: async (c) => { order.push(`slug:${c.appSlug}`); },
|
|
1787
|
+
});
|
|
1788
|
+
assert.deepEqual(order, ['write', 'restart', 'slug:mercury-app'], 'the first tick means the keys are on the project');
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1750
1791
|
await ta('a SPENT code is never re-converted — terminal error, no code_received reset (F23)', async () => {
|
|
1751
1792
|
// A stateful one-row store: claimNext honors status, resolve writes it back —
|
|
1752
1793
|
// exactly the reset loop that used to re-convert a burned code on the next tick.
|
|
@@ -1804,11 +1845,12 @@ await ta('a sign-in check that never confirms still records WHICH App GitHub mad
|
|
|
1804
1845
|
recordEvent: async () => {},
|
|
1805
1846
|
},
|
|
1806
1847
|
});
|
|
1807
|
-
|
|
1808
|
-
assert.
|
|
1809
|
-
|
|
1810
|
-
assert.equal(
|
|
1811
|
-
assert.equal(
|
|
1848
|
+
// the slug is recorded at placement (task 1004649), then the row settles as an error
|
|
1849
|
+
assert.deepEqual(resolves.map((x) => x.status), ['converting', 'error']);
|
|
1850
|
+
const settled = resolves[1];
|
|
1851
|
+
assert.equal(settled.patch.app_slug, 'mercury-cloudbongos-com', 'the error row names the App');
|
|
1852
|
+
assert.equal(settled.patch.app_id, 5189633);
|
|
1853
|
+
assert.equal(settled.patch.github_code, null, 'the burned code is still scrubbed');
|
|
1812
1854
|
});
|
|
1813
1855
|
|
|
1814
1856
|
await ta('a failure BEFORE GitHub handed over credentials claims no App (task 1004572)', async () => {
|