@bongos/core 1.19.925 → 1.19.927

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.
@@ -93,10 +93,29 @@ await t('every action not explicitly allowed is refused on the self row', () =>
93
93
  }
94
94
  });
95
95
 
96
- await t('the allow-list is exactly the two acts that cannot destroy the box', () => {
96
+ await t('the allow-list is exactly the acts that cannot destroy the box', () => {
97
97
  // Pinned by NAME so widening it is a deliberate edit someone has to argue for,
98
- // not a side effect of adding an action to INTENT_ACTIONS.
99
- assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(), ['core-upgrade', 'restart']);
98
+ // not a side effect of adding an action to INTENT_ACTIONS. It did its job: adding
99
+ // 'core-upgrade-preflight' to INTENT_ACTIONS in task 1004121 did NOT reach here, and
100
+ // this line is what forced the argument to be made (task 1004125). The argument: a
101
+ // dry run returns at pre-flight having moved no pin, run no npm and restarted
102
+ // nothing, and the control plane is the only instance whose hall HAS an Upgrade
103
+ // page — so refusing the preview there protected nothing and removed everything.
104
+ assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(),
105
+ ['core-upgrade', 'core-upgrade-preflight', 'restart']);
106
+ });
107
+
108
+ await t('the preview is permitted on the control plane — the one row that can use it', () => {
109
+ assert.equal(G.intentAllowedForShape('core-upgrade-preflight', 'control-plane'), true);
110
+ });
111
+
112
+ await t('and widening it did NOT let the destructive acts through', () => {
113
+ // The whole risk of arguing one action onto an allow-list is arguing four.
114
+ for (const a of P.INTENT_ACTIONS) {
115
+ if (['core-upgrade', 'core-upgrade-preflight', 'restart'].includes(a)) continue;
116
+ assert.equal(G.intentAllowedForShape(a, 'control-plane'), false,
117
+ `'${a}' must still be refused against the box the platform runs on`);
118
+ }
100
119
  });
101
120
 
