@bongos/core 1.20.24 → 1.20.25

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.
@@ -0,0 +1,381 @@
1
+ // tests/provisioning_wizard_create_xenos.mjs — a brand-new user presses "Create my
2
+ // project" (task 1004412, ADR 0354).
3
+ //
4
+ // The hub's create wizard (modules/public-landing/public/projects.html) ends in one
5
+ // request: POST /provisioning/instances with the wizard's hosting shape. A new hub
6
+ // signup is a Xenos (migrations/029_rank_three_live.sql). So the question this file
7
+ // answers is the one a stranger actually asks: when a Xenos sends the wizard's own
8
+ // request, what comes back, and what does the wizard then say?
9
+ //
10
+ // Until task 1004412 the answer was 403 permission_forbidden for EVERY new user, shown as
11
+ // the generic "We couldn't file the request". Task 1003370 had gated cloud-host outright,
12
+ // before ADR 0345 made it the free front door. The owner's ruling (2026-09-30): a
13
+ // builder's first live hosted project needs no permission, and a further one needs
14
+ // provisioning.fleet.manage.
15
+ //
16
+ // It is asked as close to production as a DB-free test can get:
17
+ // • the shape is READ from projects.html, not typed here, so the test follows the
18
+ // wizard if the wizard ever changes what it sends;
19
+ // • the permission gate is the REAL requirePermission (src/bongos/auth.js), not a
20
+ // fake. The builder's grants are the catalog's own seed for their rank
21
+ // (seedForRank), which is what governance_002 wrote into role_permissions, fed
22
+ // through the real resolver's effectivePermissions on the session-fold path;
23
+ // • the error envelope is the real attachFail, and the wizard's copy is the real
24
+ // friendlyCreateError, handed the route's real response body.
25
+ // Only the database is stubbed: the row writes, the caller's project list, and the box's
26
+ // slot count.
27
+ //
28
+ // Run: node tests/provisioning_wizard_create_xenos.mjs
29
+
30
+ import { strict as assert } from 'node:assert';
31
+ import { createRequire } from 'node:module';
32
+ import fs from 'node:fs';
33
+ import http from 'node:http';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+
40
+ let passed = 0, failed = 0;
41
+ async function ta(name, fn) {
42
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+
46
+ // ── what the wizard sends and says, read from the wizard ─────────────────────────
47
+ // LF-normalised: a Windows checkout hands back CRLF.
48
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
49
+ // The create click's handler, from its listener to the end of that listener. The scan is
50
+ // guarded below: a regex that quietly matches nothing would test nothing.
51
+ const CREATE = (() => {
52
+ const start = HUB.indexOf("$('doCreate').addEventListener('click'");
53
+ if (start < 0) return '';
54
+ const end = HUB.indexOf('\n });\n', start);
55
+ return end < 0 ? '' : HUB.slice(start, end);
56
+ })();
57
+ const WIZARD_SHAPES = [...CREATE.matchAll(/hosting_shape:\s*'([a-z-]+)'/g)].map((m) => m[1]);
58
+ const WIZARD_SHAPE = WIZARD_SHAPES[0];
59
+
60
+ // The wizard's own error copy, evaluated. It is self-contained (it reads only its
61
+ // argument), so its text runs as it is; the caller hands it `{ data: <response body> }`
62
+ // exactly as the create click does (`e.data = data`).
63
+ const friendlyCreateError = (() => {
64
+ const start = HUB.indexOf('function friendlyCreateError(err) {');
65
+ const end = HUB.indexOf('\n function createFailureMarkup', start);
66
+ assert.ok(start >= 0 && end > start, 'friendlyCreateError() exists in projects.html');
67
+ return new Function(`${HUB.slice(start, end)}\nreturn friendlyCreateError;`)();
68
+ })();
69
+
70
+ // The request a stranger's first project sends: a fresh repo, the default address.
71
+ function wizardBody(slug) {
72
+ return { slug, hosting_shape: WIZARD_SHAPE, target_ref: `newcomer/${slug}`, onboard_mode: 'greenfield' };
73
+ }
74
+
75
+ // ── stubs: the database, and only the database ────────────────────────────────────
76
+ const provisioning = require('../modules/provisioning/provisioning.js');
77
+ let created = [];
78
+ let createDelay = null; // a promise the next createInstance waits on, for the race case
79
+ provisioning.createInstance = async (_db, args) => {
80
+ if (createDelay) await createDelay;
81
+ created.push(args);
82
+ return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, hosting_shape: args.hostingShape, status: 'requested', domain: args.domain || null }, created: true };
83
+ };
84
+ // Whether the builder's free-place lock was still held when the intent was queued —
85
+ // the step right after the insert — so the lock's early release can be asserted.
86
+ let lockHeldAtEnqueue = null;
87
+ provisioning.enqueueIntent = async (_db, _id) => { lockHeldAtEnqueue = heldLocks.has('7'); return { intent: { id: 1, action: 'provision', state: 'pending' }, created: true }; };
88
+ provisioning.getOpenIntent = async () => null;
89
+ provisioning.recordEvent = async () => {};
90
+ provisioning.getInstanceBySlug = async () => null;
91
+ provisioning.getInstanceByDomain = async () => null;
92
+ // The caller's own projects, flipped per case. The gate counts these.
93
+ let owned = [];
94
+ provisioning.listInstancesForOwner = async () => owned;
95
+ require('../modules/provisioning/catalog-bridge.js').publishInstanceToCatalog = async () => {};
96
+ // The free-place lock, with the real one's semantics and none of its database: a
97
+ // try-lock per builder, released when the gate's release function runs. The real
98
+ // function's SQL and client handling are proved separately below (holdFreePlace cases).
99
+ const freePlace = require('../modules/provisioning/free-place-lock.js');
100
+ const realHoldFreePlace = freePlace.holdFreePlace;
101
+ const heldLocks = new Set();
102
+ freePlace.holdFreePlace = async (_db, builderId) => {
103
+ const key = String(builderId);
104
+ if (heldLocks.has(key)) return null;
105
+ heldLocks.add(key);
106
+ return async () => { heldLocks.delete(key); };
107
+ };
108
+ // The box's slot count. `placed` is flipped per case; 15 is the 2 GB box's ceiling.
109
+ const capacity = require('../modules/provisioning/capacity.js');
110
+ let placed = 3;
111
+ capacity.fleetCapacity = async () => ({ box: 'control-plane', placed, ceiling: 15, source: 'measured' });
112
+
113
+ // ── auth: a session for a builder of the given rank, holding that rank's real grants ─
114
+ const catalog = require('../modules/government/catalog.js');
115
+ const resolver = require('../modules/government/resolver.js');
116
+ const seams = require('../src/module-seams.js');
117
+ let rank = 'xenos';
118
+ // true → the session carries no grant keys and the resolver throws, so authority cannot
119
+ // be established at all (the kernel then denies with held: null).
120
+ let authorityUnreadable = false;
121
+ seams.registerProvider('government', {
122
+ // The session-fold path: requirePermission runs this over the raw grant keys the
123
+ // session query carried. The DB path below answers the same set, so either path
124
+ // resolves to the rank's seed.
125
+ effectivePermissions: resolver.effectivePermissions,
126
+ resolveBuilderPermissions: async () => {
127
+ if (authorityUnreadable) throw new Error('resolver down');
128
+ return catalog.seedForRank(rank);
129
+ },
130
+ permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
131
+ isOwnershipScoped: catalog.isOwnershipScoped,
132
+ });
133
+ const api = require('../src/module-api.js');
134
+ api.requireBuilder = (req, _res, next) => {
135
+ req.builder = { id: 7, github_login: 'newcomer', rank };
136
+ req.rawPermissionKeys = authorityUnreadable ? undefined : catalog.seedForRank(rank);
137
+ next();
138
+ };
139
+
140
+ const express = require('express');
141
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
142
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
143
+ const app = express();
144
+ app.use(express.json());
145
+ app.use(attachFail);
146
+ app.use('/api/bongos', provisioningRoutes());
147
+
148
+ const server = app.listen(0);
149
+ await new Promise((r) => server.once('listening', r));
150
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
151
+
152
+ function post(body) {
153
+ return new Promise((resolve, reject) => {
154
+ const payload = JSON.stringify(body);
155
+ const req = http.request(`${base}/provisioning/instances`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
158
+ }, (res) => {
159
+ let d = '';
160
+ res.on('data', (c) => { d += c; });
161
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
162
+ });
163
+ req.on('error', reject);
164
+ req.end(payload);
165
+ });
166
+ }
167
+
168
+ function reset({ as = 'xenos', projects = [], slots = 3 } = {}) {
169
+ rank = as; owned = projects; placed = slots; created = []; createDelay = null; authorityUnreadable = false;
170
+ }
171
+ const hosted = (slug, status = 'active') => ({ id: 9, owner_builder_id: 7, slug, hosting_shape: 'cloud-host', status });
172
+
173
+ // ── the harness is real ───────────────────────────────────────────────────────────
174
+
175
+ await ta('the wizard sends exactly one hosting shape, and it is cloud-host', () => {
176
+ // If this scan found nothing, every case below would post an undefined shape — which
177
+ // defaults to the open byo-host and passes for the wrong reason.
178
+ assert.ok(CREATE.includes("'/provisioning/instances'"), 'found the create click and its POST');
179
+ assert.deepEqual(WIZARD_SHAPES, ['cloud-host'], `the create request's hosting_shape literals: ${JSON.stringify(WIZARD_SHAPES)}`);
180
+ });
181
+
182
+ await ta('a Xenos holds the catalog seed for its rank, and that seed has no fleet permission', () => {
183
+ const xenos = catalog.seedForRank('xenos');
184
+ assert.ok(xenos.length > 0 && xenos.includes('task.ship'), 'the seed is the real one, not an empty list');
185
+ assert.ok(!xenos.includes('provisioning.fleet.manage'));
186
+ assert.ok(catalog.seedForRank('metic').includes('provisioning.fleet.manage'), 'a Metic does hold it');
187
+ });
188
+
189
+ // ── the first project (task 1004412) ──────────────────────────────────────────────
190
+
191
+ await ta('a Xenos\'s FIRST hosted project is created from the wizard\'s exact request', async () => {
192
+ // The finding: before task 1004412 this was 403 permission_forbidden, for every new user.
193
+ reset();
194
+ const res = await post(wizardBody('first-light'));
195
+ assert.equal(res.status, 201, `got ${res.status} ${res.raw}`);
196
+ assert.equal(created.length, 1);
197
+ assert.equal(created[0].hostingShape, 'cloud-host', 'stored as the hosted shape the wizard asked for');
198
+ });
199
+
200
+ await ta('only a project taken offline gives the free place back; byo-host projects never use it', async () => {
201
+ reset({ projects: [hosted('old-one', 'torn_down'), { ...hosted('own-box'), hosting_shape: 'byo-host' }] });
202
+ const res = await post(wizardBody('first-light'));
203
+ assert.equal(res.status, 201, `got ${res.status} ${res.raw}`);
204
+ });
205
+
206
+ await ta('asking again for your OWN project is that project, not a second one', async () => {
207
+ // A retry of a snagged standup, or a revive, re-sends the same slug. It must not be
208
+ // counted against the place it already holds.
209
+ reset({ projects: [hosted('first-light', 'error')] });
210
+ const res = await post(wizardBody('first-light'));
211
+ assert.equal(res.status, 201, `got ${res.status} ${res.raw}`);
212
+ });
213
+
214
+ // ── a second project ──────────────────────────────────────────────────────────────
215
+
216
+ await ta('a Xenos who already hosts a project is refused a second, and nothing is created', async () => {
217
+ for (const status of ['active', 'requested', 'provisioning', 'error', 'tearing_down']) {
218
+ reset({ projects: [hosted('first-light', status)] });
219
+ const res = await post(wizardBody('second-sun'));
220
+ assert.equal(res.status, 403, `with a ${status} project: got ${res.status} ${res.raw}`);
221
+ assert.equal(res.body.error.code, 'permission_forbidden');
222
+ assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
223
+ assert.deepEqual(created, [], `with a ${status} project: no row`);
224
+ }
225
+ });
226
+
227
+ await ta('the wizard tells that person why, in plain words, and not the raw permission', async () => {
228
+ reset({ projects: [hosted('first-light')] });
229
+ const res = await post(wizardBody('second-sun'));
230
+ const said = friendlyCreateError({ data: res.body });
231
+ assert.match(said, /one project for free, and yours already has one/, said);
232
+ assert.match(said, /take the old one offline/, 'it names the way out, in the project page\'s own words');
233
+ assert.match(said, /Trusted builders can host more than one/, 'and the other way out: the permission lifts the limit (the owner\'s wording, 2026-09-30)');
234
+ assert.doesNotMatch(said, /provisioning\.fleet\.manage|permission|metic/i, 'no wire vocabulary in the headline');
235
+ });
236
+
237
+ await ta('when authority cannot be checked at all, the wizard does not blame the person\'s project', async () => {
238
+ // held: null is the kernel's fail-closed denial — the permission system could not
239
+ // answer. That is a blip, not "you already have one", so the wizard's copy falls to its
240
+ // generic sentence (an empty string here) with the raw text under Technical details.
241
+ reset({ projects: [hosted('first-light')] });
242
+ authorityUnreadable = true;
243
+ const res = await post(wizardBody('second-sun'));
244
+ assert.equal(res.status, 403, `got ${res.status} ${res.raw}`);
245
+ assert.equal(res.body.error.code, 'permission_forbidden');
246
+ assert.equal(res.body.error.details.held, null, 'the fail-closed shape');
247
+ assert.equal(friendlyCreateError({ data: res.body }), '');
248
+ });
249
+
250
+ await ta('a Metic may host more than one — the permission lifts the limit', async () => {
251
+ reset({ as: 'metic', projects: [hosted('first-light'), hosted('second-sun')] });
252
+ const res = await post(wizardBody('third-star'));
253
+ assert.equal(res.status, 201, `got ${res.status} ${res.raw}`);
254
+ });
255
+
256
+ await ta('two creates sent at once cannot both take the free place', async () => {
257
+ // The count and the insert are two steps. Hold the first request inside the insert and
258
+ // send a second: it must be refused, not counted against a list that has not changed yet.
259
+ reset();
260
+ let release;
261
+ createDelay = new Promise((r) => { release = r; });
262
+ const first = post(wizardBody('first-light'));
263
+ await new Promise((r) => setTimeout(r, 50)); // the first is now waiting inside createInstance
264
+ // A refused second answers at once. One that got through would wait on the same held
265
+ // insert, so it is given half a second and then the first is released either way —
266
+ // otherwise a broken guard would hang this file instead of failing it.
267
+ const pending = post(wizardBody('second-sun'));
268
+ const early = await Promise.race([pending, new Promise((r) => setTimeout(() => r(null), 500))]);
269
+ release();
270
+ const second = early || await pending;
271
+ const firstRes = await first;
272
+ assert.equal(firstRes.status, 201, `first: ${firstRes.status} ${firstRes.raw}`);
273
+ assert.equal(second.status, 409, `second: ${second.status} ${second.raw}`);
274
+ assert.equal(second.body.error.code, 'create_in_progress');
275
+ assert.deepEqual(created.map((a) => a.slug), ['first-light'], 'exactly one project');
276
+ // …and once the first has answered, the mark is gone: the next request is counted again.
277
+ createDelay = null; owned = [hosted('first-light')];
278
+ const third = await post(wizardBody('second-sun'));
279
+ assert.equal(third.status, 403, `after the first answered: ${third.status} ${third.raw}`);
280
+ });
281
+
282
+ await ta('a project list that cannot be read refuses, and never counts as zero', async () => {
283
+ reset();
284
+ provisioning.listInstancesForOwner = async () => { throw new Error('db down'); };
285
+ try {
286
+ const res = await post(wizardBody('first-light'));
287
+ assert.equal(res.status, 500, `got ${res.status} ${res.raw}`);
288
+ assert.equal(res.body.error.code, 'request_failed');
289
+ assert.deepEqual(created, [], 'no row');
290
+ assert.match(friendlyCreateError({ data: res.body }), /couldn’t file the request/);
291
+ } finally {
292
+ provisioning.listInstancesForOwner = async () => owned;
293
+ }
294
+ });
295
+
296
+ // ── the box is full ───────────────────────────────────────────────────────────────
297
+
298
+ await ta('a full server is said plainly in the wizard, never as the operator\'s sentence', async () => {
299
+ reset({ slots: 15 });
300
+ const res = await post(wizardBody('first-light'));
301
+ assert.equal(res.status, 409, `got ${res.status} ${res.raw}`);
302
+ assert.equal(res.body.error.code, 'box_full');
303
+ assert.deepEqual(created, [], 'no row');
304
+ const said = friendlyCreateError({ data: res.body });
305
+ assert.match(said, /server is full/, said);
306
+ assert.doesNotMatch(said, /ADR|task \d|slots/i, 'the server\'s own reason is written for operators');
307
+ });
308
+
309
+ await ta('the lock is let go as soon as the row exists, not at the end of the request', async () => {
310
+ reset();
311
+ lockHeldAtEnqueue = null;
312
+ const res = await post(wizardBody('first-light'));
313
+ assert.equal(res.status, 201, `got ${res.status} ${res.raw}`);
314
+ assert.equal(lockHeldAtEnqueue, false, 'released right after createInstance, before the intent is queued');
315
+ assert.equal(heldLocks.size, 0);
316
+ });
317
+
318
+ await ta('a lock that cannot be taken refuses, and never admits', async () => {
319
+ reset();
320
+ const saved = freePlace.holdFreePlace;
321
+ freePlace.holdFreePlace = async () => { throw new Error('db down'); };
322
+ try {
323
+ const res = await post(wizardBody('first-light'));
324
+ assert.equal(res.status, 500, `got ${res.status} ${res.raw}`);
325
+ assert.equal(res.body.error.code, 'request_failed');
326
+ assert.deepEqual(created, [], 'no row');
327
+ } finally {
328
+ freePlace.holdFreePlace = saved;
329
+ }
330
+ });
331
+
332
+ // ── the real lock: one per builder across processes, held on its own client ───────
333
+
334
+ function fakeDb({ held = true, lockThrows = false, unlockThrows = false } = {}) {
335
+ const log = { queries: [], released: [] };
336
+ const client = {
337
+ query: async (sql, params) => {
338
+ log.queries.push({ sql, params });
339
+ if (/pg_try_advisory_lock/.test(sql)) { if (lockThrows) throw new Error('lock failed'); return { rows: [{ held }] }; }
340
+ if (/pg_advisory_unlock/.test(sql)) { if (unlockThrows) throw new Error('unlock failed'); return { rows: [{ pg_advisory_unlock: true }] }; }
341
+ throw new Error(`unexpected SQL: ${sql}`);
342
+ },
343
+ release: (destroy) => { log.released.push(destroy === true ? 'destroyed' : 'returned'); },
344
+ };
345
+ return { db: { connect: async () => client }, log };
346
+ }
347
+
348
+ await ta('holdFreePlace: a TRY lock keyed on the builder, released by unlocking and returning the client', async () => {
349
+ const { db, log } = fakeDb();
350
+ const release = await realHoldFreePlace(db, 1000025);
351
+ assert.equal(typeof release, 'function', 'held → a release function');
352
+ assert.match(log.queries[0].sql, /pg_try_advisory_lock\(hashtext\('provisioning\.one-free-hosted-project'\), \$1::int\)/,
353
+ 'try (never waits), namespaced so it cannot collide with another lock on a builder id');
354
+ assert.deepEqual(log.queries[0].params, [1000025]);
355
+ assert.deepEqual(log.released, [], 'the client is HELD for the request — a session lock dies with its connection');
356
+ await release();
357
+ await release();
358
+ assert.match(log.queries[1].sql, /pg_advisory_unlock/);
359
+ assert.equal(log.queries.length, 2, 'a second release is a no-op');
360
+ assert.deepEqual(log.released, ['returned']);
361
+ });
362
+
363
+ await ta('holdFreePlace: someone else holds it → null, and the client goes straight back', async () => {
364
+ const { db, log } = fakeDb({ held: false });
365
+ assert.equal(await realHoldFreePlace(db, 7), null);
366
+ assert.deepEqual(log.released, ['returned']);
367
+ });
368
+
369
+ await ta('holdFreePlace: a failed lock or unlock DESTROYS the client, which ends the session and its lock', async () => {
370
+ const failing = fakeDb({ lockThrows: true });
371
+ await assert.rejects(() => realHoldFreePlace(failing.db, 7), /lock failed/);
372
+ assert.deepEqual(failing.log.released, ['destroyed']);
373
+ const stuck = fakeDb({ unlockThrows: true });
374
+ const release = await realHoldFreePlace(stuck.db, 7);
375
+ await release();
376
+ assert.deepEqual(stuck.log.released, ['destroyed'], 'a lock we could not let go of must not go back into the pool');
377
+ });
378
+
379
+ server.close();
380
+ console.log(`\nprovisioning_wizard_create_xenos: ${passed} passed, ${failed} failed`);
381
+ process.exit(failed ? 1 : 0);