@bongos/core 1.19.582 → 1.19.584

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,172 @@
1
+ // tests/provisioning_paid_shape_gate.mjs
2
+ //
3
+ // The paid-shape gate on POST /provisioning/instances (task 1003682).
4
+ //
5
+ // Before this, the create route's only gate was requireBuilder, and `hosting_shape` was
6
+ // caller-supplied and validated only against the allowed set. `dedicated` is the ONE shape
7
+ // whose runner leg creates a real DigitalOcean droplet (scripts/gds/provision.js — billed
8
+ // monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos included —
9
+ // could queue recurring infrastructure spend the owner never approved. The route that
10
+ // SPENDS was less gated than force-teardown, which stops spending and sits behind
11
+ // provisioning.fleet.manage.
12
+ //
13
+ // The fix gates the SHAPE, not the route: every $0 shape (standalone, co-tenant) stays open
14
+ // to any authenticated builder, because ordinary project creation must not require a rank.
15
+ // Only `dedicated` demands provisioning.fleet.manage — deliberately the same atom that
16
+ // already guards the fleet cost-ledger and force-teardown, since spending on the fleet and
17
+ // managing the fleet are one authority.
18
+ //
19
+ // DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
20
+ // beyond a loopback listener.
21
+ //
22
+ // Run: node tests/provisioning_paid_shape_gate.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { createRequire } from 'node:module';
26
+ import http from 'node:http';
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ const provisioning = require('../modules/provisioning/provisioning.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ // ── stubs: the route's data access + auth ────────────────────────────────────────
39
+ let lastCreateArgs = null;
40
+ provisioning.createInstance = async (_db, args) => {
41
+ lastCreateArgs = args;
42
+ return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, hosting_shape: args.hostingShape }, created: true };
43
+ };
44
+ provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
45
+ provisioning.recordEvent = async () => {};
46
+ provisioning.getInstanceBySlug = async () => null;
47
+
48
+ const api = require('../src/module-api.js');
49
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
50
+
51
+ // The permission gate, faked at the same seam the real one uses. `held` is flipped per
52
+ // case below so one mounted app covers both the denied and the admitted builder.
53
+ let held = [];
54
+ api.requirePermission = (...perms) => async (req, res, next) => {
55
+ if (perms.every((p) => held.includes(p))) return next();
56
+ return res.fail('permission_forbidden', {
57
+ status: 403,
58
+ message: `missing permission: ${perms.filter((p) => !held.includes(p)).join(', ')}`,
59
+ details: { required: perms, held: perms.filter((p) => held.includes(p)) },
60
+ });
61
+ };
62
+
63
+ const express = require('express');
64
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
65
+ const app = express();
66
+ app.use(express.json());
67
+ app.use((_req, res, next) => {
68
+ res.fail = (code, opts, details) => {
69
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
70
+ res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
71
+ };
72
+ next();
73
+ });
74
+ app.use('/api/bongos', provisioningRoutes());
75
+
76
+ const server = app.listen(0);
77
+ await new Promise((r) => server.once('listening', r));
78
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
79
+
80
+ function post(path, body) {
81
+ return new Promise((resolve, reject) => {
82
+ const payload = JSON.stringify(body);
83
+ const req = http.request(`${base}${path}`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
86
+ }, (res) => {
87
+ let d = '';
88
+ res.on('data', (c) => { d += c; });
89
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
90
+ });
91
+ req.on('error', reject);
92
+ req.end(payload);
93
+ });
94
+ }
95
+
96
+ // ── the $0 shapes stay open — this is the half a careless fix would break ─────────
97
+
98
+ await ta('standalone: a builder with NO fleet permission still creates (the live wizard path)', async () => {
99
+ held = [];
100
+ lastCreateArgs = null;
101
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
102
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
103
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
104
+ });
105
+
106
+ await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
107
+ for (const body of [
108
+ { slug: 'demo', hosting_shape: 'co-tenant', no_address: true },
109
+ { slug: 'demo', no_address: true },
110
+ ]) {
111
+ held = [];
112
+ const res = await post('/provisioning/instances', body);
113
+ assert.equal(res.status, 201, `expected 201 for ${JSON.stringify(body)}, got ${res.status} ${res.raw}`);
114
+ }
115
+ });
116
+
117
+ // ── dedicated is the metered one ──────────────────────────────────────────────────
118
+
119
+ await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
120
+ held = [];
121
+ lastCreateArgs = null;
122
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
123
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
124
+ assert.equal(res.body.error.code, 'permission_forbidden');
125
+ // requirePermission reports the gate's literal list under `details` (the same envelope
126
+ // requireRank uses), so the denial names the atom a caller has to be granted.
127
+ assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
128
+ assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
129
+ });
130
+
131
+ await ta('dedicated: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
132
+ held = ['provisioning.fleet.manage'];
133
+ lastCreateArgs = null;
134
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
135
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
136
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'dedicated');
137
+ });
138
+
139
+ await ta('the gate refuses BEFORE the row exists — a denied dedicated create leaves nothing behind', async () => {
140
+ // The denial must land ahead of createInstance, not after it: a refused-but-inserted row
141
+ // would still be drained by the runner, which reads the column, not the HTTP status.
142
+ held = [];
143
+ lastCreateArgs = null;
144
+ await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'dedicated', tier: 's-4vcpu-8gb' });
145
+ assert.equal(lastCreateArgs, null);
146
+ });
147
+
148
+ // ── the gate cannot be walked around ──────────────────────────────────────────────
149
+
150
+ await ta('a case-variant shape does not slip past the gate INTO a dedicated row', async () => {
151
+ // The gate matches the exact wire value, and so does the shape validator — so a variant
152
+ // is refused as a bad shape rather than admitted as a cheap one and stored as dedicated.
153
+ // The property that matters is not WHICH error comes back, it is that no dedicated row
154
+ // is ever created without the permission.
155
+ for (const shape of ['DEDICATED', 'Dedicated', ' dedicated', 'dedicated ']) {
156
+ held = [];
157
+ lastCreateArgs = null;
158
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
159
+ assert.ok(res.status === 400 || res.status === 403, `${JSON.stringify(shape)} → expected refusal, got ${res.status} ${res.raw}`);
160
+ assert.equal(lastCreateArgs, null, `${JSON.stringify(shape)} must not create a row`);
161
+ }
162
+ });
163
+
164
+ await ta('HOSTING_SHAPES still carries exactly the three shapes the gate reasons about', () => {
165
+ // If a fourth, also-metered shape is ever added, this test is the tripwire that says the
166
+ // gate's single-shape assumption needs revisiting.
167
+ assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(), ['co-tenant', 'dedicated', 'standalone']);
168
+ });
169
+
170
+ server.close();
171
+ console.log(`\nprovisioning_paid_shape_gate: ${passed} passed, ${failed} failed`);
172
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,194 @@
1
+ // tests/wizard_draft_resume.mjs — the create wizard survives a round-trip through GitHub
2
+ // (task 1003683).
3
+ //
4
+ // Two defects on the Home step, both found while walking the R24 proof on live:
5
+ //
6
+ // 1. EVERY boot reset the draft to step 1. enter() deliberately renders the name step
7
+ // before /me settles ("deeper steps carry authenticated side effects"), and its own
8
+ // comment promises "onAuth() re-enters to the real draft step the moment /me answers".
9
+ // But show() PERSISTS whatever step it renders, so the placeholder overwrote the
10
+ // draft's real position with 1 — leaving onAuth() nothing to return to. Granting repo
11
+ // access from step 5 therefore dumped the visitor back at the name step, every time.
12
+ // Reproduced live on core 1.19.580: planting {step:5} and reloading read back step 1.
13
+ //
14
+ // 2. "or enter it manually" was a ONE-WAY door. #ghConnect renders only while repoAccess
15
+ // is 'need-connect', the toggle set repoAccess='manual', and nothing ever set it back
16
+ // — so the "Allow repo access" button vanished until a full page reload. Navigating
17
+ // back to step 4 and forward again did not restore it.
18
+ //
19
+ // Harness: there is no jsdom in this repo, so this follows tests/project_door_ui.mjs —
20
+ // slice the real region out of the page's INLINE script and run it in a vm under a minimal
21
+ // DOM stub. The slice boundaries are asserted, so a rename can never leave this file
22
+ // quietly testing nothing.
23
+ //
24
+ // NOT covered here, deliberately: the team shape. The first draft of this task claimed the
25
+ // OAuth hop dropped it; that was wrong — teamShape, projType and modules all survive a
26
+ // reload (verified live). What looked like a drop was the documented "clicking the chosen
27
+ // pill again UNSAYS it" toggle being re-clicked by hand.
28
+ //
29
+ // Run: node --test --test-reporter=tap tests/wizard_draft_resume.mjs
30
+
31
+ import assert from 'node:assert/strict';
32
+ import { test } from 'node:test';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import vm from 'node:vm';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
40
+
41
+ // ── slice 1 · the step-resolution block inside enter() ────────────────────────────
42
+ const STEP_RE = /var step = authKnown &&[\s\S]*?show\(step, !!focus\);\r?\n\s*\}/;
43
+ const stepSlice = (HUB.match(STEP_RE) || [null])[0];
44
+
45
+ test('the step-resolution slice is still findable (a rename must fail loudly, not silently pass)', () => {
46
+ assert.ok(stepSlice, 'enter()\'s step-resolution block was not found in projects.html');
47
+ assert.match(stepSlice, /show\(1, false\)/, 'the pre-auth placeholder render is part of the slice');
48
+ });
49
+
50
+ // A faithful miniature of the real show(): the ONE behaviour that matters here is that it
51
+ // writes state.step and then persists the whole state. If show() ever stops persisting,
52
+ // this test still passes for the right reason — the draft keeps the real step either way.
53
+ function runStep({ authKnown, draftStep }) {
54
+ const store = new Map();
55
+ const state = { step: draftStep, name: 'probe', teamShape: 'small-team' };
56
+ store.set('draft', JSON.stringify(state));
57
+ const rendered = [];
58
+ const sandbox = {
59
+ authKnown,
60
+ state,
61
+ focus: false,
62
+ saveDraft() { store.set('draft', JSON.stringify(state)); },
63
+ show(step) { state.step = step; rendered.push(step); sandbox.saveDraft(); },
64
+ $: () => ({ focus() {} }),
65
+ };
66
+ sandbox.globalThis = sandbox;
67
+ vm.createContext(sandbox);
68
+ vm.runInContext(stepSlice, sandbox);
69
+ return { rendered, persisted: JSON.parse(store.get('draft')), state };
70
+ }
71
+
72
+ test('pre-auth: the placeholder renders step 1 but the draft KEEPS the real step', () => {
73
+ const { rendered, persisted } = runStep({ authKnown: false, draftStep: 5 });
74
+ assert.deepEqual(rendered, [1], 'still renders the name step before identity settles');
75
+ assert.equal(persisted.step, 5, 'the draft must not be overwritten with the placeholder');
76
+ });
77
+
78
+ test('pre-auth: the in-memory step is restored too, so onAuth() can re-enter to it', () => {
79
+ // onAuth() calls enter() again and reads state.step from MEMORY, not from storage — so
80
+ // restoring only the stored copy would fix nothing.
81
+ const { state } = runStep({ authKnown: false, draftStep: 6 });
82
+ assert.equal(state.step, 6);
83
+ });
84
+
85
+ test('post-auth: the real step is rendered', () => {
86
+ const { rendered, persisted } = runStep({ authKnown: true, draftStep: 5 });
87
+ assert.deepEqual(rendered, [5]);
88
+ assert.equal(persisted.step, 5);
89
+ });
90
+
91
+ test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
92
+ assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
93
+ // 8 is the done panel, reached by its own branch above this slice; 0 is the gate. Neither
94
+ // is a resumable wizard position, so the placeholder is allowed to stand.
95
+ for (const bad of [0, 8, 99]) {
96
+ assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
97
+ }
98
+ });
99
+
100
+ // ── slice 2 · syncHome's control over the way back ────────────────────────────────
101
+ const SYNC_RE = /function syncHome\(\) \{[\s\S]*?\n {4}\}/;
102
+ const syncSlice = (HUB.match(SYNC_RE) || [null])[0];
103
+
104
+ test('the syncHome slice is still findable', () => {
105
+ assert.ok(syncSlice, 'syncHome() was not found in projects.html');
106
+ assert.match(syncSlice, /ghManualBack/, 'syncHome governs the way-back control');
107
+ });
108
+
109
+ function runSync(repoAccess) {
110
+ const els = {};
111
+ // Elements start HIDDEN so that "shown" is only ever the result of syncHome setting it.
112
+ // With a `hidden: false` default, a control syncHome never mentions reads as visible and
113
+ // the assertion below would pass against the unfixed page.
114
+ const el = (id) => (els[id] ||= {
115
+ id, hidden: true, textContent: '', _on: false,
116
+ classList: { toggle(_c, v) { els[id]._on = !!v; } },
117
+ });
118
+ const sandbox = {
119
+ repoAccess,
120
+ state: { mode: 'greenfield' },
121
+ $: el,
122
+ isManualHome: () => repoAccess === 'manual' || repoAccess === 'unavailable',
123
+ initAdoptPicker() {}, syncFresh() {},
124
+ };
125
+ sandbox.globalThis = sandbox;
126
+ vm.createContext(sandbox);
127
+ vm.runInContext(syncSlice + '\nsyncHome();', sandbox);
128
+ return { connectShown: el('ghConnect')._on, manualShown: !el('manualHome').hidden, backShown: !el('ghManualBack').hidden };
129
+ }
130
+
131
+ test('choosing manual leaves a way back to the connect offer', () => {
132
+ const manual = runSync('manual');
133
+ assert.equal(manual.manualShown, true, 'the manual fields are up');
134
+ assert.equal(manual.connectShown, false, 'the connect box is down — that is what made it a trap');
135
+ assert.equal(manual.backShown, true, 'and the way back is offered');
136
+ });
137
+
138
+ test('a FORCED fallback offers no way back, because connecting cannot help', () => {
139
+ // 'unavailable' means the server could not store a token. Offering "use my GitHub
140
+ // repositories instead" there would send the visitor through a round-trip that lands
141
+ // them right back here — an offer that cannot be honoured is worse than none.
142
+ const forced = runSync('unavailable');
143
+ assert.equal(forced.manualShown, true);
144
+ assert.equal(forced.backShown, false);
145
+ });
146
+
147
+ test('while the connect offer is up, the way back is not', () => {
148
+ const need = runSync('need-connect');
149
+ assert.equal(need.connectShown, true);
150
+ assert.equal(need.backShown, false, 'no second, redundant control beside the button itself');
151
+ });
152
+
153
+ // ── slice 3 · why this task's original team-shape claim was wrong ─────────────────
154
+ // The bug was filed claiming a GitHub round-trip drops the team shape. It does not: a
155
+ // planted draft reads teamShape back intact after a reload (verified live on 1.19.580).
156
+ // The real explanation is this handler — the pill is an UNSAY toggle, so re-clicking the
157
+ // already-chosen shape clears it. During the R24 walk that second click was mine. Pinning
158
+ // it here so the claim rests on the handler's actual behaviour rather than on prose.
159
+ // Anchored on addEventListener: the same selector also appears inside paintTeam(), which
160
+ // only writes aria-pressed. Matching that one instead yields a slice that will not parse.
161
+ const TEAM_RE = /root\.querySelectorAll\('#teamCards \.tpill'\)\.forEach\(function \(b\) \{\s*b\.addEventListener[\s\S]*?\n {4}\}\);/;
162
+ const teamSlice = (HUB.match(TEAM_RE) || [null])[0];
163
+
164
+ test('the team-pill slice is still findable', () => {
165
+ assert.ok(teamSlice, 'the #teamCards pill handler was not found in projects.html');
166
+ assert.match(teamSlice, /state\.teamShape/, 'the handler owns the team-shape answer');
167
+ });
168
+
169
+ test('the team pill UNSAYS on a second click — the answer is not dropped, it is toggled off', () => {
170
+ const state = { teamShape: null };
171
+ const handlers = [];
172
+ const pill = (team) => ({
173
+ getAttribute: (a) => (a === 'data-team' ? team : null),
174
+ addEventListener: (_e, fn) => handlers.push({ team, fn }),
175
+ });
176
+ const sandbox = {
177
+ state,
178
+ root: { querySelectorAll: () => [pill('solo'), pill('small-team'), pill('community')] },
179
+ paintTeam() {}, paintDetail() {}, saveDraft() {},
180
+ };
181
+ sandbox.globalThis = sandbox;
182
+ vm.createContext(sandbox);
183
+ vm.runInContext(teamSlice, sandbox);
184
+
185
+ const click = (team) => handlers.find((h) => h.team === team).fn();
186
+ click('small-team');
187
+ assert.equal(state.teamShape, 'small-team', 'first click answers');
188
+ click('small-team');
189
+ assert.equal(state.teamShape, null, 'the SAME pill again unsays it — this is the mis-read bug');
190
+ click('small-team');
191
+ assert.equal(state.teamShape, 'small-team', 'and a third click answers again');
192
+ click('community');
193
+ assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces rather than clears');
194
+ });