@bongos/core 1.19.141 → 1.19.143

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,243 @@
1
+ // tests/provisioning_slug_available.mjs
2
+ //
3
+ // GET /provisioning/slug-available (task 1002898 N10). The create wizard declared
4
+ // '<slug>.<apex> — yours from the moment it stands up' on FORMAT validation alone;
5
+ // no availability route existed anywhere, so a collision surfaced only as a 409 at
6
+ // the final Create click. This route answers the same question POST
7
+ // /provisioning/instances will, and the panel now waits for it before promising.
8
+ //
9
+ // The contract under test is AGREEMENT with the create route — the route is only
10
+ // worth having if its three refusals match the three ways create actually answers:
11
+ // taken → another builder holds the slug → create 409s slug_taken
12
+ // yours → the CALLER holds it → create is an idempotent re-request
13
+ // reserved → row free, <slug>.<apex> reserved → create 400s domain_reserved
14
+ // plus the same trim+lowercase normalization and the same isValidSlug bar, and the
15
+ // same byte-exact `slug text UNIQUE` lookup (no lower(slug), which would over-report).
16
+ //
17
+ // Also pinned: it requires a session (NOT public — the answer is a yes/no about every
18
+ // project slug on the instance) and it is rate-limited (same reason).
19
+ //
20
+ // DB-free: data-access fns are stubbed on the cached module object (the
21
+ // provisioning_default_domain.mjs harness). No network.
22
+ //
23
+ // Run: node tests/provisioning_slug_available.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
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
+ // ── harness ─────────────────────────────────────────────────────────────────
39
+ let slugHolder = null; // what getInstanceBySlug returns
40
+ let lookedUp = []; // the exact string the route handed the lookup
41
+ provisioning.getInstanceBySlug = async (_db, s) => { lookedUp.push(s); return slugHolder; };
42
+
43
+ const api = require('../src/module-api.js');
44
+ let authOk = true; // flipped to prove the route is gated, not public
45
+ api.requireBuilder = (req, res, next) => {
46
+ if (!authOk) return res.status(401).json({ error: { code: 'unauthenticated' } });
47
+ req.builder = { id: 7, github_login: 'owner' };
48
+ next();
49
+ };
50
+ api.requirePermission = () => (_req, _res, next) => next();
51
+ let brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
52
+ api.branding = () => ({ domains: brandingDomains, identity: { productName: 'Cloud Bongos' } });
53
+
54
+ const express = require('express');
55
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
56
+
57
+ // Each app gets its OWN router — and therefore its own fresh rate-limit bucket, so
58
+ // the limiter test cannot be starved (or starve) the correctness tests.
59
+ function mount() {
60
+ const app = express();
61
+ app.use(express.json());
62
+ app.use((_req, res, next) => {
63
+ res.fail = (code, opts, details) => {
64
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
65
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
66
+ };
67
+ next();
68
+ });
69
+ app.use('/api/bongos', provisioningRoutes());
70
+ return app;
71
+ }
72
+
73
+ const server = mount().listen(0);
74
+ await new Promise((r) => server.once('listening', r));
75
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
76
+
77
+ function get(path, origin = base) {
78
+ return new Promise((resolve, reject) => {
79
+ const req = http.request(`${origin}${path}`, { method: 'GET' }, (res) => {
80
+ let d = '';
81
+ res.on('data', (c) => { d += c; });
82
+ res.on('end', () => {
83
+ let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ }
84
+ resolve({ status: res.statusCode, body: j, raw: d, headers: res.headers });
85
+ });
86
+ });
87
+ req.on('error', reject);
88
+ req.end();
89
+ });
90
+ }
91
+
92
+ const heldBy = (ownerId) => ({ id: 41, owner_builder_id: ownerId, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' });
93
+
94
+ function reset() {
95
+ slugHolder = null;
96
+ lookedUp = [];
97
+ authOk = true;
98
+ brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
99
+ }
100
+
101
+ // ── the answer, and its agreement with the create route ─────────────────────
102
+ console.log('GET /provisioning/slug-available — the verdict:');
103
+
104
+ await ta('a free handle → available, with the address create would assign', async () => {
105
+ reset();
106
+ const res = await get('/provisioning/slug-available?slug=spike');
107
+ assert.equal(res.status, 200, res.raw);
108
+ assert.deepEqual(res.body, { slug: 'spike', available: true, reason: null, address: 'spike.cloudbongos.com' });
109
+ });
110
+
111
+ await ta("held by ANOTHER builder → taken (the create route's 409 slug_taken)", async () => {
112
+ reset();
113
+ slugHolder = heldBy(99);
114
+ const res = await get('/provisioning/slug-available?slug=spike');
115
+ assert.equal(res.status, 200, res.raw);
116
+ assert.equal(res.body.available, false);
117
+ assert.equal(res.body.reason, 'taken');
118
+ });
119
+
120
+ await ta("held by the CALLER → 'yours', never 'taken' — create re-requests it, it is not a new project", async () => {
121
+ reset();
122
+ slugHolder = heldBy(7);
123
+ const res = await get('/provisioning/slug-available?slug=spike');
124
+ assert.equal(res.status, 200, res.raw);
125
+ assert.equal(res.body.available, false);
126
+ assert.equal(res.body.reason, 'yours', 'the caller must not be told a stranger holds it');
127
+ });
128
+
129
+ await ta('the holder row NEVER travels — only the verdict', async () => {
130
+ reset();
131
+ slugHolder = heldBy(99);
132
+ const res = await get('/provisioning/slug-available?slug=spike');
133
+ assert.deepEqual(Object.keys(res.body).sort(), ['address', 'available', 'reason', 'slug']);
134
+ assert.doesNotMatch(res.raw, /owner_builder_id|status|"id"/, 'no instance shape may leak');
135
+ });
136
+
137
+ await ta("a free row whose <slug>.<apex> is platform-reserved → 'reserved' (create 400s domain_reserved)", async () => {
138
+ reset();
139
+ const res = await get('/provisioning/slug-available?slug=www');
140
+ assert.equal(res.status, 200, res.raw);
141
+ assert.equal(res.body.available, false);
142
+ assert.equal(res.body.reason, 'reserved');
143
+ // agrees with the helper the create route itself calls
144
+ assert.equal(provisioning.isReservedHostname('www.cloudbongos.com', 'cloudbongos.com'), true);
145
+ });
146
+
147
+ await ta('a taken row wins over reserved — the row is the uniqueness rule', async () => {
148
+ reset();
149
+ slugHolder = heldBy(99);
150
+ const res = await get('/provisioning/slug-available?slug=www');
151
+ assert.equal(res.body.reason, 'taken');
152
+ });
153
+
154
+ // ── normalization: the same sequence the create route runs ──────────────────
155
+ console.log('\nnormalization — must match the create route byte for byte:');
156
+
157
+ await ta('trim + lowercase before the lookup, exactly as create does', async () => {
158
+ reset();
159
+ const res = await get('/provisioning/slug-available?slug=%20%20SPIKE%20%20');
160
+ assert.equal(res.status, 200, res.raw);
161
+ assert.equal(res.body.slug, 'spike', 'the normalized handle is echoed back');
162
+ assert.deepEqual(lookedUp, ['spike'], 'the UNIQUE index is byte-exact — the lookup must be too');
163
+ });
164
+
165
+ await ta('an invalid handle → 400 bad_slug, the same bar and reason string as create', async () => {
166
+ for (const bad of ['a', '-bad', 'bad-', 'do--uble', 'Nope!', '', 'x'.repeat(41)]) {
167
+ reset();
168
+ const res = await get(`/provisioning/slug-available?slug=${encodeURIComponent(bad)}`);
169
+ assert.equal(res.status, 400, `${JSON.stringify(bad)} → ${res.raw}`);
170
+ assert.equal(res.body.error.code, 'bad_slug');
171
+ assert.match(res.body.error.reason, /lowercase, 2–40 chars/);
172
+ }
173
+ });
174
+
175
+ await ta('a missing slug param → 400 bad_slug, never a lookup', async () => {
176
+ reset();
177
+ const res = await get('/provisioning/slug-available');
178
+ assert.equal(res.status, 400, res.raw);
179
+ assert.equal(res.body.error.code, 'bad_slug');
180
+ assert.deepEqual(lookedUp, [], 'nothing may reach the database');
181
+ });
182
+
183
+ await ta('a blank apex (self-host) still answers availability, with no address to promise', async () => {
184
+ reset();
185
+ brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: '' };
186
+ const res = await get('/provisioning/slug-available?slug=www');
187
+ assert.equal(res.status, 200, res.raw);
188
+ assert.equal(res.body.address, null, 'nothing to assign → nothing to name');
189
+ assert.equal(res.body.available, true, 'with no apex there is no reserved address to collide with');
190
+ });
191
+
192
+ // ── the gate + the throttle ─────────────────────────────────────────────────
193
+ console.log('\nthe gate and the throttle — it is an enumeration oracle:');
194
+
195
+ await ta('requires a session — this route is NOT public', async () => {
196
+ reset();
197
+ authOk = false;
198
+ const res = await get('/provisioning/slug-available?slug=spike');
199
+ assert.equal(res.status, 401, res.raw);
200
+ assert.deepEqual(lookedUp, [], 'an unauthenticated caller learns nothing');
201
+ });
202
+
203
+ await ta('the limiter keys on the BUILDER, not the IP — one office address cannot starve itself', async () => {
204
+ reset();
205
+ const RL = require('../modules/provisioning/rate-limit.js');
206
+ // default key is still the IP (the public tls-check/status limiters depend on it)
207
+ assert.equal(RL.clientIp({ ip: '203.0.113.7' }), '203.0.113.7');
208
+ const seen = [];
209
+ const limiter = RL.makeAskRateLimiter({ max: 1, key: (req) => `b:${req.builder.id}` });
210
+ const runFor = (id) => new Promise((resolve) => {
211
+ const res = { set() {}, status(c) { this._c = c; return this; }, json(b) { resolve({ status: this._c, body: b }); } };
212
+ limiter({ ip: '203.0.113.7', builder: { id } }, res, () => { seen.push(id); resolve({ status: 200 }); });
213
+ });
214
+ assert.equal((await runFor(7)).status, 200, 'builder 7 gets its first call');
215
+ assert.equal((await runFor(7)).status, 429, 'and is throttled on its second');
216
+ assert.equal((await runFor(8)).status, 200,
217
+ 'a DIFFERENT builder on the same IP still gets through — the bucket is per-builder');
218
+ assert.deepEqual(seen, [7, 8]);
219
+ });
220
+
221
+ await ta('rate-limited — a crawler is shed with 429 + Retry-After', async () => {
222
+ reset();
223
+ const rlServer = mount().listen(0);
224
+ await new Promise((r) => rlServer.once('listening', r));
225
+ const rlBase = `http://127.0.0.1:${rlServer.address().port}/api/bongos`;
226
+ try {
227
+ let limited = null;
228
+ // the limiter's cap is 30/min/IP; walk past it and the next answer must be 429
229
+ for (let i = 0; i < 40 && !limited; i++) {
230
+ const res = await get(`/provisioning/slug-available?slug=spike${i}`, rlBase);
231
+ if (res.status === 429) limited = res;
232
+ }
233
+ assert.ok(limited, 'the route must stop answering under a flood');
234
+ assert.equal(limited.body.error, 'rate_limited');
235
+ assert.ok(Number(limited.headers['retry-after']) > 0, 'Retry-After must tell the caller when');
236
+ } finally {
237
+ rlServer.close();
238
+ }
239
+ });
240
+
241
+ server.close();
242
+ console.log(`\n${passed} passed, ${failed} failed`);
243
+ process.exit(failed ? 1 : 0);
@@ -251,6 +251,124 @@ test('the create-repo response keeps the html_url it already returned', async ()
251
251
  assert.match(fn, /saveDraft\(\)/, 'the draft is persisted after the URL lands on state');
252
252
  });