102
121
  await t('an ordinary instance is untouched by the guard', () => {
@@ -456,5 +475,227 @@ await t('no verdict at all is null — never an implied pass', () => {
456
475
  assert.equal(p.behind, true, 'and the rest of the reading is unaffected');
457
476
  });
458
477
 
478
+
479
+ console.log('\nthe body the page actually sends (task 1004124):');
480
+
481
+ // THE REAL VALIDATOR, NOT A STUB. Every other handler test here injects
482
+ // `validateOrRespond: () => false`, which is right for asserting branch behaviour and
483
+ // is exactly why this shipped broken: the route declares `{ strict: true }`, so a field
484
+ // the schema does not NAME is a 400 before the handler runs — and task 1004121 added
485
+ // the `dry_run` read without the declaration. Two correct halves, one contract, and no
486
+ // test across it. `src/bongos/routes/core-update.js` documents this same class in its
487
+ // own comment; this is the provisioning half learning it.
488
+ const HELPERS = require('../src/bongos/routes/_helpers.js');
489
+
490
+ const validatorHarness = () => {
491
+ const calls = [];
492
+ const provisioning = {
493
+ getInstanceById: async () => ACTIVE,
494
+ enqueueIntent: async (_db, id, action, requestedBy, targetVersion) => {
495
+ calls.push({ id, action, requestedBy, targetVersion });
496
+ return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
497
+ },
498
+ recordEvent: async () => {},
499
+ };
500
+ const handler = CU.makeCoreUpgradeHandler({
501
+ provisioning, pool: {}, parseId: () => 7,
502
+ validateOrRespond: HELPERS.validateOrRespond, log: { error() {} },
503
+ });
504
+ let body = null;
505
+ let status = 200;
506
+ const res = {
507
+ json: (b) => { body = b; return res; },
508
+ fail: (code, o) => { body = { code, ...(typeof o === 'object' ? o : { status: o }) }; status = (o && o.status) || o || 400; return res; },
509
+ status: (c) => { status = c; return res; },
510
+ };
511
+ return { calls, handler, res, provisioning, read: () => ({ body, status }) };
512
+ };
513
+
514
+ await t('the preview payload the page posts is ACCEPTED, not refused as an unknown field', async () => {
515
+ // deploy.js:183 sends exactly `{ to, dry_run: true }`. Live, before the fix, this
516
+ // answered 400 unknown_field — so the whole "see what would change" button was dead.
517
+ const h = validatorHarness();
518
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: true } }, h.res);
519
+ const { body } = h.read();
520
+ assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
521
+ const queued = h.calls.find((c) => c.action === 'core-upgrade-preflight');
522
+ assert.ok(queued, 'and it reaches the queue as a PREVIEW, not as a move');
523
+ });
524
+
525
+ await t('a guard refusal reaches the owner as ITS OWN sentence, not "internal error"', async () => {
526
+ // The guard writes `code`, `status: 409` and a publicMessage for exactly this moment.
527
+ // The catch discarded all three, so every control-plane refusal this route ever made
528
+ // was shown as a 500 "internal error" while the real sentence sat in the journal —
529
+ // which is how the preview refusal stayed invisible until it was pressed live.
530
+ // Elsewhere in this file we already assert that sentence is well-formed; nothing
531
+ // asserted it ARRIVED.
532
+ const h = validatorHarness();
533
+ const refusal = Object.assign(new Error('blocked'), {
534
+ code: 'control_plane_protected', status: 409,
535
+ publicMessage: 'That action cannot be aimed at the machine the platform itself runs on.',
536
+ });
537
+ h.provisioning.enqueueIntent = async () => { throw refusal; };
538
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
539
+ const { body } = h.read();
540
+ assert.equal(body && body.code, 'control_plane_protected', 'the guard\'s own code, not core_upgrade_failed');
541
+ assert.equal(body && body.status, 409, 'a refusal is not a 500');
542
+ assert.match((body && body.message) || '', /cannot be aimed at the machine/);
543
+ });
544
+
545
+ await t('an UNEXPECTED throw is still opaque — this is not a blanket error passthrough', async () => {
546
+ // Forwarding every error would leak internals. Only an error that declared itself
547
+ // public (code + status + publicMessage) is forwarded.
548
+ const h = validatorHarness();
549
+ h.provisioning.enqueueIntent = async () => { throw new Error('connection terminated: relation "secrets" does not exist'); };
550
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
551
+ const { body } = h.read();
552
+ assert.equal(body && body.code, 'core_upgrade_failed');
553
+ assert.equal(body && body.message, 'internal error');
554
+ assert.ok(!/secrets|relation/.test(JSON.stringify(body)), 'no internals reach the client');
555
+ });
556
+
557
+ await t('the move payload still validates, and still queues a move', async () => {
558
+ const h = validatorHarness();
559
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925' } }, h.res);
560
+ const { body } = h.read();
561
+ assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
562
+ assert.ok(h.calls.find((c) => c.action === 'core-upgrade'), 'no dry_run means a real move');
563
+ });
564
+
565
+ await t('strict is still strict — a typo is still refused', async () => {
566
+ // The fix is a declaration, not a loosening. Widening to non-strict would have made
567
+ // the button work and silently swallowed every future typo, which is the trade
568
+ // _helpers.js exists to refuse.
569
+ const h = validatorHarness();
570
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_runn: true } }, h.res);
571
+ const { body } = h.read();
572
+ assert.equal(body && body.code, 'validation_failed', 'an undeclared field is still a 400');
573
+ assert.equal(h.calls.length, 0, 'and nothing was queued');
574
+ });
575
+
576
+ await t('dry_run must be a boolean — a truthy string does not become a preview', async () => {
577
+ const h = validatorHarness();
578
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: 'yes' } }, h.res);
579
+ const { body } = h.read();
580
+ assert.equal(body && body.code, 'validation_failed',
581
+ 'the handler compares === true, so a string would silently queue a MOVE where a preview was asked for');
582
+ });
583
+
584
+
585
+ // A REAL REQUEST THROUGH THE ASSEMBLED ROUTER. The harness above proves the SCHEMA
586
+ // (it injects the very function object the route injects — api.validateOrRespond IS
587
+ // _helpers.validateOrRespond), but it cannot see the wiring: middleware order, a
588
+ // missing body parser, a mis-shaped handler mount. Those are invisible to any test
589
+ // that calls the factory directly, which is the same lesson
590
+ // src/bongos/routes/core-update.js records about its own asyncHandler bug. So: boot
591
+ // the actual router over express and POST to it.
592
+ await t('a real POST through the assembled router accepts the preview body', async () => {
593
+ const api = require('../src/module-api.js');
594
+ const prov = require('../modules/provisioning/provisioning.js');
595
+ const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
596
+
597
+ const saved = {};
598
+ const stub = (obj, key, value) => {
599
+ saved[key] = Object.getOwnPropertyDescriptor(obj, key);
600
+ Object.defineProperty(obj, key, { value, configurable: true, writable: true });
601
+ };
602
+ // Only the two auth gates and the data layer are replaced. parseId and
603
+ // validateOrRespond stay REAL — they are the contract under test.
604
+ stub(api, 'requireBuilder', (req, _res, next) => { req.builder = { id: 42 }; next(); });
605
+ stub(api, 'requirePermission', () => (_req, _res, next) => next());
606
+ stub(api, 'pool', {});
607
+ const queued = [];
608
+ const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
609
+ prov.getInstanceById = async () => ACTIVE;
610
+ prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
611
+ queued.push({ id, action, requestedBy, targetVersion });
612
+ return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
613
+ };
614
+ prov.recordEvent = async () => {};
615
+ delete require.cache[routePath];
616
+
617
+ const express = require('express');
618
+ const app = express();
619
+ app.use(express.json());
620
+ app.use(require(routePath)());
621
+ const server = app.listen(0);
622
+ await new Promise((r) => server.once('listening', r));
623
+ try {
624
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/provisioning/instances/41/core-upgrade`, {
625
+ method: 'POST',
626
+ headers: { 'content-type': 'application/json' },
627
+ body: JSON.stringify({ to: '1.19.925', dry_run: true }),
628
+ });
629
+ const body = await res.json().catch(() => null);
630
+ assert.notEqual(res.status, 400, `the page's own payload must not be refused — got ${res.status} ${JSON.stringify(body)}`);
631
+ const preview = queued.find((q) => q.action === 'core-upgrade-preflight');
632
+ assert.ok(preview, `and it reaches the queue as a preview — queued ${JSON.stringify(queued)}`);
633
+ assert.equal(preview.targetVersion, '1.19.925');
634
+ assert.equal(preview.requestedBy, 'api:archon:42', 'attributed through the real auth seam');
635
+ } finally {
636
+ server.close();
637
+ for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
638
+ prov.getInstanceById = savedProv.get;
639
+ prov.enqueueIntent = savedProv.enq;
640
+ prov.recordEvent = savedProv.rec;
641
+ delete require.cache[routePath];
642
+ }
643
+ });
644
+
645
+
646
+ // THE AUDIT, AS A GUARD RATHER THAN A CLAIM. The task asked for a sweep of the other
647
+ // provisioning write routes for this same shape. A sweep performed once and reported in
648
+ // prose is indistinguishable, later, from one never performed — and the next route to
649
+ // add a strict schema gets no help from it. So the sweep is a test: it re-runs on every
650
+ // build and fails on the next occurrence rather than on this one.
651
+ await t('every strict body schema in this module declares each field its handler reads', () => {
652
+ const dir = join(repoRoot, 'modules', 'provisioning');
653
+ const files = [];
654
+ const walk = (d) => {
655
+ for (const e of readdirSync(d, { withFileTypes: true })) {
656
+ if (e.name === 'node_modules' || e.name === 'tests') continue;
657
+ const full = join(d, e.name);
658
+ if (e.isDirectory()) walk(full);
659
+ else if (e.name.endsWith('.js')) files.push(full);
660
+ }
661
+ };
662
+ walk(dir);
663
+
664
+ const offenders = [];
665
+ let strictSites = 0;
666
+ for (const f of files) {
667
+ const src = readFileSync(f, 'utf8');
668
+ const lines = src.split('\n');
669
+ for (let i = 0; i < lines.length; i += 1) {
670
+ if (!lines[i].includes('validateOrRespond(req')) continue;
671
+ // The call spans until its `)) return;`; the handler body follows it.
672
+ let j = i;
673
+ while (j < lines.length && j < i + 40 && !lines[j].includes(')) return;')) j += 1;
674
+ const call = lines.slice(i, j + 1).join('\n');
675
+ if (!/strict:\s*true/.test(call)) continue; // extras are ignored by design
676
+ strictSites += 1;
677
+ // The SCHEMA argument only — brace-matched from the `{` after `res,`. Scanning the
678
+ // whole call would also count the options object's keys as declared fields, which
679
+ // is exactly the kind of over-broad match that turns a guard into decoration.
680
+ const open = call.indexOf('{', call.indexOf('res,'));
681
+ let depth = 0;
682
+ let close = open;
683
+ for (; close < call.length; close += 1) {
684
+ if (call[close] === '{') depth += 1;
685
+ else if (call[close] === '}') { depth -= 1; if (!depth) break; }
686
+ }
687
+ const schemaSrc = call.slice(open, close + 1);
688
+ const declared = new Set([...schemaSrc.matchAll(/(\w+)\s*:\s*\{/g)].map((m) => m[1]));
689
+ const body = lines.slice(j + 1, j + 140).join('\n');
690
+ const read = new Set([...body.matchAll(/req\.body\.(\w+)/g)].map((m) => m[1]));
691
+ const missing = [...read].filter((k) => !declared.has(k));
692
+ if (missing.length) offenders.push(`${f.replace(repoRoot + '/', '')}:${i + 1} reads ${missing.join(', ')} but does not declare it`);
693
+ }
694
+ }
695
+ assert.ok(strictSites > 0, 'the scan found no strict schema at all — it has stopped looking');
696
+ assert.deepEqual(offenders, [],
697
+ 'under strict, a field the schema does not name is a 400 BEFORE the handler runs, so reading it is unreachable code and a broken endpoint');
698
+ });
699
+
459
700
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
460
701
  if (failed) process.exit(1);
package/tests/helpers.mjs CHANGED
@@ -630,5 +630,104 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
630
630
  };
