@bongos/core 1.21.70 → 1.21.71
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 +114 -59
- package/.claude/skills/goal-create/SKILL.md +21 -3
- 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 +5 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +46 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +25 -21
- package/docs/copy-registry.json +76 -30
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +8 -8
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +37 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +3 -3
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +11 -1
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- package/tests/upkeep_home.mjs +156 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// tests/upgrade_request.mjs — "Ask for this upgrade" on Settings → Software update
|
|
2
|
+
// (task 1004595).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. Will's mercury, a project the Cloud Bongos operator runs, said "Update
|
|
5
|
+
// ready" and then nothing: no button, no next step. The fix gives its owner one press that
|
|
6
|
+
// reaches the operator and then says it is pending. So these tests hold that:
|
|
7
|
+
// - the press is offered only on a project the hub's operator runs, never on a
|
|
8
|
+
// self-hosted one (there is nobody at the hub to do it);
|
|
9
|
+
// - it reaches the operator through the direct feedback door, with a receipt the
|
|
10
|
+
// project's PUBLIC redeem route will vouch for (the hub files nothing without it);
|
|
11
|
+
// - it is sent once per version, remembered, and the page then shows it pending;
|
|
12
|
+
// - nothing in this file touches the network or a database.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/upgrade_request.mjs
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const ur = require('../src/bongos/upgrade-request.js');
|
|
27
|
+
const { makeReceiptStore } = require('../src/bongos/routes/feedback.js');
|
|
28
|
+
|
|
29
|
+
const HUB = 'https://cloudbongos.com';
|
|
30
|
+
const WILL = { id: '12', github_id: '4242', github_login: 'will-x' };
|
|
31
|
+
const T0 = Date.parse('2026-10-06T21:00:00Z');
|
|
32
|
+
|
|
33
|
+
/** A hub that records what it was sent and answers `status`. */
|
|
34
|
+
function fakeHub(status = 202) {
|
|
35
|
+
const sent = [];
|
|
36
|
+
const fetchImpl = async (url, init) => { sent.push({ url, init, body: JSON.parse(init.body) }); return { status }; };
|
|
37
|
+
return { sent, fetchImpl };
|
|
38
|
+
}
|
|
39
|
+
/** project_settings in memory. */
|
|
40
|
+
function fakeSettings(initial = null) {
|
|
41
|
+
const rows = new Map();
|
|
42
|
+
if (initial) rows.set(ur.SETTING_KEY, { key: ur.SETTING_KEY, value: JSON.stringify(initial) });
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
getSetting: async (k) => rows.get(k) || null,
|
|
46
|
+
setSetting: async (k, v, by) => { const r = { key: k, value: String(v), updated_by: by }; rows.set(k, r); return r; },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function deps({ hub = fakeHub(), settings = fakeSettings(), store = makeReceiptStore(), now = () => T0 } = {}) {
|
|
50
|
+
return { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r), now, _hub: hub, _settings: settings, _store: store };
|
|
51
|
+
}
|
|
52
|
+
const ASK = { host: 'mercury.cloudbongos.com', running: '1.21.20', wanted: '1.21.66', builder: WILL, doorKind: 'cli' };
|
|
53
|
+
|
|
54
|
+
// ---- who is offered the press -------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
test('offered on a project the hub runs (by its door, or by its name under the hub), never on a self-hosted one', () => {
|
|
57
|
+
const base = { offered: '1.21.66', hub: HUB, stored: null };
|
|
58
|
+
assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, true, 'Will\'s mercury: no door, hosted name');
|
|
59
|
+
assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'hub' }).available, true, 'a hosted project on its own domain, with a hub door');
|
|
60
|
+
assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'cli' }).available, false, 'self-hosted: nobody at the hub upgrades it');
|
|
61
|
+
assert.equal(ur.askState({ ...base, host: 'cloudbongos.com', doorKind: 'local' }).available, false, 'the platform runs its own door');
|
|
62
|
+
assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli', hub: null }).available, false, 'no hub to ask');
|
|
63
|
+
assert.equal(ur.askState({ ...base, offered: null, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, false, 'up to date: nothing to ask for');
|
|
64
|
+
assert.equal(ur.askState({ ...base, host: 'localhost:4600', doorKind: 'hub' }).available, false, 'a host the hub cannot reach to check the receipt');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---- the press itself ---------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test('a press reaches the operator through the direct feedback door, and is remembered', async () => {
|
|
70
|
+
const d = deps();
|
|
71
|
+
const out = await ur.requestUpgrade(ASK, d);
|
|
72
|
+
assert.equal(out.status, 201);
|
|
73
|
+
assert.deepEqual(out.body.request, { version: '1.21.66', requested_at: new Date(T0).toISOString(), by: 'will-x' });
|
|
74
|
+
|
|
75
|
+
assert.equal(d._hub.sent.length, 1);
|
|
76
|
+
const { url, body, init } = d._hub.sent[0];
|
|
77
|
+
assert.equal(url, `${HUB}/api/bongos/sso/feedback/direct`);
|
|
78
|
+
assert.equal(init.redirect, 'error');
|
|
79
|
+
assert.equal(body.origin, 'https://mercury.cloudbongos.com', 'the hub redeems the receipt at this project');
|
|
80
|
+
assert.equal(body.kind, 'bug');
|
|
81
|
+
assert.match(body.message.split('\n')[0], /^Upgrade request: mercury\.cloudbongos\.com asks for core 1\.21\.66 \(it runs 1\.21\.20\)$/);
|
|
82
|
+
assert.match(body.message, /@will-x pressed "Ask for this upgrade"/);
|
|
83
|
+
|
|
84
|
+
// The receipt is the one the hub will redeem: same sender, same text hash.
|
|
85
|
+
const receipt = d._store.redeem(body.receipt);
|
|
86
|
+
assert.equal(receipt.github_id, '4242');
|
|
87
|
+
assert.equal(receipt.github_login, 'will-x');
|
|
88
|
+
assert.equal(receipt.sha256, crypto.createHash('sha256').update(body.message, 'utf8').digest('hex'));
|
|
89
|
+
assert.equal(receipt.kind, 'bug');
|
|
90
|
+
|
|
91
|
+
const stored = ur.parseStored(d._settings.rows.get(ur.SETTING_KEY));
|
|
92
|
+
assert.deepEqual(stored, out.body.request, 'remembered, so the page can say it is pending');
|
|
93
|
+
assert.equal(d._settings.rows.get(ur.SETTING_KEY).updated_by, '12');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('pressing again for the same version sends nothing and answers from the stored request', async () => {
|
|
97
|
+
const d = deps({ settings: fakeSettings({ version: '1.21.66', requested_at: '2026-09-01T00:00:00Z', by: 'will-x' }) });
|
|
98
|
+
const out = await ur.requestUpgrade(ASK, d);
|
|
99
|
+
assert.equal(out.status, 200);
|
|
100
|
+
assert.equal(out.body.already, true);
|
|
101
|
+
assert.equal(out.body.request.requested_at, '2026-09-01T00:00:00.000Z');
|
|
102
|
+
assert.equal(d._hub.sent.length, 0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a newer version may be asked for, but not twice inside a day', async () => {
|
|
106
|
+
const recent = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 3600e3).toISOString(), by: 'will-x' });
|
|
107
|
+
const d1 = deps({ settings: recent });
|
|
108
|
+
const out = await ur.requestUpgrade(ASK, d1);
|
|
109
|
+
assert.equal(out.status, 429);
|
|
110
|
+
assert.equal(out.body.error, 'asked_recently');
|
|
111
|
+
assert.match(out.body.message, /already asked for an upgrade today/, 'every refusal carries a sentence the page can show');
|
|
112
|
+
assert.equal(out.body.request.version, '1.21.60', 'the refusal carries the pending request, so the page can show it');
|
|
113
|
+
assert.equal(d1._hub.sent.length, 0);
|
|
114
|
+
|
|
115
|
+
const old = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 2 * 86400e3).toISOString(), by: 'will-x' });
|
|
116
|
+
const d2 = deps({ settings: old });
|
|
117
|
+
assert.equal((await ur.requestUpgrade(ASK, d2)).status, 201);
|
|
118
|
+
assert.equal(d2._hub.sent.length, 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('nothing is sent, and nothing remembered, when there is no one to ask or nothing to ask for', async () => {
|
|
122
|
+
for (const over of [{ doorKind: 'cli', host: 'shop.example.org' }, { doorKind: 'local' }, { wanted: null }, { wanted: '1.21.66"><b>' }]) {
|
|
123
|
+
const d = deps();
|
|
124
|
+
const out = await ur.requestUpgrade({ ...ASK, ...over }, d);
|
|
125
|
+
assert.equal(out.status, 409, JSON.stringify(over));
|
|
126
|
+
assert.equal(d._hub.sent.length, 0);
|
|
127
|
+
assert.equal(d._settings.rows.size, 0);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a hub that does not take it, or cannot be reached, leaves the request unasked so it can be pressed again', async () => {
|
|
132
|
+
for (const hub of [fakeHub(503), fakeHub(400), { sent: [], fetchImpl: async () => { throw new Error('ECONNRESET'); } }]) {
|
|
133
|
+
const d = deps({ hub });
|
|
134
|
+
const out = await ur.requestUpgrade(ASK, d);
|
|
135
|
+
assert.equal(out.status, 502);
|
|
136
|
+
assert.equal(out.body.error, 'operator_unreachable');
|
|
137
|
+
assert.equal(d._settings.rows.size, 0);
|
|
138
|
+
}
|
|
139
|
+
const limited = deps({ hub: fakeHub(429) });
|
|
140
|
+
const lim = await ur.requestUpgrade(ASK, limited);
|
|
141
|
+
assert.equal(lim.status, 429);
|
|
142
|
+
assert.match(lim.body.message, /Try again in an hour/);
|
|
143
|
+
assert.equal(limited._settings.rows.size, 0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a stored row this file did not write reads as not asked', () => {
|
|
147
|
+
for (const value of ['', 'nope', '{}', JSON.stringify({ version: 'x', requested_at: '2026-10-01' }), JSON.stringify({ version: '1.2.3', requested_at: 'never' })]) {
|
|
148
|
+
assert.equal(ur.parseStored({ value }), null, value);
|
|
149
|
+
}
|
|
150
|
+
assert.equal(ur.parseStored({ value: JSON.stringify({ version: '1.2.3', requested_at: '2026-10-01T00:00:00Z', by: '<script>' }) }).by, null);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---- the receipt is redeemable at the project's PUBLIC route -------------------------
|
|
154
|
+
|
|
155
|
+
test('the receipt the press issues is the one the public redeem route answers (one shared store)', async () => {
|
|
156
|
+
const fb = require('../src/bongos/routes/feedback.js');
|
|
157
|
+
const express = require('express');
|
|
158
|
+
const app = express();
|
|
159
|
+
app.use(require('../src/bongos/middleware/error-envelope.js').attachFail);
|
|
160
|
+
app.use(fb());
|
|
161
|
+
const hub = fakeHub();
|
|
162
|
+
const settings = fakeSettings();
|
|
163
|
+
const out = await ur.requestUpgrade(ASK, { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting,
|
|
164
|
+
issueReceipt: (r) => fb.defaultReceiptStore().issue(r), now: () => T0 });
|
|
165
|
+
assert.equal(out.status, 201);
|
|
166
|
+
const server = app.listen(0);
|
|
167
|
+
await new Promise((r) => server.once('listening', r));
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/feedback/receipt/${hub.sent[0].body.receipt}`);
|
|
170
|
+
assert.equal(res.status, 200);
|
|
171
|
+
const body = await res.json();
|
|
172
|
+
assert.equal(body.github_id, '4242');
|
|
173
|
+
assert.equal(body.sha256, crypto.createHash('sha256').update(hub.sent[0].body.message, 'utf8').digest('hex'));
|
|
174
|
+
} finally { server.close(); }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---- the route ----------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
|
|
180
|
+
|
|
181
|
+
test('[source] the press is gated on the same atom as the Update button', () => {
|
|
182
|
+
const at = ROUTE_SRC.indexOf("'/software-update/request'");
|
|
183
|
+
assert.ok(at > 0);
|
|
184
|
+
const seg = ROUTE_SRC.slice(at, ROUTE_SRC.indexOf("asyncHandler('POST /software-update/request'"));
|
|
185
|
+
assert.match(seg, /auth\.requireBuilder/);
|
|
186
|
+
assert.match(seg, /auth\.requirePermission\('core\.pin\.move'\)/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
async function serve(deps, { forwardedHost = 'evil.example' } = {}) {
|
|
190
|
+
const auth = require('../src/bongos/auth.js');
|
|
191
|
+
const saved = { b: auth.requireBuilder, p: auth.requirePermission };
|
|
192
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { ...WILL }; next(); };
|
|
193
|
+
auth.requirePermission = () => (_req, _res, next) => next();
|
|
194
|
+
const build = require('../src/bongos/routes/core-update.js');
|
|
195
|
+
const express = require('express');
|
|
196
|
+
const app = express();
|
|
197
|
+
app.use(express.json());
|
|
198
|
+
app.use((_req, res, next) => { res.fail = (code, o) => res.status(o.status).json({ error: { code, message: o.message || code, details: o.details } }); next(); });
|
|
199
|
+
try { app.use(build(deps)); } finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
|
|
200
|
+
const server = app.listen(0);
|
|
201
|
+
await new Promise((r) => server.once('listening', r));
|
|
202
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
203
|
+
const call = async (method, p) => {
|
|
204
|
+
const res = await fetch(`${base}${p}`, { method, headers: { 'X-Forwarded-Host': forwardedHost, Host: forwardedHost, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined });
|
|
205
|
+
return { status: res.status, body: await res.json() };
|
|
206
|
+
};
|
|
207
|
+
return { call, close: () => server.close() };
|
|
208
|
+
}
|
|
209
|
+
const UPDATE = { reading: 'ok', running: '1.21.20', offered: '1.21.66' };
|
|
210
|
+
|
|
211
|
+
test('GET then POST then GET: offered, sent once, then shown as pending', async () => {
|
|
212
|
+
// A hub that takes a moment, so the two presses below truly overlap.
|
|
213
|
+
const hub = fakeHub();
|
|
214
|
+
const quick = hub.fetchImpl;
|
|
215
|
+
hub.fetchImpl = async (...a) => { await new Promise((r) => setTimeout(r, 40)); return quick(...a); };
|
|
216
|
+
const settings = fakeSettings();
|
|
217
|
+
const store = makeReceiptStore();
|
|
218
|
+
const s = await serve({
|
|
219
|
+
softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true,
|
|
220
|
+
doorInputs: () => ({ provisioning: false, idp: null }),
|
|
221
|
+
askDeps: () => ({ hub: HUB, host: 'mercury.cloudbongos.com', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r) }),
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
let r = await s.call('GET', '/software-update');
|
|
225
|
+
assert.equal(r.status, 200);
|
|
226
|
+
assert.deepEqual(r.body.door, { kind: 'cli', url: null });
|
|
227
|
+
assert.equal(r.body.ask.available, true);
|
|
228
|
+
assert.equal(r.body.ask.hub_host, 'cloudbongos.com');
|
|
229
|
+
assert.equal(r.body.ask.requested, null);
|
|
230
|
+
|
|
231
|
+
const [a, b] = await Promise.all([s.call('POST', '/software-update/request'), s.call('POST', '/software-update/request')]);
|
|
232
|
+
assert.deepEqual([a.status, b.status].sort(), [200, 201], 'a double press sends once; the second is answered from the row');
|
|
233
|
+
assert.equal(hub.sent.length, 1);
|
|
234
|
+
assert.equal(hub.sent[0].body.origin, 'https://mercury.cloudbongos.com',
|
|
235
|
+
'the call-back address is the project\'s own configured host, never the request\'s (X-Forwarded-Host: evil.example here)');
|
|
236
|
+
|
|
237
|
+
r = await s.call('GET', '/software-update');
|
|
238
|
+
assert.equal(r.body.ask.requested.version, '1.21.66');
|
|
239
|
+
assert.equal(r.body.ask.requested.by, 'will-x');
|
|
240
|
+
} finally { s.close(); }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('the version asked for is the server\'s own reading, and a self-hosted project is refused', async () => {
|
|
244
|
+
const hub = fakeHub();
|
|
245
|
+
const settings = fakeSettings();
|
|
246
|
+
const askDeps = () => ({ hub: HUB, host: 'shop.example.org', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: () => 'r'.repeat(24) });
|
|
247
|
+
const common = { softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }), askDeps };
|
|
248
|
+
// A self-hosted project whose caller claims a hosted name in the headers is still refused.
|
|
249
|
+
const self = await serve(common, { forwardedHost: 'mercury.cloudbongos.com' });
|
|
250
|
+
try {
|
|
251
|
+
const r = await self.call('POST', '/software-update/request');
|
|
252
|
+
assert.equal(r.status, 409);
|
|
253
|
+
assert.equal(r.body.error.code, 'not_operator_run');
|
|
254
|
+
assert.equal((await self.call('GET', '/software-update')).body.ask.available, false);
|
|
255
|
+
assert.equal(hub.sent.length, 0);
|
|
256
|
+
} finally { self.close(); }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---- the panel ----------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
|
|
262
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
263
|
+
|
|
264
|
+
function bootPanel(answers) {
|
|
265
|
+
const el = (id) => ({ id, hidden: true, disabled: false, textContent: '', _html: '', dataset: {}, handlers: {},
|
|
266
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
267
|
+
querySelectorAll: () => [], addEventListener(ev, fn) { this.handlers[ev] = fn; } });
|
|
268
|
+
const els = new Map(['software-update-scroll', 'su-body', 'su-note', 'settings-groups'].map((id) => [id, el(id)]));
|
|
269
|
+
els.get('settings-groups').dataset.active = 'update';
|
|
270
|
+
// Buttons drawn into su-body exist only while its html carries their id.
|
|
271
|
+
const dynamic = new Map();
|
|
272
|
+
const getElementById = (id) => {
|
|
273
|
+
if (els.has(id)) return els.get(id);
|
|
274
|
+
if (!els.get('su-body').innerHTML.includes(`id="${id}"`)) return null;
|
|
275
|
+
const key = `${id}@${els.get('su-body').innerHTML.length}:${els.get('su-body').innerHTML.slice(0, 40)}`;
|
|
276
|
+
if (!dynamic.has(key)) dynamic.set(key, el(id));
|
|
277
|
+
return dynamic.get(key);
|
|
278
|
+
};
|
|
279
|
+
const calls = [];
|
|
280
|
+
const windowObj = {
|
|
281
|
+
BongosClient: { createClient: () => ({ request: async (m, url, opts) => { calls.push({ m, url, opts }); return answers(m, url); } }) },
|
|
282
|
+
OTB: { escapeHtml },
|
|
283
|
+
location: { assign: () => {} },
|
|
284
|
+
};
|
|
285
|
+
const sandbox = { window: windowObj, document: { getElementById, addEventListener: () => {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
|
|
286
|
+
vm.createContext(sandbox);
|
|
287
|
+
vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
|
|
288
|
+
const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
289
|
+
return { calls, settle, body: () => els.get('su-body').innerHTML, byId: getElementById };
|
|
290
|
+
}
|
|
291
|
+
const ok = (data, status = 200) => ({ ok: status < 400, status, data });
|
|
292
|
+
const PAGE = { ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.66', whats_new: null }, door: { kind: 'cli', url: null }, can_update: true,
|
|
293
|
+
ask: { available: true, hub_host: 'cloudbongos.com', requested: null, ask_again_after: null } };
|
|
294
|
+
|
|
295
|
+
test('Will\'s page: who upgrades it, and an Ask button, never a server command', async () => {
|
|
296
|
+
const b = bootPanel(() => ok(PAGE));
|
|
297
|
+
await b.settle();
|
|
298
|
+
assert.match(b.body(), /the team at cloudbongos\.com looks after, so they install its updates/);
|
|
299
|
+
assert.match(b.body(), /id="su-ask">Ask for this upgrade</);
|
|
300
|
+
assert.doesNotMatch(b.body(), /bongos upgrade/);
|
|
301
|
+
assert.match(b.body(), /Update ready/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('pressing it sends the request and the page then says it is pending', async () => {
|
|
305
|
+
const request = { version: '1.21.66', requested_at: '2026-10-06T21:00:00.000Z', by: 'will-x' };
|
|
306
|
+
const b = bootPanel((m) => (m === 'POST' ? ok({ ok: true, already: false, request }, 201) : ok(PAGE)));
|
|
307
|
+
await b.settle();
|
|
308
|
+
await b.byId('su-ask').handlers.click();
|
|
309
|
+
await b.settle();
|
|
310
|
+
assert.deepEqual(b.calls.map((c) => `${c.m} ${c.url}`), ['GET /api/bongos/software-update', 'POST /api/bongos/software-update/request']);
|
|
311
|
+
assert.match(b.body(), /Upgrade requested on 6 October 2026 by @will-x, for version <span class="su-ver">1\.21\.66<\/span>\. The team at cloudbongos\.com has your request/);
|
|
312
|
+
assert.match(b.body(), /<span class="su-pill">Requested<\/span>/);
|
|
313
|
+
assert.doesNotMatch(b.body(), /id="su-ask"/, 'no second press offered');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('a page loaded after asking shows it pending; a failed press says why and can be pressed again', async () => {
|
|
317
|
+
const asked = { ...PAGE, ask: { ...PAGE.ask, requested: { version: '1.21.66', requested_at: '2026-10-05T10:00:00Z', by: null }, ask_again_after: '2026-10-06T10:00:00Z' } };
|
|
318
|
+
const p = bootPanel(() => ok(asked));
|
|
319
|
+
await p.settle();
|
|
320
|
+
assert.match(p.body(), /Upgrade requested on 5 October 2026, for version/);
|
|
321
|
+
assert.doesNotMatch(p.body(), /id="su-ask"/);
|
|
322
|
+
|
|
323
|
+
const f = bootPanel((m) => (m === 'POST' ? ok({ error: { code: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } }, 502) : ok(PAGE)));
|
|
324
|
+
await f.settle();
|
|
325
|
+
const btn = f.byId('su-ask');
|
|
326
|
+
await btn.handlers.click();
|
|
327
|
+
await f.settle();
|
|
328
|
+
assert.equal(btn.disabled, false);
|
|
329
|
+
assert.equal(f.byId('su-ask-status').textContent, 'Could not send the request: Could not reach the people who run this project. Try again in a few minutes.');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('a hosted project with a hub door keeps its Update button and offers asking as the fallback', async () => {
|
|
333
|
+
const b = bootPanel(() => ok({ ...PAGE, door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' } }));
|
|
334
|
+
await b.settle();
|
|
335
|
+
assert.match(b.body(), /id="su-update" href="https:\/\/cloudbongos\.com\/deploy\?project=mercury"/);
|
|
336
|
+
assert.match(b.body(), /If that page does not let you in, ask the team at cloudbongos\.com/);
|
|
337
|
+
assert.match(b.body(), /class="pbtn pbtn--quiet" id="su-ask"/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('no ask for a viewer who may not upgrade, or where the server does not offer it', async () => {
|
|
341
|
+
const viewer = bootPanel(() => ok({ ...PAGE, can_update: false }));
|
|
342
|
+
await viewer.settle();
|
|
343
|
+
assert.doesNotMatch(viewer.body(), /id="su-ask"/);
|
|
344
|
+
const self = bootPanel(() => ok({ ...PAGE, ask: { ...PAGE.ask, available: false } }));
|
|
345
|
+
await self.settle();
|
|
346
|
+
assert.doesNotMatch(self.body(), /id="su-ask"/);
|
|
347
|
+
assert.match(self.body(), /<code>bongos upgrade<\/code>/);
|
|
348
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// tests/upkeep_home.mjs — platform upkeep always has a home (task 1004597).
|
|
2
|
+
//
|
|
3
|
+
// THE BUG. On an instance whose goals are all product goals, a builder about to
|
|
4
|
+
// upgrade the Bongos core tried to record it first (the ledger rule) and POST
|
|
5
|
+
// /tasks refused `goal_id_required` (the goal rule) — none of the goals fit. The
|
|
6
|
+
// session gave up rather than misfile. The two rules contradicted each other.
|
|
7
|
+
//
|
|
8
|
+
// THE FIX. A goal-less filing with an EXPLICIT kind 'infra' lands in the building
|
|
9
|
+
// version's maintenance goal (the same find-or-create home ADR 0262 gives a
|
|
10
|
+
// homeless bug). Anything else without a goal is still refused — and the refusal
|
|
11
|
+
// now names that home and the exact flag to use.
|
|
12
|
+
//
|
|
13
|
+
// These drive the REAL POST /tasks handler with a stubbed lifecycle db, so the
|
|
14
|
+
// wiring (not just the helper) is what is under test.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/upkeep_home.mjs
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
process.env.NODE_ENV = 'test';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
const { isUpkeepFiling, resolveUpkeepHome, UPKEEP_KIND } = require('../modules/lifecycle/upkeep-home.js');
|
|
26
|
+
|
|
27
|
+
// ---- the rule ---------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
test('only an explicit infra kind with no goal counts as upkeep', () => {
|
|
30
|
+
assert.equal(UPKEEP_KIND, 'infra');
|
|
31
|
+
assert.equal(isUpkeepFiling({ kind: 'infra' }), true);
|
|
32
|
+
assert.equal(isUpkeepFiling({ kind: 'infra', goal_id: 7 }), false, 'a named goal always wins');
|
|
33
|
+
for (const kind of [undefined, 'feature', 'bug', 'cleanup', 'unclassified']) {
|
|
34
|
+
assert.equal(isUpkeepFiling({ kind }), false, `${kind} must still name a goal — no silent bucket`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('resolveUpkeepHome finds-or-creates inside a transaction, and only for upkeep', async () => {
|
|
39
|
+
const calls = [];
|
|
40
|
+
const deps = {
|
|
41
|
+
withTx: async (fn) => { calls.push('tx'); return fn({ tx: true }); },
|
|
42
|
+
ensureMaintenanceGoal: async (v, { client }) => { calls.push([v, client.tx]); return { id: '42' }; },
|
|
43
|
+
};
|
|
44
|
+
assert.deepEqual(await resolveUpkeepHome({ kind: 'infra', version_id: 'V2' }, deps), { id: '42' });
|
|
45
|
+
assert.deepEqual(calls, ['tx', ['V2', true]], 'the advisory lock only holds inside a transaction');
|
|
46
|
+
calls.length = 0;
|
|
47
|
+
assert.equal(await resolveUpkeepHome({ kind: 'feature', version_id: 'V2' }, deps), null);
|
|
48
|
+
assert.equal(calls.length, 0, 'a non-upkeep filing never creates a goal');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---- the real route ----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const DB_PATH = require.resolve('../modules/lifecycle/db.js');
|
|
54
|
+
const realDb = require(DB_PATH);
|
|
55
|
+
|
|
56
|
+
// Load task-write-routes against a db whose goal/version/task calls are scripted,
|
|
57
|
+
// capture the POST /tasks handler, and return a caller for it.
|
|
58
|
+
function loadRoute({ versionStatus = 'building', maintenance = { id: '900', version_id: 'V2', status: 'open', is_maintenance: true } } = {}) {
|
|
59
|
+
const created = [];
|
|
60
|
+
const ensured = [];
|
|
61
|
+
const stub = {
|
|
62
|
+
...realDb,
|
|
63
|
+
getVersion: async (id) => ({ id, status: versionStatus }),
|
|
64
|
+
findTaskBySourceRef: async () => null,
|
|
65
|
+
ensureMaintenanceGoal: async (v) => { ensured.push(v); return versionStatus === 'building' ? maintenance : null; },
|
|
66
|
+
getGoal: async (id) => (String(id) === String(maintenance.id) ? maintenance : { id, version_id: 'V2', status: 'open' }),
|
|
67
|
+
createTask: async (spec) => { created.push(spec); return { id: '1', goal_id: spec.goalId, touches: [], title: spec.title }; },
|
|
68
|
+
listCriteriaForGoal: async () => [],
|
|
69
|
+
getTaskCriteria: async () => [],
|
|
70
|
+
listOpenTasksWithTouches: async () => [],
|
|
71
|
+
};
|
|
72
|
+
const routePath = require.resolve('../modules/lifecycle/routes/task-write-routes.js');
|
|
73
|
+
const api = require('../src/module-api');
|
|
74
|
+
const realWithTx = api.withTx;
|
|
75
|
+
require.cache[DB_PATH].exports = stub;
|
|
76
|
+
delete require.cache[routePath];
|
|
77
|
+
const handlers = {};
|
|
78
|
+
try {
|
|
79
|
+
require(routePath)({
|
|
80
|
+
post: (p, ...h) => { handlers['POST ' + p] = h[h.length - 1]; },
|
|
81
|
+
patch: () => {}, get: () => {}, delete: () => {},
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
require.cache[DB_PATH].exports = realDb;
|
|
85
|
+
}
|
|
86
|
+
async function call(body) {
|
|
87
|
+
// withTx must never reach a real pool here — hand ensureMaintenanceGoal a fake client.
|
|
88
|
+
api.withTx = async (fn) => fn({ fake: true });
|
|
89
|
+
const out = {};
|
|
90
|
+
const res = {
|
|
91
|
+
status(s) { out.status = s; return this; },
|
|
92
|
+
json(b) { out.body = b; return this; },
|
|
93
|
+
fail(code, opts) { out.status = typeof opts === 'number' ? opts : opts.status; out.body = { error: { code, message: opts.message } }; return this; },
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await handlers['POST /tasks']({ body, builder: { id: 1, rank: 'archon' } }, res);
|
|
97
|
+
} finally {
|
|
98
|
+
api.withTx = realWithTx;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
return { call, created, ensured };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const UPGRADE = { version_id: 'V2', title: 'Upgrade the Bongos core to 1.21.70', description: 'Platform upkeep.', discipline: 'engineer' };
|
|
106
|
+
|
|
107
|
+
test('an upgrade filed with kind infra and NO goal lands in the maintenance goal', async () => {
|
|
108
|
+
const { call, created, ensured } = loadRoute();
|
|
109
|
+
const out = await call({ ...UPGRADE, kind: 'infra' });
|
|
110
|
+
assert.equal(out.status, 201, JSON.stringify(out.body));
|
|
111
|
+
assert.deepEqual(ensured, ['V2'], 'the building version\'s maintenance goal is found or made');
|
|
112
|
+
assert.equal(created[0].goalId, 900, 'and the task is filed under it');
|
|
113
|
+
assert.equal(created[0].status, 'backlog', 'the status rules are untouched — no free ready work');
|
|
114
|
+
assert.equal(out.body.goal_home, 'maintenance', 'the caller is told where it went');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a goal-less filing that is NOT upkeep is still refused, and the refusal names the upkeep home', async () => {
|
|
118
|
+
const { call, created, ensured } = loadRoute();
|
|
119
|
+
const out = await call({ ...UPGRADE, kind: 'feature' });
|
|
120
|
+
assert.equal(out.status, 400);
|
|
121
|
+
assert.equal(out.body.error.code, 'goal_id_required');
|
|
122
|
+
assert.match(out.body.error.message, /kind "infra"/, 'names the exact way to file upkeep');
|
|
123
|
+
assert.match(out.body.error.message, /maintenance goal/);
|
|
124
|
+
assert.equal(created.length, 0);
|
|
125
|
+
assert.equal(ensured.length, 0, 'no goal is created for a refused filing');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('upkeep against a version not being built is refused in words that say why', async () => {
|
|
129
|
+
const { call, created } = loadRoute({ versionStatus: 'planning' });
|
|
130
|
+
const out = await call({ ...UPGRADE, kind: 'infra' });
|
|
131
|
+
assert.equal(out.status, 400);
|
|
132
|
+
assert.equal(out.body.error.code, 'goal_id_required');
|
|
133
|
+
assert.match(out.body.error.message, /not being built/);
|
|
134
|
+
assert.equal(created.length, 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a named goal is used as-is, even for infra work', async () => {
|
|
138
|
+
const { call, created, ensured } = loadRoute();
|
|
139
|
+
const out = await call({ ...UPGRADE, kind: 'infra', goal_id: 55 });
|
|
140
|
+
assert.equal(out.status, 201);
|
|
141
|
+
assert.equal(ensured.length, 0);
|
|
142
|
+
assert.equal(created[0].goalId, 55);
|
|
143
|
+
assert.equal(out.body.goal_home, undefined);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---- the CLI ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
test('bongos task new: the refusal tells a builder both ways forward in plain words', () => {
|
|
149
|
+
const { goalRequiredHelp } = require('../scripts/gds/task.js');
|
|
150
|
+
const help = goalRequiredHelp({ kind: 'feature', version_id: 'V2' });
|
|
151
|
+
assert.match(help, /--goal <id>/);
|
|
152
|
+
assert.match(help, /--kind infra and no --goal/);
|
|
153
|
+
assert.match(help, /Bongos upgrade/);
|
|
154
|
+
const stuck = goalRequiredHelp({ kind: 'infra', version_id: 'V0' });
|
|
155
|
+
assert.match(stuck, /not being built/);
|
|
156
|
+
});
|