253
253
 
254
+ /* ── The address step's three choices (task 1002898 N2 / N8 / N10) ───────────
255
+ One root: the create path asserted things it had not verified. The step offered
256
+ an address it had not checked was free, hid the no-address opt-out under a fold
257
+ labelled the OPPOSITE choice, and re-sent a client-derived default the server
258
+ already owns. ── */
259
+
260
+ test('the no-address opt-out is visible at the step, not folded under "a domain I own" (N8)', async () => {
261
+ const { body } = await request('/projects', 'game.test.local');
262
+ const panel = (body.match(/<div class="addrConfirm" id="addrConfirm">([\s\S]*?)\n {14}<\/div>/) || [, ''])[1];
263
+ assert.ok(panel, 'the address confirmation box must be extractable');
264
+ const fold = (panel.match(/<details id="addrCustom">[\s\S]*?<\/details>/) || [''])[0];
265
+ assert.ok(fold, 'the bring-your-own-domain disclosure still exists');
266
+ assert.match(fold, /id="f-domain"/, 'the domain field is what the fold is FOR');
267
+ assert.doesNotMatch(fold, /f-noaddr/,
268
+ 'the opt-out for NO address may not live under a fold labelled "use a domain I own"');
269
+ assert.match(panel, /<\/details>[\s\S]*id="f-noaddr"/,
270
+ 'the opt-out sits after the disclosure, in the open');
271
+ // all three choices legible at once: assigned address, own domain, no address
272
+ assert.match(panel, /id="addrAssigned"/);
273
+ assert.match(fold, /Use a domain I own instead/);
274
+ assert.match(panel, /Start without an address/);
275
+ });
276
+
277
+ test('the ownership promise is gated on the availability answer, never on format (N10)', async () => {
278
+ const { body } = await request('/projects', 'game.test.local');
279
+ const fn = (body.match(/function renderAddrPanel\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
280
+ assert.ok(fn, 'renderAddrPanel() must be extractable');
281
+ // the promise now rides a branch that requires the server's verdict
282
+ assert.match(fn, /avail === 'free'[\s\S]{0,200}yours from the moment it stands up/,
283
+ "'yours from the moment it stands up' must only render on a free verdict");
284
+ // and the honest alternatives exist rather than a single optimistic string
285
+ assert.match(fn, /already taken/, 'a taken handle is said, not confirmed');
286
+ assert.match(fn, /you already have that one/i, "the caller's own handle reads as theirs, not as a stranger's");
287
+ assert.match(fn, /reserved by the platform/, 'a platform-reserved address is said');
288
+ assert.match(fn, /checking that it’s free/, 'the in-flight state has its own calm rung');
289
+ // the degrade: an unanswered check names the address WITHOUT promising it
290
+ assert.match(fn, /the address it would take/, 'no answer degrades to naming, not promising');
291
+ assert.match(fn, /couldn’t check whether that handle is free/, 'and says so honestly');
292
+ });
293
+
294
+ test('the availability check is debounced, generation-guarded, and fails to "unknown" (N10)', async () => {
295
+ const { body } = await request('/projects', 'game.test.local');
296
+ const fn = (body.match(/function checkSlugAvailable\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
297
+ assert.ok(fn, 'checkSlugAvailable() must be extractable');
298
+ assert.match(fn, /\/provisioning\/slug-available\?slug=/, 'it asks the real route');
299
+ assert.match(fn, /encodeURIComponent\(slug\)/, 'the handle is encoded into the query');
300
+ assert.match(fn, /clearTimeout\(slugCheckT\)[\s\S]*setTimeout\(/, 'debounced on a timer');
301
+ assert.match(fn, /myGen !== slugCheckGen/, 'a stale in-flight answer cannot overwrite a newer one');
302
+ assert.match(fn, /catch[\s\S]*state: 'unknown'/, 'a failed check is never read as free');
303
+ // a verdict only counts for the handle it was asked about
304
+ const guard = (body.match(/function slugAvailState\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
305
+ assert.match(guard, /slugAvail\.slug === state\.slug/, 'a verdict for another handle is not trusted');
306
+ });
307
+
308
+ test('only a DEFINITE negative blocks the step — unknown and checking never gate it (N10)', async () => {
309
+ const { body } = await request('/projects', 'game.test.local');
310
+ const fn = (body.match(/function validateStep\(step\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
311
+ assert.ok(fn, 'validateStep() must be extractable');
312
+ const gate = (fn.match(/if \(!state\.domain && !state\.noAddress\) \{[\s\S]*?\n {8}\}/) || [''])[0];
313
+ assert.ok(gate, 'the availability gate must be extractable');
314
+ assert.match(gate, /av === 'taken' \|\| av === 'yours' \|\| av === 'reserved'/,
315
+ 'exactly the three definite negatives block');
316
+ assert.doesNotMatch(gate, /'unknown'|'checking'/,
317
+ 'an unanswered or in-flight check must never trap the owner on the step');
318
+ });
319
+
320
+ test("the wizard's validSlug() agrees with the server's isValidSlug(), handle for handle (N10)", async () => {
321
+ // The whole availability contract is "answer what create would answer" — but the
322
+ // page gates on its OWN hand-copied validSlug() before it even asks. If the two
323
+ // drift, the wizard promises (or refuses) an address the server would not, which is
324
+ // the exact class of dishonesty N10 exists to close. Run both over the same corpus.
325
+ const { body } = await request('/projects', 'game.test.local');
326
+ const src = (body.match(/(function validSlug\(s\)\s*\{[\s\S]*?\n \})/) || [, ''])[1];
327
+ assert.ok(src, 'validSlug() must be extractable');
328
+ const clientValid = new Function('s', `${src}\nreturn validSlug(s);`);
329
+ const { createRequire } = await import('node:module');
330
+ const require = createRequire(import.meta.url);
331
+ const serverValid = require('../modules/provisioning/provisioning.js').isValidSlug;
332
+
333
+ const corpus = [
334
+ 'mercury', 'a1', 'x'.repeat(40), 'has-dash', 'a-b-c', '0start', 'end0',
335
+ 'a', '', 'x'.repeat(41), '-lead', 'trail-', 'do--uble', 'UPPER', 'sp ace',
336
+ 'dot.ted', 'under_score', 'bang!', 'uni✦code', '--', '-', 'a--b',
337
+ ];
338
+ for (const s of corpus) {
339
+ assert.equal(clientValid(s), serverValid(s),
340
+ `validSlug(${JSON.stringify(s)}) disagrees: page=${clientValid(s)} server=${serverValid(s)}`);
341
+ }
342
+ });
343
+
344
+ test('the create POST lets the SERVER assign the default address (N2)', async () => {
345
+ const { body } = await request('/projects', 'game.test.local');
346
+ // the wizard may only carry a bring-your-own domain; the <slug>.<apex> default is
347
+ // the server's (task 1002704), and re-asserting a client-derived copy could only
348
+ // disagree with it
349
+ assert.match(body, /domain: byoDomain\(\) \|\| undefined/,
350
+ 'only a bring-your-own domain travels in the create body');
351
+ assert.doesNotMatch(body, /domain: resolvedDomain\(\) \|\| undefined/,
352
+ 'the client-derived assigned default must no longer be re-sent');
353
+ const fn = (body.match(/function byoDomain\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
354
+ assert.ok(fn, 'byoDomain() must be extractable');
355
+ assert.match(fn, /looksLikeDomain\(state\.domain\)/, 'and only a domain that parses');
356
+ // the opt-out is still the only thing that means "no address"
357
+ assert.match(body, /no_address: state\.noAddress \? true : undefined/,
358
+ 'the explicit opt-out shape must survive — an absent domain now means "assign one"');
359
+ // resolvedDomain() still drives what the page DISPLAYS; it just stops driving the wire
360
+ assert.match(body, /resolvedDomain\(\)/, 'the display path keeps its own resolver');
361
+ });
362
+
363
+ test('the done panel promises the address the SERVER assigned, not our preview (N2)', async () => {
364
+ const { body } = await request('/projects', 'game.test.local');
365
+ assert.match(body, /var madeDomain = \(data\.instance && data\.instance\.domain\) \|\| '';/,
366
+ 'the created-instance stash reads the address off the create response');
367
+ assert.match(body, /domain: madeDomain/, 'and stashes that, not resolvedDomain()');
368
+ assert.match(body, /noaddr: !madeDomain/,
369
+ 'no address on the row IS the no-address state — derived, not re-asserted');
370
+ });
371
+
254
372
  test('the address row surfaces the manual-DNS note the runner recorded (F16)', async () => {
255
373
  const { body } = await request('/projects', 'game.test.local');
256
374
  assert.match(body, /inst\.dns_note/, 'the card reads the dns_note the runner writes');