@bongos/core 1.19.925 → 1.19.926

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.
@@ -456,5 +456,195 @@ await t('no verdict at all is null — never an implied pass', () => {
456
456
  assert.equal(p.behind, true, 'and the rest of the reading is unaffected');
457
457
  });
458
458
 
459
+
460
+ console.log('\nthe body the page actually sends (task 1004124):');
461
+
462
+ // THE REAL VALIDATOR, NOT A STUB. Every other handler test here injects
463
+ // `validateOrRespond: () => false`, which is right for asserting branch behaviour and
464
+ // is exactly why this shipped broken: the route declares `{ strict: true }`, so a field
465
+ // the schema does not NAME is a 400 before the handler runs — and task 1004121 added
466
+ // the `dry_run` read without the declaration. Two correct halves, one contract, and no
467
+ // test across it. `src/bongos/routes/core-update.js` documents this same class in its
468
+ // own comment; this is the provisioning half learning it.
469
+ const HELPERS = require('../src/bongos/routes/_helpers.js');
470
+
471
+ const validatorHarness = () => {
472
+ const calls = [];
473
+ const provisioning = {
474
+ getInstanceById: async () => ACTIVE,
475
+ enqueueIntent: async (_db, id, action, requestedBy, targetVersion) => {
476
+ calls.push({ id, action, requestedBy, targetVersion });
477
+ return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
478
+ },
479
+ recordEvent: async () => {},
480
+ };
481
+ const handler = CU.makeCoreUpgradeHandler({
482
+ provisioning, pool: {}, parseId: () => 7,
483
+ validateOrRespond: HELPERS.validateOrRespond, log: { error() {} },
484
+ });
485
+ let body = null;
486
+ let status = 200;
487
+ const res = {
488
+ json: (b) => { body = b; return res; },
489
+ fail: (code, o) => { body = { code, ...(typeof o === 'object' ? o : { status: o }) }; status = (o && o.status) || o || 400; return res; },
490
+ status: (c) => { status = c; return res; },
491
+ };
492
+ return { calls, handler, res, read: () => ({ body, status }) };
493
+ };
494
+
495
+ await t('the preview payload the page posts is ACCEPTED, not refused as an unknown field', async () => {
496
+ // deploy.js:183 sends exactly `{ to, dry_run: true }`. Live, before the fix, this
497
+ // answered 400 unknown_field — so the whole "see what would change" button was dead.
498
+ const h = validatorHarness();
499
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: true } }, h.res);
500
+ const { body } = h.read();
501
+ assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
502
+ const queued = h.calls.find((c) => c.action === 'core-upgrade-preflight');
503
+ assert.ok(queued, 'and it reaches the queue as a PREVIEW, not as a move');
504
+ });
505
+
506
+ await t('the move payload still validates, and still queues a move', async () => {
507
+ const h = validatorHarness();
508
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925' } }, h.res);
509
+ const { body } = h.read();
510
+ assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
511
+ assert.ok(h.calls.find((c) => c.action === 'core-upgrade'), 'no dry_run means a real move');
512
+ });
513
+
514
+ await t('strict is still strict — a typo is still refused', async () => {
515
+ // The fix is a declaration, not a loosening. Widening to non-strict would have made
516
+ // the button work and silently swallowed every future typo, which is the trade
517
+ // _helpers.js exists to refuse.
518
+ const h = validatorHarness();
519
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_runn: true } }, h.res);
520
+ const { body } = h.read();
521
+ assert.equal(body && body.code, 'validation_failed', 'an undeclared field is still a 400');
522
+ assert.equal(h.calls.length, 0, 'and nothing was queued');
523
+ });
524
+
525
+ await t('dry_run must be a boolean — a truthy string does not become a preview', async () => {
526
+ const h = validatorHarness();
527
+ await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: 'yes' } }, h.res);
528
+ const { body } = h.read();
529
+ assert.equal(body && body.code, 'validation_failed',
530
+ 'the handler compares === true, so a string would silently queue a MOVE where a preview was asked for');
531
+ });
532
+
533
+
534
+ // A REAL REQUEST THROUGH THE ASSEMBLED ROUTER. The harness above proves the SCHEMA
535
+ // (it injects the very function object the route injects — api.validateOrRespond IS
536
+ // _helpers.validateOrRespond), but it cannot see the wiring: middleware order, a
537
+ // missing body parser, a mis-shaped handler mount. Those are invisible to any test
538
+ // that calls the factory directly, which is the same lesson
539
+ // src/bongos/routes/core-update.js records about its own asyncHandler bug. So: boot
540
+ // the actual router over express and POST to it.
541
+ await t('a real POST through the assembled router accepts the preview body', async () => {
542
+ const api = require('../src/module-api.js');
543
+ const prov = require('../modules/provisioning/provisioning.js');
544
+ const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
545
+
546
+ const saved = {};
547
+ const stub = (obj, key, value) => {
548
+ saved[key] = Object.getOwnPropertyDescriptor(obj, key);
549
+ Object.defineProperty(obj, key, { value, configurable: true, writable: true });
550
+ };
551
+ // Only the two auth gates and the data layer are replaced. parseId and
552
+ // validateOrRespond stay REAL — they are the contract under test.
553
+ stub(api, 'requireBuilder', (req, _res, next) => { req.builder = { id: 42 }; next(); });
554
+ stub(api, 'requirePermission', () => (_req, _res, next) => next());
555
+ stub(api, 'pool', {});
556
+ const queued = [];
557
+ const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
558
+ prov.getInstanceById = async () => ACTIVE;
559
+ prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
560
+ queued.push({ id, action, requestedBy, targetVersion });
561
+ return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
562
+ };
563
+ prov.recordEvent = async () => {};
564
+ delete require.cache[routePath];
565
+
566
+ const express = require('express');
567
+ const app = express();
568
+ app.use(express.json());
569
+ app.use(require(routePath)());
570
+ const server = app.listen(0);
571
+ await new Promise((r) => server.once('listening', r));
572
+ try {
573
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/provisioning/instances/41/core-upgrade`, {
574
+ method: 'POST',
575
+ headers: { 'content-type': 'application/json' },
576
+ body: JSON.stringify({ to: '1.19.925', dry_run: true }),
577
+ });
578
+ const body = await res.json().catch(() => null);
579
+ assert.notEqual(res.status, 400, `the page's own payload must not be refused — got ${res.status} ${JSON.stringify(body)}`);
580
+ const preview = queued.find((q) => q.action === 'core-upgrade-preflight');
581
+ assert.ok(preview, `and it reaches the queue as a preview — queued ${JSON.stringify(queued)}`);
582
+ assert.equal(preview.targetVersion, '1.19.925');
583
+ assert.equal(preview.requestedBy, 'api:archon:42', 'attributed through the real auth seam');
584
+ } finally {
585
+ server.close();
586
+ for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
587
+ prov.getInstanceById = savedProv.get;
588
+ prov.enqueueIntent = savedProv.enq;
589
+ prov.recordEvent = savedProv.rec;
590
+ delete require.cache[routePath];
591
+ }
592
+ });
593
+
594
+
595
+ // THE AUDIT, AS A GUARD RATHER THAN A CLAIM. The task asked for a sweep of the other
596
+ // provisioning write routes for this same shape. A sweep performed once and reported in
597
+ // prose is indistinguishable, later, from one never performed — and the next route to
598
+ // add a strict schema gets no help from it. So the sweep is a test: it re-runs on every
599
+ // build and fails on the next occurrence rather than on this one.
600
+ await t('every strict body schema in this module declares each field its handler reads', () => {
601
+ const dir = join(repoRoot, 'modules', 'provisioning');
602
+ const files = [];
603
+ const walk = (d) => {
604
+ for (const e of readdirSync(d, { withFileTypes: true })) {
605
+ if (e.name === 'node_modules' || e.name === 'tests') continue;
606
+ const full = join(d, e.name);
607
+ if (e.isDirectory()) walk(full);
608
+ else if (e.name.endsWith('.js')) files.push(full);
609
+ }
610
+ };
611
+ walk(dir);
612
+
613
+ const offenders = [];
614
+ let strictSites = 0;
615
+ for (const f of files) {
616
+ const src = readFileSync(f, 'utf8');
617
+ const lines = src.split('\n');
618
+ for (let i = 0; i < lines.length; i += 1) {
619
+ if (!lines[i].includes('validateOrRespond(req')) continue;
620
+ // The call spans until its `)) return;`; the handler body follows it.
621
+ let j = i;
622
+ while (j < lines.length && j < i + 40 && !lines[j].includes(')) return;')) j += 1;
623
+ const call = lines.slice(i, j + 1).join('\n');
624
+ if (!/strict:\s*true/.test(call)) continue; // extras are ignored by design
625
+ strictSites += 1;
626
+ // The SCHEMA argument only — brace-matched from the `{` after `res,`. Scanning the
627
+ // whole call would also count the options object's keys as declared fields, which
628
+ // is exactly the kind of over-broad match that turns a guard into decoration.
629
+ const open = call.indexOf('{', call.indexOf('res,'));
630
+ let depth = 0;
631
+ let close = open;
632
+ for (; close < call.length; close += 1) {
633
+ if (call[close] === '{') depth += 1;
634
+ else if (call[close] === '}') { depth -= 1; if (!depth) break; }
635
+ }
636
+ const schemaSrc = call.slice(open, close + 1);
637
+ const declared = new Set([...schemaSrc.matchAll(/(\w+)\s*:\s*\{/g)].map((m) => m[1]));
638
+ const body = lines.slice(j + 1, j + 140).join('\n');
639
+ const read = new Set([...body.matchAll(/req\.body\.(\w+)/g)].map((m) => m[1]));
640
+ const missing = [...read].filter((k) => !declared.has(k));
641
+ if (missing.length) offenders.push(`${f.replace(repoRoot + '/', '')}:${i + 1} reads ${missing.join(', ')} but does not declare it`);
642
+ }
643
+ }
644
+ assert.ok(strictSites > 0, 'the scan found no strict schema at all — it has stopped looking');
645
+ assert.deepEqual(offenders, [],
646
+ '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');
647
+ });
648
+
459
649
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
460
650
  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));