@bongos/core 1.25.4 → 1.25.6
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 +77 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +239 -3
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +12 -12
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +49 -52
- package/modules/hall-ui/public/shell-game.js +34 -3
- package/modules/hall-ui/public/welcome-boot.js +27 -11
- package/modules/hall-ui/records/start-screen.md +10 -0
- package/modules/onboarding/genesis-home.js +35 -1
- package/modules/onboarding/routes/onboarding.js +7 -2
- package/modules/provisioning/catalog-bridge.js +3 -0
- package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/paid-shape-gate.js +3 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/provisioning.js +11 -4
- package/modules/provisioning/routes/update-subscription.js +51 -0
- package/modules/provisioning/test-project.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/government-walk.js +2 -2
- package/scripts/bongos/update-channel.js +27 -4
- package/scripts/bongos/update-sweep.js +17 -3
- package/src/bongos/api-errors.js +2 -0
- package/src/bongos/db.js +43 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/bongos/routes/builders.js +37 -0
- package/src/branding.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/builder_card_requirement.mjs +75 -0
- package/tests/currency_label.mjs +3 -1
- package/tests/hall_start_screen.mjs +62 -8
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_paid_shape_gate.mjs +16 -0
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_update_subscription_route.mjs +141 -0
- package/tests/test_project.mjs +122 -0
- package/tests/update_channel_db.mjs +24 -0
- package/tests/wizard_demo.mjs +25 -4
|
@@ -23,17 +23,20 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
|
23
23
|
const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
|
|
24
24
|
|
|
25
25
|
// ---------- in a vm: the pure parts and the welcome's hand-off ----------
|
|
26
|
-
function sandbox({ store = {}, game = true } = {}) {
|
|
27
|
-
const started = [], asked = [];
|
|
26
|
+
function sandbox({ store = {}, session = {}, game = true } = {}) {
|
|
27
|
+
const started = [], asked = [], went = [];
|
|
28
28
|
const window = {
|
|
29
|
-
location: { search: '', pathname: '/', hash: '' },
|
|
29
|
+
location: { search: '', pathname: '/', hash: '', assign: (h) => went.push(h) },
|
|
30
30
|
__BRANDING__: { identity: { worldName: 'Harbor' } },
|
|
31
31
|
localStorage: {
|
|
32
32
|
getItem: (k) => (k in store ? store[k] : null),
|
|
33
33
|
setItem: (k, v) => { store[k] = String(v); },
|
|
34
34
|
removeItem: (k) => { delete store[k]; },
|
|
35
35
|
},
|
|
36
|
-
sessionStorage: {
|
|
36
|
+
sessionStorage: {
|
|
37
|
+
getItem: (k) => (k in session ? session[k] : null),
|
|
38
|
+
setItem: (k, v) => { session[k] = String(v); },
|
|
39
|
+
},
|
|
37
40
|
OTBCraftMode: { markAsked: () => asked.push('marked'), onAccess: (a, b) => asked.push(['picker', a.authed, b.id]) },
|
|
38
41
|
};
|
|
39
42
|
const document = { documentElement: { dataset: {}, classList: { add() {}, remove() {} } }, addEventListener() {} };
|
|
@@ -44,7 +47,7 @@ function sandbox({ store = {}, game = true } = {}) {
|
|
|
44
47
|
window.OTBShellGame.start = (o) => { started.push(o); return true; };
|
|
45
48
|
}
|
|
46
49
|
vm.runInContext(read('welcome-boot.js'), box, { filename: 'welcome-boot.js' });
|
|
47
|
-
return { window, store, started, asked };
|
|
50
|
+
return { window, store, session, started, asked, went };
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
test('the menu is Start, Options, Achievements, Extras, Quit, in that order', () => {
|
|
@@ -105,10 +108,27 @@ test('a fresh sign-in is handed to the start screen with the still, and the craf
|
|
|
105
108
|
assert.deepEqual(s.asked, [['picker', true, 7]], 'the picker is asked once the world is showing');
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
test('
|
|
109
|
-
const
|
|
111
|
+
test('every visit to Home opens on Game or Classic, signed in or not (task 1005219)', () => {
|
|
112
|
+
const visit = sandbox();
|
|
113
|
+
assert.equal(visit.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
114
|
+
assert.deepEqual([visit.started.length, visit.asked], [1, []], 'a new visit, signed in, is the chooser, not the welcome');
|
|
115
|
+
const deep = sandbox();
|
|
116
|
+
deep.window.location.pathname = '/goals';
|
|
117
|
+
assert.equal(deep.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
118
|
+
assert.equal(deep.started.length, 0, 'only Home asks; a page deeper in keeps the day\'s welcome');
|
|
119
|
+
const out = sandbox();
|
|
120
|
+
assert.equal(out.window.OTBWelcome.onAccess({ authed: false }, null), true);
|
|
121
|
+
assert.equal(out.started.length, 1, 'signed out, the chooser stands in for the painted welcome');
|
|
122
|
+
assert.equal(typeof out.started[0].signIn, 'function', 'and a pick goes on to sign in');
|
|
123
|
+
out.started[0].signIn();
|
|
124
|
+
assert.deepEqual([out.went, out.store['hall-welcome-signin']], [['/api/bongos/auth/web/start'], '1'],
|
|
125
|
+
'to GitHub, remembered as a sign-in so the return opens the picked menu');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('within one visit, Home asks once; the day\'s welcome stays for a later page, and a hall without shell-game.js greets a sign-in as before', () => {
|
|
129
|
+
const daily = sandbox({ session: { 'hall-chooser-seen': '1' } });
|
|
110
130
|
assert.equal(daily.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
111
|
-
assert.deepEqual([daily.started.length, daily.asked], [0, ['marked']], 'the day\'s first visit is the welcome\'s');
|
|
131
|
+
assert.deepEqual([daily.started.length, daily.asked], [0, ['marked']], 'asked already this visit: the day\'s first visit is the welcome\'s');
|
|
112
132
|
const bare = sandbox({ store: { 'hall-welcome-signin': '1' }, game: false });
|
|
113
133
|
assert.equal(bare.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
114
134
|
assert.deepEqual(bare.asked, ['marked']);
|
|
@@ -286,6 +306,40 @@ test('choosing Classic at sign-in closes the screen, leaves game mode off, and s
|
|
|
286
306
|
await ctx.close();
|
|
287
307
|
});
|
|
288
308
|
|
|
309
|
+
// task 1005219: the hall's front opens on the chooser signed out; the pick goes to sign-in and the
|
|
310
|
+
// return opens that menu without asking again — the start screen for Game, the plain hall for Classic.
|
|
311
|
+
test('signed out, a pick goes to sign-in, and the return opens the picked menu without asking again', { skip }, async () => {
|
|
312
|
+
for (const pick of ['game', 'classic']) {
|
|
313
|
+
const ctx = await hall();
|
|
314
|
+
await ctx.route(ORIGIN + '/api/bongos/auth/web/start', (route) => route.fulfill({ contentType: 'text/html', body: 'github' }));
|
|
315
|
+
const page = await ctx.newPage();
|
|
316
|
+
await page.goto(ORIGIN + '/');
|
|
317
|
+
await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
|
|
318
|
+
await page.evaluate(() => window.OTBShell.applyAccess({ authed: false }));
|
|
319
|
+
await page.waitForSelector('#game-start');
|
|
320
|
+
assert.equal((await state(page)).welcome, false, 'the chooser, not the painted welcome');
|
|
321
|
+
await Promise.all([page.waitForURL(ORIGIN + '/api/bongos/auth/web/start'), page.click(`[data-gs="view-${pick}"]`)]);
|
|
322
|
+
assert.deepEqual(await page.evaluate(() => [localStorage.getItem('hall-view-pending'), localStorage.getItem('hall-welcome-signin')]), [pick, '1']);
|
|
323
|
+
|
|
324
|
+
// back from GitHub, signed in
|
|
325
|
+
await page.goto(ORIGIN + '/');
|
|
326
|
+
await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
|
|
327
|
+
await page.evaluate(() => { const me = { authed: true, rank: 'metic', builder: { id: 7, display_name: 'Ada', github_login: 'ada' } }; window.OTBShell.applyAccess(me); window.OTBShell.applyAccess(me); });
|
|
328
|
+
await page.evaluate(() => new Promise((r) => setTimeout(r, 100)));
|
|
329
|
+
const s = await state(page);
|
|
330
|
+
if (pick === 'game') {
|
|
331
|
+
assert.equal(s.start, true, 'Game: the start screen');
|
|
332
|
+
assert.equal(await page.isVisible('#gs-items'), true, 'on its menu');
|
|
333
|
+
assert.equal(await page.isVisible('#gs-views'), false, 'not asked again');
|
|
334
|
+
assert.equal(s.game, 'on');
|
|
335
|
+
} else {
|
|
336
|
+
assert.deepEqual([s.start, s.welcome, s.game], [false, false, null], 'Classic: the hall, no screen, game mode off');
|
|
337
|
+
}
|
|
338
|
+
assert.equal(await page.evaluate(() => localStorage.getItem('hall-view-pending')), null, 'the pick is spent');
|
|
339
|
+
await ctx.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
289
343
|
test('on a phone the panel is the whole width, nothing scrolls sideways and every choice clears the floor (task 1005192)', { skip }, async () => {
|
|
290
344
|
for (const [w, h] of [[390, 844], [320, 700]]) {
|
|
291
345
|
const ctx = await hall({ viewport: { width: w, height: h } });
|
|
@@ -80,6 +80,7 @@ ${NEUTRAL_PREFIX}_FOUNDING_GENESIS=
|
|
|
80
80
|
${NEUTRAL_PREFIX}_DEMO_PEOPLE=
|
|
81
81
|
${NEUTRAL_PREFIX}_DEMO_HOURS=
|
|
82
82
|
${NEUTRAL_PREFIX}_SHIP_MODE=
|
|
83
|
+
${NEUTRAL_PREFIX}_TEST_PROJECT=
|
|
83
84
|
${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
|
|
84
85
|
`;
|
|
85
86
|
|
|
@@ -151,7 +152,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
151
152
|
// the birth marker and a demo's box (task 1004506), empty on an un-stamped row
|
|
152
153
|
[`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
|
|
153
154
|
// the wizard's ship mode (task 1004629), empty when none was picked
|
|
154
|
-
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
|
|
155
|
+
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '',
|
|
155
156
|
// where the hall's Update button goes (task 1004608), empty when no hub origin resolves
|
|
156
157
|
[`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
|
|
157
158
|
});
|
|
@@ -243,6 +243,22 @@ await ta('cloud-host: ADMITTED past the limit for a builder holding provisioning
|
|
|
243
243
|
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
await ta('cloud-host: a TEST project holds no free place, so it does not make the next one a second (task 1005036)', async () => {
|
|
247
|
+
// Only an archon makes a test project, and it is for trying features: counting it would
|
|
248
|
+
// charge a builder's one free place for a project nobody meant to keep.
|
|
249
|
+
held = [];
|
|
250
|
+
owned = [{ ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
|
|
251
|
+
lastCreateArgs = null;
|
|
252
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
253
|
+
assert.equal(res.status, 201, 'a test project alone leaves the free place free: ' + res.raw);
|
|
254
|
+
held = [];
|
|
255
|
+
owned = [LIVE_HOSTED, { ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
|
|
256
|
+
lastCreateArgs = null;
|
|
257
|
+
const second = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
258
|
+
owned = [];
|
|
259
|
+
assert.equal(second.status, 403, 'a real project plus a test one still counts the real one');
|
|
260
|
+
});
|
|
261
|
+
|
|
246
262
|
// ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
|
|
247
263
|
|
|
248
264
|
await ta('control-plane: refused for an unprivileged builder, and the refusal is not a permission', async () => {
|
|
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
128
128
|
// route never stamped, which is every row made before it shipped (spec D8).
|
|
129
129
|
CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
|
|
130
130
|
// The wizard's ship mode (task 1004629): empty when none was picked.
|
|
131
|
-
CLOUDBONGOS_SHIP_MODE: '',
|
|
131
|
+
CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '',
|
|
132
132
|
});
|
|
133
133
|
// varsOf splits on '=', so an empty value reads as '' on both sides — the point
|
|
134
134
|
// of the comparison is that the standup lines carry exactly the same key set.
|
|
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
137
137
|
X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
|
|
138
138
|
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
|
|
139
139
|
X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
|
|
140
|
-
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
|
|
140
|
+
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '',
|
|
141
141
|
}, 'defaults, explicitly');
|
|
142
142
|
});
|
|
143
143
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// tests/provisioning_update_subscription_route.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004150 (ADR 0325 decision 5): POST /provisioning/instances/:id/update-subscription — the
|
|
4
|
+
// operator's switch for "does the nightly sweep move this instance on its own".
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing assertions:
|
|
7
|
+
// • the fleet permission gate is what stands in front of it (an ungated route could arm any
|
|
8
|
+
// instance for unattended upgrades)
|
|
9
|
+
// • the body is strict and boolean: a string, a missing field or an extra field is a 400 and
|
|
10
|
+
// nothing is written
|
|
11
|
+
// • a real change writes the column and one audit event; a no-op writes and records nothing
|
|
12
|
+
// • an unknown id is a 404
|
|
13
|
+
//
|
|
14
|
+
// DB-free: the pool and data access are stubbed on the cached module objects over a real mounted
|
|
15
|
+
// router (the provisioning_repo_private_route.mjs pattern).
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/provisioning_update_subscription_route.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
25
|
+
const api = require('../src/module-api.js');
|
|
26
|
+
|
|
27
|
+
let passed = 0, failed = 0;
|
|
28
|
+
async function ta(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
30
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let instance = null;
|
|
34
|
+
let writes = [];
|
|
35
|
+
let events = [];
|
|
36
|
+
let permissionAsked = null;
|
|
37
|
+
let allowed = true;
|
|
38
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
39
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
40
|
+
api.pool.query = async (sql, params) => { writes.push({ sql, params }); return { rows: [] }; };
|
|
41
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 5, github_login: 'op', rank: 'metic' }; next(); };
|
|
42
|
+
api.requirePermission = (atom) => {
|
|
43
|
+
permissionAsked = atom;
|
|
44
|
+
return (req, res, next) => (allowed ? next() : res.status(403).json({ error: { code: 'forbidden' } }));
|
|
45
|
+
};
|
|
46
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
47
|
+
|
|
48
|
+
const express = require('express');
|
|
49
|
+
const buildRouter = require('../modules/provisioning/routes/update-subscription.js');
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use((_req, res, next) => {
|
|
53
|
+
res.fail = (code, opts) => {
|
|
54
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
55
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } });
|
|
56
|
+
};
|
|
57
|
+
next();
|
|
58
|
+
});
|
|
59
|
+
app.use('/api/bongos', buildRouter());
|
|
60
|
+
|
|
61
|
+
const server = app.listen(0);
|
|
62
|
+
await new Promise((r) => server.once('listening', r));
|
|
63
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
64
|
+
|
|
65
|
+
function post(path, body) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const payload = JSON.stringify(body);
|
|
68
|
+
const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
|
|
69
|
+
let d = '';
|
|
70
|
+
res.on('data', (c) => { d += c; });
|
|
71
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
72
|
+
});
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
req.write(payload);
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reset(patch = {}) {
|
|
80
|
+
instance = { owner_builder_id: 7, slug: 'spike', update_subscribed: false, ...patch };
|
|
81
|
+
writes = []; events = []; allowed = true;
|
|
82
|
+
}
|
|
83
|
+
const URL_18 = '/provisioning/instances/18/update-subscription';
|
|
84
|
+
|
|
85
|
+
console.log('POST /provisioning/instances/:id/update-subscription:');
|
|
86
|
+
|
|
87
|
+
await ta('is gated on the fleet permission, and a caller without it writes nothing', async () => {
|
|
88
|
+
reset(); allowed = false;
|
|
89
|
+
const r = await post(URL_18, { subscribed: true });
|
|
90
|
+
assert.equal(permissionAsked, 'provisioning.fleet.manage');
|
|
91
|
+
assert.equal(r.status, 403, r.raw);
|
|
92
|
+
assert.equal(writes.length, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await ta('arming writes the column once and records one audit event', async () => {
|
|
96
|
+
reset();
|
|
97
|
+
const r = await post(URL_18, { subscribed: true });
|
|
98
|
+
assert.equal(r.status, 200, r.raw);
|
|
99
|
+
assert.deepEqual(r.body, { ok: true, update_subscribed: true, previous: false, changed: true });
|
|
100
|
+
assert.equal(writes.length, 1);
|
|
101
|
+
assert.match(writes[0].sql, /SET update_subscribed = \$2/);
|
|
102
|
+
assert.deepEqual(writes[0].params, [18, true]);
|
|
103
|
+
assert.equal(events.length, 1);
|
|
104
|
+
assert.equal(events[0].event, 'update-subscription-changed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await ta('a no-op (already in that state) writes nothing and records nothing', async () => {
|
|
108
|
+
reset({ update_subscribed: true });
|
|
109
|
+
const r = await post(URL_18, { subscribed: true });
|
|
110
|
+
assert.equal(r.status, 200, r.raw);
|
|
111
|
+
assert.equal(r.body.changed, false);
|
|
112
|
+
assert.equal(writes.length, 0);
|
|
113
|
+
assert.equal(events.length, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await ta('disarming is symmetric', async () => {
|
|
117
|
+
reset({ update_subscribed: true });
|
|
118
|
+
const r = await post(URL_18, { subscribed: false });
|
|
119
|
+
assert.equal(r.body.update_subscribed, false);
|
|
120
|
+
assert.deepEqual(writes[0].params, [18, false]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await ta('a non-boolean, a missing field and an extra field are all 400 with nothing written', async () => {
|
|
124
|
+
for (const body of [{ subscribed: 'yes' }, {}, { subscribed: true, channel: 'minor' }]) {
|
|
125
|
+
reset();
|
|
126
|
+
const r = await post(URL_18, body);
|
|
127
|
+
assert.equal(r.status, 400, `${JSON.stringify(body)} → ${r.raw}`);
|
|
128
|
+
assert.equal(writes.length, 0);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await ta('an unknown id is 404', async () => {
|
|
133
|
+
reset(); instance = null;
|
|
134
|
+
const r = await post(URL_18, { subscribed: true });
|
|
135
|
+
assert.equal(r.status, 404, r.raw);
|
|
136
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
server.close();
|
|
140
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
141
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tests/test_project.mjs — A TEST PROJECT: one made to try features that are not live yet
|
|
2
|
+
// (task 1005036, idea 1001299; owner, 2026-11-01).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: only an archon can ask for one; it is stamped once on the row just made; it is
|
|
5
|
+
// kept off the public map; it reaches the instance as project.test; and the not-yet-live
|
|
6
|
+
// genesis switch opens on a test project and nowhere else. The free-place count is pinned in
|
|
7
|
+
// tests/provisioning_paid_shape_gate.mjs and the create route in tests/wizard_demo.mjs.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/test_project.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const tp = require('../modules/provisioning/test-project.js');
|
|
21
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
22
|
+
const bridge = require('../modules/provisioning/catalog-bridge.js');
|
|
23
|
+
const gh = require('../modules/onboarding/genesis-home.js');
|
|
24
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
25
|
+
|
|
26
|
+
const ARCHON = { id: 1, rank: 'archon' };
|
|
27
|
+
const METIC = { id: 2, rank: 'metic' };
|
|
28
|
+
const ROW = (settings) => ({ id: 41, slug: 'sandbox', domain: 'sandbox.example.test', status: 'active', hosting_shape: 'cloud-host', settings });
|
|
29
|
+
|
|
30
|
+
test('only an archon can ask for a test project, and genesis means nothing without one', () => {
|
|
31
|
+
assert.deepEqual(tp.parseTestAsk({ test: true }, ARCHON), { ok: true, test: true, genesis: false });
|
|
32
|
+
assert.deepEqual(tp.parseTestAsk({ test: true, genesis: true }, ARCHON), { ok: true, test: true, genesis: true });
|
|
33
|
+
assert.deepEqual(tp.parseTestAsk({}, METIC), { ok: true, test: false, genesis: false }, 'a request that asks for nothing is fine for anyone');
|
|
34
|
+
const refused = tp.parseTestAsk({ test: true }, METIC);
|
|
35
|
+
assert.equal(refused.ok, false);
|
|
36
|
+
assert.equal(refused.status, 403);
|
|
37
|
+
assert.equal(refused.code, 'test_project_forbidden');
|
|
38
|
+
assert.equal(tp.parseTestAsk({ test: true }, undefined).ok, false, 'no builder, no test project');
|
|
39
|
+
assert.equal(tp.parseTestAsk({ test: 'yes' }, METIC).ok, true, 'only a real boolean true asks');
|
|
40
|
+
const alone = tp.parseTestAsk({ genesis: true }, ARCHON);
|
|
41
|
+
assert.equal(alone.ok, false);
|
|
42
|
+
assert.equal(alone.code, 'genesis_needs_test');
|
|
43
|
+
assert.equal(alone.status, 400);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the mark: stamped on the row it is given, once; read only when it says on', async () => {
|
|
47
|
+
const seen = [];
|
|
48
|
+
await tp.stampTestProject({ query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } }, 41);
|
|
49
|
+
assert.equal(seen.length, 1);
|
|
50
|
+
assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
|
|
51
|
+
assert.deepEqual(seen[0].params, [41, 'test_project']);
|
|
52
|
+
assert.equal(tp.isTestProject(ROW({ test_project: 'on' })), true);
|
|
53
|
+
for (const bad of [undefined, null, {}, { test_project: 'yes' }, { test_project: true }, 'on']) {
|
|
54
|
+
assert.equal(tp.isTestProject(ROW(bad)), false, JSON.stringify(bad));
|
|
55
|
+
}
|
|
56
|
+
assert.equal(tp.isTestProject(null), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the owner view says it, and it reaches web.env as TEST_PROJECT, empty on every other project', () => {
|
|
60
|
+
assert.equal(provisioning.publicInstance(ROW({ test_project: 'on' })).is_test, true);
|
|
61
|
+
assert.equal(provisioning.publicInstance(ROW({})).is_test, false);
|
|
62
|
+
assert.equal(provisioning.settingsEnvVars(ROW({ test_project: 'on' }), 'X').X_TEST_PROJECT, 'on');
|
|
63
|
+
assert.equal(provisioning.settingsEnvVars(ROW({}), 'X').X_TEST_PROJECT, '', 'a line that came and went would leave a stale one behind');
|
|
64
|
+
assert.equal(provisioning.settingsEnvVars(ROW({ test_project: 'maybe' }), 'X').X_TEST_PROJECT, '');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a test project is never on the public map; the same row without the mark is', () => {
|
|
68
|
+
assert.equal(bridge.catalogEntryForInstance(ROW({ test_project: 'on' }), { ownerGithubId: 99 }), null);
|
|
69
|
+
const plain = bridge.catalogEntryForInstance(ROW({}), { ownerGithubId: 99 });
|
|
70
|
+
assert.ok(plain && plain.clientId === 'sandbox', 'an ordinary project still projects');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the instance reads TEST_PROJECT into project.test, and publishes it as a boolean beside project, not in it', () => {
|
|
74
|
+
const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
|
|
75
|
+
"const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({p:r.project&&r.project.test,c:b.clientBranding(r).testProject}))"],
|
|
76
|
+
{ cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
|
|
77
|
+
assert.deepEqual(read({}), { c: false }, 'no env, not a test project');
|
|
78
|
+
assert.deepEqual(read({ CLOUDBONGOS_TEST_PROJECT: 'on' }), { p: 'on', c: true });
|
|
79
|
+
assert.deepEqual(read({ CLOUDBONGOS_TEST_PROJECT: '' }), { c: false }, 'the empty line every other project carries');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── the genesis switch, on the instance ────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function world({ test = true, settings = {} } = {}) {
|
|
85
|
+
const store = new Map(Object.entries(settings));
|
|
86
|
+
const deps = {
|
|
87
|
+
branding: { project: { test: test ? 'on' : '' } },
|
|
88
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
89
|
+
setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
90
|
+
lifecycle: { seedGenesis: async () => ({ seeded: 0 }), genesisTasks: async () => ({}) },
|
|
91
|
+
government: null,
|
|
92
|
+
now: new Date('2026-11-02T09:00:00Z'),
|
|
93
|
+
};
|
|
94
|
+
return { store, deps };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test('genesis cannot be switched on from a project that is not a test project', async () => {
|
|
98
|
+
const w = world({ test: false });
|
|
99
|
+
assert.deepEqual(await gh.setEnabled(true, 1, w.deps), { ok: false, reason: 'not_a_test_project' });
|
|
100
|
+
assert.deepEqual(await gh.ownerStart(1, w.deps), { ok: false, reason: 'not_a_test_project' });
|
|
101
|
+
assert.equal(w.store.size, 0, 'nothing is written');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('on a test project the switch starts genesis, pauses it, and brings it back', async () => {
|
|
105
|
+
const w = world();
|
|
106
|
+
const on = await gh.setEnabled(true, 1, w.deps);
|
|
107
|
+
assert.equal(on.ok, true);
|
|
108
|
+
assert.equal(on.mode.active, true);
|
|
109
|
+
assert.equal(w.store.get(fm.KEYS.ENABLED), 'true');
|
|
110
|
+
const off = await gh.setEnabled(false, 1, w.deps);
|
|
111
|
+
assert.equal(off.ok, true);
|
|
112
|
+
assert.equal(off.mode, null, 'paused: the hall opens full');
|
|
113
|
+
assert.equal(w.store.get(fm.KEYS.ACTIVE), 'true', 'the founding record is kept');
|
|
114
|
+
const again = await gh.setEnabled(true, 1, w.deps);
|
|
115
|
+
assert.equal(again.mode.active, true, 'it comes back where it was');
|
|
116
|
+
assert.equal((await gh.ownerStart(1, world().deps)).ok, true, 'the owner\'s own start works on a test project');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a genesis that came alive cannot be switched back on', async () => {
|
|
120
|
+
const w = world({ settings: { [fm.KEYS.STARTED_AT]: '2026-11-01T00:00:00Z', [fm.KEYS.ENDED_AT]: '2026-11-01T05:00:00Z' } });
|
|
121
|
+
assert.deepEqual(await gh.setEnabled(true, 1, w.deps), { ok: false, reason: 'already_alive' });
|
|
122
|
+
});
|
|
@@ -129,3 +129,27 @@ test('update-sweep.js reads the DB from its own env, never an entry env (which n
|
|
|
129
129
|
// A core too old to carry the reader must keep the roster rather than crash the sweep.
|
|
130
130
|
assert.match(src, /typeof channel\.readDbChannels === 'function'/);
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
// ---- task 1004150: the subscription roster lives on the project row too ----
|
|
134
|
+
|
|
135
|
+
const subDb = (subs) => ({ ok: true, channels: new Map(), subscribed: new Map(Object.entries(subs)) });
|
|
136
|
+
|
|
137
|
+
test('subscribed on the row: reported as deploy-sourced; a file-only entry is kept as an override', () => {
|
|
138
|
+
const { instances, unreachable } = uc.applyDbSubscriptions([entry(), entry({ slug: 'charter' })], subDb({ cloudbongos: true, charter: false }));
|
|
139
|
+
assert.equal(instances[0].subscribedSource, uc.CHANNEL_SOURCE_DEPLOY);
|
|
140
|
+
assert.equal(instances[1].subscribedSource, uc.CHANNEL_SOURCE_ROSTER);
|
|
141
|
+
assert.match(instances[1].subscribedNote, /roster file only/);
|
|
142
|
+
assert.deepEqual(unreachable, []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('subscribed on the row with no roster entry: surfaced as unreachable, never dropped', () => {
|
|
146
|
+
const { instances, unreachable } = uc.applyDbSubscriptions([], subDb({ mercury: true, venus: false }));
|
|
147
|
+
assert.equal(instances.length, 0);
|
|
148
|
+
assert.deepEqual(unreachable, ['mercury']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('database unreachable: the roster alone decides and nothing is reported unreachable', () => {
|
|
152
|
+
const { instances, unreachable } = uc.applyDbSubscriptions([entry()], { ok: false, reason: 'psql not found' });
|
|
153
|
+
assert.equal(instances.length, 1);
|
|
154
|
+
assert.deepEqual(unreachable, []);
|
|
155
|
+
});
|
package/tests/wizard_demo.mjs
CHANGED
|
@@ -117,6 +117,9 @@ provisioning.getInstanceByDomain = async () => null;
|
|
|
117
117
|
const order = [];
|
|
118
118
|
const birth = require('../modules/provisioning/birth.js');
|
|
119
119
|
birth.stampBirth = async (_db, id) => { order.push(`birth:${id}`); };
|
|
120
|
+
const testProject = require('../modules/provisioning/test-project.js');
|
|
121
|
+
testProject.stampTestProject = async (_db, id) => { order.push(`test:${id}`); };
|
|
122
|
+
let rank = 'metic'; // who is asking: a test project is an archon's alone (task 1005036)
|
|
120
123
|
provisioning.createInstance = async (_db, args) => {
|
|
121
124
|
created = args;
|
|
122
125
|
const fresh = args.slug !== 'old-one';
|
|
@@ -126,7 +129,7 @@ provisioning.enqueueIntent = async (_db, _id, action) => { order.push(`enqueue:$
|
|
|
126
129
|
provisioning.getOpenIntent = async () => null;
|
|
127
130
|
provisioning.recordEvent = async () => {};
|
|
128
131
|
demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; order.push(`demo:${id}`); return null; };
|
|
129
|
-
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
132
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank }; next(); };
|
|
130
133
|
let stored = null;
|
|
131
134
|
provisioning.getInstanceById = async (_db, id) => (id === 41 ? { id: 41, owner_builder_id: 7, demo: stored } : null);
|
|
132
135
|
api.pool.query = async (sql, params) => { if (/UPDATE provisioning_instances SET demo/.test(sql)) stored = JSON.parse(params[1]); return { rows: [] }; };
|
|
@@ -173,8 +176,26 @@ test('a new project is stamped born, once, before its provision intent; an exist
|
|
|
173
176
|
assert.equal((await post('/provisioning/instances', { slug: 'born-real', hosting_shape: 'byo-host' })).status, 201);
|
|
174
177
|
assert.deepEqual(order, ['enqueue:provision'], 'a real project opens as the full hall: genesis is off by default (owner, 2026-11-01)');
|
|
175
178
|
order.length = 0;
|
|
176
|
-
|
|
177
|
-
|
|
179
|
+
// A test project (task 1005036): an archon's alone, marked once on the row, and the only way to be born with genesis.
|
|
180
|
+
const asked = await post('/provisioning/instances', { slug: 'sneaky', hosting_shape: 'byo-host', test: true });
|
|
181
|
+
assert.equal(asked.status, 403, 'a non-archon cannot make one');
|
|
182
|
+
assert.equal(asked.body.error.code, 'test_project_forbidden');
|
|
183
|
+
assert.deepEqual(order, [], 'and nothing is made, marked or queued');
|
|
184
|
+
const bare = await post('/provisioning/instances', { slug: 'bare-genesis', hosting_shape: 'byo-host', genesis: true });
|
|
185
|
+
assert.equal(bare.status, 400, 'genesis alone is no longer a way in');
|
|
186
|
+
assert.equal(bare.body.error.code, 'genesis_needs_test');
|
|
187
|
+
assert.deepEqual(order, []);
|
|
188
|
+
rank = 'archon';
|
|
189
|
+
try {
|
|
190
|
+
assert.equal((await post('/provisioning/instances', { slug: 'sandbox-a', hosting_shape: 'byo-host', test: true })).status, 201);
|
|
191
|
+
assert.deepEqual(order, ['test:41', 'enqueue:provision'], 'a test project is marked, not born into genesis');
|
|
192
|
+
order.length = 0;
|
|
193
|
+
assert.equal((await post('/provisioning/instances', { slug: 'sandbox-g', hosting_shape: 'byo-host', test: true, genesis: true })).status, 201);
|
|
194
|
+
assert.deepEqual(order, ['birth:41', 'test:41', 'enqueue:provision'], 'a test project that asks for genesis is born into it, marked, before the runner can read it');
|
|
195
|
+
order.length = 0;
|
|
196
|
+
assert.equal((await post('/provisioning/instances', { slug: 'archon-real', hosting_shape: 'byo-host' })).status, 201);
|
|
197
|
+
assert.deepEqual(order, ['enqueue:provision'], 'an ordinary project made by an archon is still an ordinary project');
|
|
198
|
+
} finally { rank = 'metic'; }
|
|
178
199
|
order.length = 0;
|
|
179
200
|
const again = await post('/provisioning/instances', { slug: 'old-one', hosting_shape: 'byo-host' });
|
|
180
201
|
assert.ok(again.status < 300, JSON.stringify(again.body));
|
|
@@ -182,7 +203,7 @@ test('a new project is stamped born, once, before its provision intent; an exist
|
|
|
182
203
|
const keep = birth.stampBirth;
|
|
183
204
|
birth.stampBirth = async () => { throw new Error('db hiccup'); };
|
|
184
205
|
try {
|
|
185
|
-
assert.equal((await post('/provisioning/instances', { slug: 'unlucky', hosting_shape: 'byo-host',
|
|
206
|
+
assert.equal((await post('/provisioning/instances', { slug: 'unlucky', hosting_shape: 'byo-host', demo: { people: 2, hours_each: 3 } })).status, 201,
|
|
186
207
|
'a missed marker costs the project its genesis, never its creation');
|
|
187
208
|
} finally { birth.stampBirth = keep; }
|
|
188
209
|
});
|