631
631
  }
632
632
 
633
+
634
+ // bootIdeaPage — boot the REAL modules/hall-ui/public/idea.js in a vm against a
635
+ // stub DOM, and hand back the HTML it wrote into #idea-body plus every request
636
+ // it made.
637
+ //
638
+ // The sibling of bootProfilePage above, and here for the same reason: the
639
+ // descent surface (task 1004094 / BV2.R10) is asserted at the RENDERED layer,
640
+ // because the first of the three findings inherited from the abandoned task
641
+ // 1004054 is that testing the server-side read alone is exactly how a renderer
642
+ // bug survives. What a reader is handed is HTML, so that is what the suite reads.
643
+ //
644
+ // Unlike profile.js, this page writes straight into an element's innerHTML
645
+ // rather than through OTBKit's ledger, so the capture is the element itself.
646
+ const IDEA_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea.js'), 'utf8');
647
+ export function bootIdeaPage({ idea = null, status = 200, template = null, viewer = { id: 1 }, ideaId = '1' } = {}) {
648
+ const els = new Map();
649
+ const makeEl = () => ({
650
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
651
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
652
+ setAttribute() {}, getAttribute() { return null; },
653
+ addEventListener() {},
654
+ querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
655
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
656
+ });
657
+ const documentObj = {
658
+ title: '',
659
+ body: { dataset: { page: 'idea' } },
660
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
661
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
662
+ querySelector(sel) {
663
+ const id = String(sel).replace(/^#/, '');
664
+ // The mark control's button and error slot exist only once the page has
665
+ // rendered one. Returning a live element for every selector would make
666
+ // the boot look as though it had wired a control that is not on the page.
667
+ if (id === 'ig-mark-btn' || id === 'ig-mark-err') {
668
+ return /id="ig-mark-btn"/.test(els.get('idea-body')?.innerHTML || '') ? this.getElementById(id) : null;
669
+ }
670
+ return this.getElementById(id);
671
+ },
672
+ querySelectorAll() { return []; },
673
+ createElement() { return makeEl(); },
674
+ addEventListener() {},
675
+ };
676
+
677
+ const requests = [];
678
+ const client = {
679
+ inbox: {
680
+ getInboxId: async ({ id }) => {
681
+ requests.push(`/inbox/${id}`);
682
+ return { status, ok: status === 200, data: status === 200 ? { idea } : null };
683
+ },
684
+ getInboxTemplate: async () => {
685
+ requests.push('/inbox/template');
686
+ return template
687
+ ? { status: 200, ok: true, data: { template } }
688
+ : { status: 500, ok: false, data: null };
689
+ },
690
+ postInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
691
+ deleteInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
692
+ },
693
+ me: {
694
+ getMe: async () => {
695
+ requests.push('/me');
696
+ return viewer
697
+ ? { status: 200, ok: true, data: { builder: viewer } }
698
+ : { status: 401, ok: false, data: null };
699
+ },
700
+ },
701
+ };
702
+
703
+ const windowObj = {
704
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
705
+ BongosClient: { createClient: () => client },
706
+ OTB: {
707
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
708
+ $: (sel) => documentObj.querySelector(sel),
709
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
710
+ toast() {},
711
+ },
712
+ location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/idea/${ideaId}`, assign() {} },
713
+ localStorage: { getItem: () => null, setItem() {} },
714
+ addEventListener() {},
715
+ };
716
+
717
+ const sandbox = {
718
+ window: windowObj, document: documentObj, location: windowObj.location,
719
+ console, setTimeout, clearTimeout, URLSearchParams,
720
+ };
721
+ sandbox.globalThis = sandbox;
722
+ vm.createContext(sandbox);
723
+ vm.runInContext(IDEA_JS, sandbox, { filename: 'idea.js' });
724
+
725
+ return {
726
+ requests,
727
+ html: () => documentObj.getElementById('idea-body').innerHTML,
728
+ title: () => documentObj.title,
729
+ };
730
+ }
731
+
633
732
  // One microtask turn — profile.js boots through a chain of awaits.
634
733
  export const settle = () => new Promise((r) => setTimeout(r, 0));