@bongos/core 1.19.999 → 1.19.1001
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 +116 -91
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +155 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/cost-ledger.js +74 -0
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +20 -39
- package/modules/provisioning/routes/provisioning.js +4 -3
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_cost_ledger.mjs +98 -0
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- package/tests/upgrade.mjs +2 -2
|
@@ -264,7 +264,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
|
|
|
264
264
|
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
265
265
|
// No assertion about FLAGS could have caught it, so this one resolves the composed
|
|
266
266
|
// cwd + relative CLI against the real filesystem, for every shape the door admits.
|
|
267
|
-
for (const shape of ['control-plane', '
|
|
267
|
+
for (const shape of ['control-plane', 'cloud-host', 'co-tenant']) {
|
|
268
268
|
const d = makeDeps({ served: '1.19.762' });
|
|
269
269
|
await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
270
270
|
const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
|
|
@@ -278,7 +278,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
|
|
|
278
278
|
|
|
279
279
|
await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
|
|
280
280
|
const d = makeDeps({ served: '1.19.762' });
|
|
281
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
281
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
282
282
|
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
283
283
|
assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
|
|
284
284
|
assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
|
|
@@ -475,7 +475,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
|
|
|
475
475
|
|
|
476
476
|
await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
|
|
477
477
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
478
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
478
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
479
479
|
const cmd = upgradeCall(d);
|
|
480
480
|
assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
|
|
481
481
|
assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
|
|
@@ -487,7 +487,7 @@ await t('the database is NAMED, not discovered — an adopted repo has no core a
|
|
|
487
487
|
// upgrade died with "cannot determine which database to migrate".
|
|
488
488
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
489
489
|
await R.coreUpgradeInstance(
|
|
490
|
-
inst({ hosting_shape: '
|
|
490
|
+
inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
491
491
|
assert.match(upgradeCall(d), /env PGDATABASE=hermeslines-marketing /, 'the runner passes the name the control plane already holds');
|
|
492
492
|
});
|
|
493
493
|
|
|
@@ -502,7 +502,7 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
|
|
|
502
502
|
// it so the fix above cannot quietly start requiring sudo where there is none.
|
|
503
503
|
const d = makeDeps({ served: '1.19.762' });
|
|
504
504
|
d.privileged = false;
|
|
505
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
505
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
506
506
|
const cmd = upgradeCall(d);
|
|
507
507
|
assert.doesNotMatch(cmd, /sudo/, 'no escalation when the runner is not privileged');
|
|
508
508
|
assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
|
|
@@ -531,7 +531,7 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
|
|
|
531
531
|
// Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
|
|
532
532
|
// that describes what happens AFTER the pin moves may differ, because a dry run stops
|
|
533
533
|
// before it.
|
|
534
|
-
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: '
|
|
534
|
+
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
|
|
535
535
|
const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
|
|
536
536
|
const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
|
|
537
537
|
for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
|
|
@@ -552,13 +552,13 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
|
|
|
552
552
|
await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
|
|
553
553
|
// The slug reaches a shell through instanceUser()/dbName(). Depending on another
|
|
554
554
|
// function's internals for that is one refactor away from an injection path.
|
|
555
|
-
assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: '
|
|
555
|
+
assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'cloud-host' }, '1.19.921', {}),
|
|
556
556
|
/invalid slug/);
|
|
557
557
|
});
|
|
558
558
|
|
|
559
559
|
await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
|
|
560
560
|
const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
|
|
561
|
-
const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: '
|
|
561
|
+
const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host' }, '1.19.921', deps);
|
|
562
562
|
assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
|
|
563
563
|
assert.ok(r.error);
|
|
564
564
|
});
|
|
@@ -716,7 +716,7 @@ await t('a tenant WITH its own account runs as that account — the security mod
|
|
|
716
716
|
// task 1003369: a distinct uid per instance is what stops a co-tenant reading a
|
|
717
717
|
// sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
|
|
718
718
|
const d = acctDeps({ accountExists: true });
|
|
719
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
719
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
720
720
|
assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
|
|
721
721
|
});
|
|
722
722
|
|
|
@@ -726,7 +726,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
726
726
|
// instance's own unit already says User=lars — but it IS a provisioning gap, so it
|
|
727
727
|
// is reported rather than silently papered over.
|
|
728
728
|
const d = acctDeps({ accountExists: false });
|
|
729
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
729
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
730
730
|
assert.equal(r.runAs, 'appsvc');
|
|
731
731
|
assert.equal(r.fellBack, true);
|
|
732
732
|
assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
|
|
@@ -741,7 +741,7 @@ await t('the control plane is never probed — it has no instance account by des
|
|
|
741
741
|
|
|
742
742
|
await t('the dry run is invoked as the account that actually exists', async () => {
|
|
743
743
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
744
|
-
await R.preflightUpgrade(inst({ hosting_shape: '
|
|
744
|
+
await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
745
745
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
746
746
|
assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
|
|
747
747
|
assert.doesNotMatch(cmd, /bongos-test4/);
|
|
@@ -749,7 +749,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
|
|
|
749
749
|
|
|
750
750
|
await t('the MOVE probes identically, so preview and move cannot diverge on the account', async () => {
|
|
751
751
|
const d = acctDeps({ accountExists: false });
|
|
752
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
752
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
753
753
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
754
754
|
assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
|
|
755
755
|
});
|
|
@@ -761,7 +761,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
|
|
|
761
761
|
// well exist. Only `no such user` is evidence.
|
|
762
762
|
const d = makeDeps({ served: '1.19.762' });
|
|
763
763
|
d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
|
|
764
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
764
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
765
765
|
assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
|
|
766
766
|
assert.equal(r.fellBack, false);
|
|
767
767
|
assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
|
|
@@ -776,7 +776,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
|
|
|
776
776
|
const d = makeDeps({ served: '1.19.762' });
|
|
777
777
|
d.controlExec = (cmd) => { asked.control.push(cmd); return { ok: true, stdout: '1001\n' }; };
|
|
778
778
|
d.exec = (cmd) => { asked.remote.push(cmd); return { ok: true, stdout: '', stderr: "id: 'x': no such user\n", softFailed: true }; };
|
|
779
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
779
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
780
780
|
assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
|
|
781
781
|
assert.equal(asked.control.length, 1, 'the control-plane executor was');
|
|
782
782
|
assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
|
|
@@ -788,7 +788,7 @@ await t('a planning tick probes ONCE, not twice', async () => {
|
|
|
788
788
|
// a subprocess per tick for nothing.
|
|
789
789
|
const d = acctDeps({ accountExists: false });
|
|
790
790
|
d.apply = false;
|
|
791
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
791
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
792
792
|
assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 1,
|
|
793
793
|
'exactly one probe: the dry run that actually executes');
|
|
794
794
|
});
|
|
@@ -800,7 +800,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
|
|
|
800
800
|
// helper's internals, so the assertion names the thrower.
|
|
801
801
|
const d = acctDeps({});
|
|
802
802
|
assert.throws(
|
|
803
|
-
() => R.resolveRunAs({ hosting_shape: '
|
|
803
|
+
() => R.resolveRunAs({ hosting_shape: 'cloud-host', slug: 'bad slug;rm -rf /' }, d),
|
|
804
804
|
/resolveRunAs: refusing to probe/,
|
|
805
805
|
'the refusal must come from this function, not from instanceUser further down',
|
|
806
806
|
);
|
|
@@ -809,7 +809,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
|
|
|
809
809
|
|
|
810
810
|
await t('a refusal reports what the command SAID, not "did not complete"', async () => {
|
|
811
811
|
const d = acctDeps({ softFail: true, dryRunStderr: 'sudo: unknown user bongos-test4\n' });
|
|
812
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
812
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
813
813
|
assert.equal(v.ok, false);
|
|
814
814
|
assert.match(v.error, /unknown user bongos-test4/,
|
|
815
815
|
`the reason was in hand and must travel — got: ${JSON.stringify(v.error)}`);
|
|
@@ -817,21 +817,21 @@ await t('a refusal reports what the command SAID, not "did not complete"', async
|
|
|
817
817
|
|
|
818
818
|
await t('a ✖ line still wins over the raw tail', async () => {
|
|
819
819
|
const d = acctDeps({ softFail: true, dryRunStdout: 'noise\n ✖ already on 1.19.927 (pass --force to re-run)\n' });
|
|
820
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
820
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
821
821
|
assert.match(v.error, /already on 1\.19\.927/);
|
|
822
822
|
assert.doesNotMatch(v.error, /^noise/, 'the parsed refusal is preferred to the raw tail');
|
|
823
823
|
});
|
|
824
824
|
|
|
825
825
|
await t('a fallback is recorded on the verdict the owner reads', async () => {
|
|
826
826
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
827
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
827
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
828
828
|
assert.equal(v.ran_as, 'appsvc');
|
|
829
829
|
assert.match(v.account_note, /no bongos-test4 account yet/);
|
|
830
830
|
});
|
|
831
831
|
|
|
832
832
|
await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
|
|
833
833
|
const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
|
|
834
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
834
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
835
835
|
assert.equal(v.ok, false, 'a command that said nothing is not a pass');
|
|
836
836
|
assert.match(v.error, /said nothing/);
|
|
837
837
|
});
|
|
@@ -22,7 +22,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
22
22
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
|
|
23
23
|
|
|
24
24
|
const preview = (id, slug, over = {}) => ({
|
|
25
|
-
instance_id: id, slug, hosting_shape: '
|
|
25
|
+
instance_id: id, slug, hosting_shape: 'cloud-host',
|
|
26
26
|
served_version: '1.19.800', recommended_version: '1.19.871',
|
|
27
27
|
behind: true, reading: 'ok', observed_at: new Date().toISOString(), open_intent: null,
|
|
28
28
|
...over,
|
|
@@ -30,11 +30,11 @@ const preview = (id, slug, over = {}) => ({
|
|
|
30
30
|
|
|
31
31
|
// The fleet as the control plane really reports it: mostly torn down, a couple live.
|
|
32
32
|
const FLEET = [
|
|
33
|
-
{ id: '31', slug: 'mercury', status: 'active', hosting_shape: '
|
|
33
|
+
{ id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host' },
|
|
34
34
|
{ id: '32', slug: 'hoodify-finance', status: 'active', hosting_shape: 'co-tenant' },
|
|
35
|
-
{ id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: '
|
|
36
|
-
{ id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: '
|
|
37
|
-
{ id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: '
|
|
35
|
+
{ id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'cloud-host' },
|
|
36
|
+
{ id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'cloud-host' },
|
|
37
|
+
{ id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
|
|
38
38
|
];
|
|
39
39
|
|
|
40
40
|
function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null } = {}) {
|
|
@@ -108,8 +108,12 @@ test('the server names the people on a row through a join, and the writes stay b
|
|
|
108
108
|
const body = src.slice(src.indexOf(`async function ${fn}`));
|
|
109
109
|
assert.match(body.slice(0, 700), /SELECT \$\{LIST_COLS\} \$\{LIST_FROM\}/, `${fn} reads the joined columns`);
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// Sliced to the end of the function rather than a character count: task
|
|
112
|
+
// 1004216 widened who may settle, so the body grew past the old 600-char
|
|
113
|
+
// window and a fixed slice would report the property missing when it is there.
|
|
114
|
+
const from = src.indexOf('async function setHelpRequestStatus');
|
|
115
|
+
const upd = src.slice(from, src.indexOf('\n}', from));
|
|
116
|
+
assert.match(upd, /RETURNING \$\{COLS\}/, 'the UPDATE cannot reach a joined alias, so it returns the bare columns');
|
|
113
117
|
});
|
|
114
118
|
|
|
115
119
|
test('the first line is decided: the ask\'s first sentence is the title, the rest the sub, no word lost', () => {
|
package/tests/help_requests.mjs
CHANGED
|
@@ -141,11 +141,62 @@ test('the addressed read excludes your own requests', () => {
|
|
|
141
141
|
assert.match(fn.slice(0, 700), /status = 'open'/, 'and settled requests are history');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
// WHO MAY SETTLE — widened by task 1004216 / ADR 0330, and RUN rather than
|
|
145
|
+
// grepped, because the rule is now a shape (asker OR addressee, except for
|
|
146
|
+
// withdraw) that a source scan would agree with while the SQL said otherwise.
|
|
147
|
+
async function settleSql(args) {
|
|
148
|
+
const calls = [];
|
|
149
|
+
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1 }] }; } };
|
|
150
|
+
await help.setHelpRequestStatus(args, { pool });
|
|
151
|
+
return calls[0];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test('the asker still settles, and the rule lives in the WHERE rather than in rank', async () => {
|
|
155
|
+
const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: [] });
|
|
156
|
+
assert.match(c.sql, /requested_by = \$2/, 'ownership is in the WHERE, not in rank');
|
|
157
|
+
assert.match(c.sql, /status = 'open'/, 'and a settled request cannot be re-settled');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('the ADDRESSEE can close an ask too — by name, or through a craft they belong to', async () => {
|
|
161
|
+
// The defect this fixes: a request addressed to someone who has just answered
|
|
162
|
+
// it on the page, and still cannot close it, is a surface arguing with itself.
|
|
163
|
+
const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: ['artist'] });
|
|
164
|
+
assert.match(c.sql, /needs_builder_id = \$2/, 'the person a request names');
|
|
165
|
+
assert.match(c.sql, /needs_craft = ANY\(\$5::text\[\]\)/, 'or a craft they belong to');
|
|
166
|
+
assert.deepEqual(c.params[4], ['artist'], 'and the crafts are bound, never interpolated');
|
|
167
|
+
assert.equal(c.params[3], false, 'the asker-only switch is off for answered');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('WITHDRAW stays the asker\'s alone — an addressee cannot make an ask disappear', async () => {
|
|
171
|
+
// Withdrawing means "I no longer need this", which only the person who needed
|
|
172
|
+
// it can say. An addressee who wants rid of an ask answers it and closes it,
|
|
173
|
+
// in the open, with their name on both.
|
|
174
|
+
const c = await settleSql({ id: 1, builderId: 7, status: 'withdrawn', crafts: ['artist'] });
|
|
175
|
+
assert.equal(c.params[3], true, 'the asker-only switch is ON for withdrawn');
|
|
176
|
+
assert.match(c.sql, /\$4::boolean IS FALSE/, 'and it gates the addressee branch, not the asker one');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a craft the caller does not hold is filtered out before it binds', async () => {
|
|
180
|
+
// The same fail-closed rule the addressed read learned the hard way: a craft
|
|
181
|
+
// value that did not come from the caller's own profile must never widen a
|
|
182
|
+
// scope. Here it would let anyone close any craft-addressed ask.
|
|
183
|
+
const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: ['wizard', 'artist'] });
|
|
184
|
+
assert.deepEqual(c.params[4], ['artist'], 'an unknown craft is dropped, not passed through');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('settling records WHO settled it', async () => {
|
|
188
|
+
const c = await settleSql({ id: 1, builderId: 7, status: 'answered', crafts: [] });
|
|
189
|
+
assert.match(c.sql, /settled_by = \$2/, 'now that two different people can close an ask, which one matters');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the route takes the caller\'s crafts from their PROFILE, never from the body', () => {
|
|
193
|
+
// C01's privacy hole was a caller-supplied craft widening a scope. The same
|
|
194
|
+
// value now decides who may CLOSE an ask, so it comes from the same place.
|
|
195
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
196
|
+
const idx = routes.indexOf("'PATCH /help-requests/:id'");
|
|
197
|
+
const body = routes.slice(idx, routes.indexOf('errorCode', idx));
|
|
198
|
+
assert.match(body, /craftsForBuilder\(req\.builder\.id\)/, 'read from the profile');
|
|
199
|
+
assert.ok(!/req\.body\.craft|req\.query\.craft/.test(body), 'and never from the caller');
|
|
149
200
|
});
|
|
150
201
|
|
|
151
202
|
// ---- it stands alone ------------------------------------------------------
|
|
@@ -217,7 +268,13 @@ test('the settle refusal no longer lets you enumerate ids', () => {
|
|
|
217
268
|
// The comment claimed one refusal for three cases; the code looked the row up
|
|
218
269
|
// and said which. Comment and code now agree.
|
|
219
270
|
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
220
|
-
|
|
271
|
+
// Scoped to the PATCH HANDLER, not to the rest of the file. Task 1004216 added
|
|
272
|
+
// the reply routes after it, and one of them looks a request up on purpose —
|
|
273
|
+
// a reply to an id that never existed should be a named 404 rather than an FK
|
|
274
|
+
// violation rendered as a 500. Slicing to the end of the file would read that
|
|
275
|
+
// lookup as this handler's and report a hole that is not here.
|
|
276
|
+
const from = routes.indexOf("'PATCH /help-requests/:id'");
|
|
277
|
+
const patch = routes.slice(from, routes.indexOf('help_request_update_failed', from));
|
|
221
278
|
assert.ok(!/getHelpRequest/.test(patch), 'no existence lookup in the refusal path');
|
|
222
279
|
const reasons = patch.match(/reason: '[^']+'/g) || [];
|
|
223
280
|
assert.equal(reasons.length, 1, `exactly one refusal reason, got ${reasons}`);
|
|
@@ -309,3 +366,141 @@ test('a help request addressed to a non-existent builder is a named 400', () =>
|
|
|
309
366
|
assert.match(routes, /builderExists/);
|
|
310
367
|
assert.match(routes, /no_such_builder/, 'the refusal names the field, not an FK 500');
|
|
311
368
|
});
|
|
369
|
+
|
|
370
|
+
// ---- replies (task 1004216, ADR 0330) --------------------------------------
|
|
371
|
+
//
|
|
372
|
+
// An ask could be filed and settled and never ANSWERED — the person being asked
|
|
373
|
+
// had no write at all, so the answer happened somewhere the project could not
|
|
374
|
+
// see, and the row sat open until its author remembered it.
|
|
375
|
+
test('a reply must say something, and cannot say more than an ask', () => {
|
|
376
|
+
const why = (input) => help.validateReply(input).errors.map((e) => e.reason);
|
|
377
|
+
assert.deepEqual(why({ body: 'the metrics stack was removed on the 15th' }), []);
|
|
378
|
+
assert.deepEqual(why({ body: ' ' }), ['required'], 'whitespace is not an answer');
|
|
379
|
+
assert.deepEqual(why({}), ['required']);
|
|
380
|
+
assert.deepEqual(why({ body: 'x'.repeat(help.MAX_REPLY + 1) }), ['too_long']);
|
|
381
|
+
assert.equal(help.MAX_REPLY, help.MAX_STUCK, 'an answer may be as long as the question');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('a reply is stored trimmed, against the ask it answers', async () => {
|
|
385
|
+
const calls = [];
|
|
386
|
+
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 5 }] }; } };
|
|
387
|
+
await help.createReply({ helpRequestId: 11, builderId: 3, body: ' moot, it was removed ' }, { pool });
|
|
388
|
+
assert.match(calls[0].sql, /INSERT INTO lifecycle_help_request_replies/);
|
|
389
|
+
assert.deepEqual(calls[0].params, [11, 3, 'moot, it was removed']);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('a thread reads OLDEST FIRST — it is a conversation, not a queue', async () => {
|
|
393
|
+
const calls = [];
|
|
394
|
+
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } };
|
|
395
|
+
await help.listReplies(11, { pool });
|
|
396
|
+
assert.match(calls[0].sql, /ORDER BY r\.created_at ASC, r\.id ASC/,
|
|
397
|
+
'and ties break on id, so two replies in the same second keep their order');
|
|
398
|
+
assert.match(calls[0].sql, /builder_login|builder_name/, 'it names who said it');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('every thread on a page is ONE query, not one per row', async () => {
|
|
402
|
+
// A list read renders a thread under every ask. Per-row fetching is what makes
|
|
403
|
+
// a page slow for reasons no single request explains.
|
|
404
|
+
const calls = [];
|
|
405
|
+
const pool = {
|
|
406
|
+
query: async (sql, params) => {
|
|
407
|
+
calls.push({ sql, params });
|
|
408
|
+
return { rows: [{ id: 1, help_request_id: '11', body: 'a' }, { id: 2, help_request_id: '12', body: 'b' }] };
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
const map = await help.repliesForRequests(['11', '12', '11'], { pool });
|
|
412
|
+
assert.equal(calls.length, 1, 'one round trip for the whole page');
|
|
413
|
+
assert.deepEqual(calls[0].params, [[11, 12]], 'de-duplicated, and bound as an array');
|
|
414
|
+
assert.equal(map.get('11').length, 1);
|
|
415
|
+
assert.equal(map.get('12').length, 1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('reading the replies of an unknown ask answers [] — a 404 there would be an id oracle', async () => {
|
|
419
|
+
// The asymmetry with POST (which DOES 404) is deliberate. This surface already
|
|
420
|
+
// learned once that an existence lookup on a refusal path lets a caller
|
|
421
|
+
// enumerate ids; a 404 on a GET rebuilds that oracle on the cheapest possible
|
|
422
|
+
// request. The POST can afford it because it is a write.
|
|
423
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
424
|
+
const idx = routes.indexOf("'GET /help-requests/:id/replies'");
|
|
425
|
+
assert.ok(idx > 0, 'the route exists');
|
|
426
|
+
const body = routes.slice(idx, routes.indexOf('errorCode', idx));
|
|
427
|
+
assert.ok(!/getHelpRequest|not_found/.test(body), 'no existence lookup, no 404');
|
|
428
|
+
|
|
429
|
+
const pool = { query: async () => ({ rows: [] }) };
|
|
430
|
+
assert.deepEqual(await help.listReplies(999999, { pool }), [], 'and the read itself is happy to find nothing');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('the bulk thread read is BOUNDED, and bounded PER ASK', async () => {
|
|
434
|
+
// listReplies caps at MAX_ROWS; this one backs three list routes on the page's
|
|
435
|
+
// own load path, so an unbounded result set would be the one place a busy
|
|
436
|
+
// project could make Collab arrive by the megabyte (grader).
|
|
437
|
+
//
|
|
438
|
+
// Per-ask, not one global LIMIT: a global cap lets one busy ask eat the budget
|
|
439
|
+
// and hands the rows below it empty threads, which reads as "nobody answered" —
|
|
440
|
+
// the exact lie this surface exists to stop telling.
|
|
441
|
+
const calls = [];
|
|
442
|
+
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } };
|
|
443
|
+
await help.repliesForRequests([11, 12], { pool });
|
|
444
|
+
assert.match(calls[0].sql, /ROW_NUMBER\(\) OVER \(PARTITION BY r\.help_request_id/, 'the window is per ask');
|
|
445
|
+
assert.match(calls[0].sql, new RegExp(`thread_pos <= ${help.MAX_THREAD_ON_LIST}`), 'and it is capped');
|
|
446
|
+
assert.ok(help.MAX_THREAD_ON_LIST > 0 && help.MAX_THREAD_ON_LIST <= help.MAX_ROWS);
|
|
447
|
+
// The order inside the window must match the single-thread read's, or the
|
|
448
|
+
// first N a list shows are a different N in a different order.
|
|
449
|
+
assert.match(calls[0].sql, /ORDER BY r\.created_at ASC, r\.id ASC\) AS thread_pos/);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('asking for no threads asks the database nothing', async () => {
|
|
453
|
+
const pool = { query: async () => { throw new Error('must not query'); } };
|
|
454
|
+
const map = await help.repliesForRequests([], { pool });
|
|
455
|
+
assert.equal(map.size, 0);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('a row without replies carries an EMPTY list, never a missing field', () => {
|
|
459
|
+
// A reader must never have to tell "no replies" apart from "this response does
|
|
460
|
+
// not carry replies" — the second is how a page decides to hide a thread that
|
|
461
|
+
// is really there.
|
|
462
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
463
|
+
assert.match(routes, /replies: threads\.get\(String\(r\.id\)\) \|\| \[\]/);
|
|
464
|
+
for (const route of ["'GET /help-requests/for-me'", "'GET /help-requests/mine'", "'GET /help-requests'"]) {
|
|
465
|
+
const idx = routes.indexOf(route);
|
|
466
|
+
assert.ok(idx > 0, `${route} exists`);
|
|
467
|
+
const body = routes.slice(idx, routes.indexOf('errorCode', idx));
|
|
468
|
+
assert.match(body, /withReplies\(/, `${route} carries its threads`);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('there is no way to edit or delete a reply, at either layer', () => {
|
|
473
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
474
|
+
assert.ok(!/router\.(patch|put|delete)\('\/help-requests\/:id\/replies/.test(routes), 'no route');
|
|
475
|
+
const src = read('modules', 'lifecycle', 'help-requests.js');
|
|
476
|
+
assert.ok(!/function (update|delete|edit)Reply/i.test(src), 'no data function');
|
|
477
|
+
// And the table refuses regardless — the guarantee must not rest on the routes
|
|
478
|
+
// that happen to exist today.
|
|
479
|
+
const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
|
|
480
|
+
assert.match(sql, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('the FK RESTRICTS — append-only and ON DELETE CASCADE cannot both be true', () => {
|
|
484
|
+
// A real bug, caught by applying this migration to a live Postgres rather than
|
|
485
|
+
// by reading it. The first draft cascaded, because "a reply has no meaning
|
|
486
|
+
// without the ask it answers" reads right — but a cascade IS a plain DELETE on
|
|
487
|
+
// the replies table, the append-only trigger refuses it, and deleting a help
|
|
488
|
+
// request that had ever been answered failed outright with a trigger error.
|
|
489
|
+
const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
|
|
490
|
+
assert.match(sql, /REFERENCES lifecycle_help_requests\(id\) ON DELETE RESTRICT/);
|
|
491
|
+
assert.ok(!/ON DELETE CASCADE/.test(sql.replace(/^\s*--.*$/gm, '')),
|
|
492
|
+
'no cascade in the statements — it would be refused by the trigger below it');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('a reply to an ask that never existed is a named 404, not an FK 500', () => {
|
|
496
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
497
|
+
const idx = routes.indexOf("'POST /help-requests/:id/replies'");
|
|
498
|
+
assert.ok(idx > 0, 'the route exists');
|
|
499
|
+
const body = routes.slice(idx, routes.indexOf('errorCode', idx));
|
|
500
|
+
assert.match(body, /getHelpRequest\(id\)/);
|
|
501
|
+
assert.match(body, /help_request_not_found/);
|
|
502
|
+
// The guard sits on the router line, ahead of the handler's own label — the
|
|
503
|
+
// same place the board read's test looks for it.
|
|
504
|
+
const line = routes.slice(routes.lastIndexOf('router.post', idx), idx);
|
|
505
|
+
assert.match(line, /api\.requireBuilder/, 'signed in, like every other route here');
|
|
506
|
+
});
|
|
@@ -85,7 +85,10 @@ test('the render guards a half-typed name per project, names an open run, and of
|
|
|
85
85
|
|
|
86
86
|
test('the ender no longer claims nothing on the page erases — it scopes that to itself and points at the card', () => {
|
|
87
87
|
const truth = fn('teardownTruth');
|
|
88
|
-
|
|
88
|
+
// Was 2 — one per hosting-shape branch. Task 1004182 left one branch (ADR 0327 decision 4
|
|
89
|
+
// removed 'dedicated'), so the count is 1. The property this case is really about is that
|
|
90
|
+
// the ender's reassurance is scoped to ITSELF, which the assertions below carry.
|
|
91
|
+
assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 1, 'the built-world truth ends on the reassurance');
|
|
89
92
|
assert.doesNotMatch(HUB, /Nothing here erases a project/);
|
|
90
93
|
const delFold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
91
94
|
assert.doesNotMatch(delFold, /no permanent-delete button/i, 'the capability exists now');
|
package/tests/init.mjs
CHANGED
|
@@ -240,8 +240,8 @@ test('provisioningRequest: null unless opted in; builds the POST body when opted
|
|
|
240
240
|
assert.equal(init.provisioningRequest(fullSpec()), null); // no block → not requested
|
|
241
241
|
const co = init.provisioningRequest(provSpec({})); // empty block = opted in, defaults
|
|
242
242
|
assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'byo-host' });
|
|
243
|
-
const full = init.provisioningRequest(provSpec({ shape: '
|
|
244
|
-
assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: '
|
|
243
|
+
const full = init.provisioningRequest(provSpec({ shape: 'cloud-host', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
|
|
244
|
+
assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'cloud-host', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
|
|
245
245
|
});
|
|
246
246
|
|
|
247
247
|
test('provisioningRequest: unknown shape falls back to byo-host, the free front door', () => {
|
|
@@ -256,7 +256,7 @@ test('kickoffTasks: provisioning requested → production-topology is AUTOMATED
|
|
|
256
256
|
firstAdmin: 'x', version: { id: 'V1', name: 'V1', done_when: 'x' } };
|
|
257
257
|
const manual = init.kickoffTasks(init.normalizeSpec(baseSpec)); // no provisioning
|
|
258
258
|
assert.ok(manual.some((t) => /Production topology/i.test(t.title)), 'manual keeps the production-topology task');
|
|
259
|
-
const withProv = init.kickoffTasks(init.normalizeSpec({ ...baseSpec, provisioning: { shape: '
|
|
259
|
+
const withProv = init.kickoffTasks(init.normalizeSpec({ ...baseSpec, provisioning: { shape: 'cloud-host' } }));
|
|
260
260
|
assert.ok(!withProv.some((t) => /Production topology/i.test(t.title)), 'provisioning drops the production-topology task');
|
|
261
261
|
assert.equal(withProv.length, manual.length - 1, 'exactly one fewer kickoff task');
|
|
262
262
|
assert.ok(withProv.some((t) => /OAuth/i.test(t.title)), 'OAuth stays manual');
|
|
@@ -272,7 +272,7 @@ test('normalizeSpec: coerces the provisioning block; drops it when absent', () =
|
|
|
272
272
|
});
|
|
273
273
|
|
|
274
274
|
test('validateSpec: valid provisioning passes; flags an underivable slug', () => {
|
|
275
|
-
assert.deepEqual(init.validateSpec(provSpec({ shape: '
|
|
275
|
+
assert.deepEqual(init.validateSpec(provSpec({ shape: 'cloud-host' })), []);
|
|
276
276
|
// productName 'x' slugifies to a 1-char slug (< the module's 2-char floor) and no
|
|
277
277
|
// explicit provisioning.slug overrides it → flagged before the server 400s.
|
|
278
278
|
const bad = init.normalizeSpec({ identity: { productName: 'x' }, repo: { owner: 'o', name: 'n' }, firstAdmin: 'x',
|
|
@@ -642,12 +642,12 @@ async function runSeedTests() {
|
|
|
642
642
|
|
|
643
643
|
await at('seedViaApi: provisioning success → POSTs the request, no manual topo task, provisioned:true', async () => {
|
|
644
644
|
const api = fakeApi([['/provisioning/instances', { ok: true, status: 201, data: { instance: { cost_estimate_usd: 12 } } }]]);
|
|
645
|
-
const out = await init.seedViaApi(provSpec({ shape: '
|
|
645
|
+
const out = await init.seedViaApi(provSpec({ shape: 'cloud-host', domain: 'x.example.com', tier: 's-1vcpu-2gb' }), { log: () => {}, api });
|
|
646
646
|
assert.equal(out.provisioned, true);
|
|
647
647
|
const prov = api.calls.find((c) => c.path.includes('/provisioning/instances'));
|
|
648
648
|
assert.ok(prov, 'POSTed the provisioning request');
|
|
649
649
|
assert.equal(prov.body.slug, 'cloud-bongos');
|
|
650
|
-
assert.equal(prov.body.hosting_shape, '
|
|
650
|
+
assert.equal(prov.body.hosting_shape, 'cloud-host');
|
|
651
651
|
assert.ok(!findTaskPost(api, /Production topology/), 'production-topology NOT filed as a manual task');
|
|
652
652
|
});
|
|
653
653
|
|
package/tests/onboard.mjs
CHANGED
|
@@ -129,8 +129,9 @@ test('onboardPlan lists repo-auth as an AUTO, control-plane step between scaffol
|
|
|
129
129
|
const step = plan.find((s) => s.key === 'repo-auth');
|
|
130
130
|
assert.ok(step, 'the plan carries a repo-auth step');
|
|
131
131
|
assert.equal(step.kind, 'auto', 'the runner mints + registers the key — no routine owner action');
|
|
132
|
-
assert.deepEqual(step.shapes, ['
|
|
133
|
-
'
|
|
132
|
+
assert.deepEqual(step.shapes, ['cloud-host', 'co-tenant'],
|
|
133
|
+
'byo-host has no platform runner, so its plan must not promise this leg');
|
|
134
|
+
assert.ok(!step.shapes.includes('byo-host'), 'named explicitly: the shape without a runner is the one that must be absent');
|
|
134
135
|
assert.ok(keys.indexOf('scaffold') < keys.indexOf('repo-auth'), 'the checkout exists before it can be wired');
|
|
135
136
|
assert.ok(keys.indexOf('repo-auth') < keys.indexOf('provision'), 'wired before the provision leg whose pull needs it');
|
|
136
137
|
assert.match(step.label, /read-only deploy key/, 'the label says what the key can and cannot do');
|
|
@@ -253,13 +254,13 @@ test('buildInitSpec maps answers to a STANDALONE init spec (domain → origin +
|
|
|
253
254
|
assert.equal(spec.repo.name, 'mercury');
|
|
254
255
|
assert.equal(spec.firstAdmin, 'acme');
|
|
255
256
|
assert.equal(spec.domains.publicOrigin, 'https://mercury.example.com');
|
|
256
|
-
assert.equal(spec.provisioning.shape, '
|
|
257
|
+
assert.equal(spec.provisioning.shape, 'cloud-host');
|
|
257
258
|
assert.equal(spec.provisioning.slug, 'mercury');
|
|
258
259
|
});
|
|
259
260
|
|
|
260
261
|
test('buildInitSpec without a domain still defaults a standalone provisioning shape by slug', () => {
|
|
261
262
|
const spec = ob.buildInitSpec({ productName: 'X', repoOwner: 'o', repoName: 'x', slug: 'x' });
|
|
262
|
-
assert.equal(spec.provisioning.shape, '
|
|
263
|
+
assert.equal(spec.provisioning.shape, 'cloud-host');
|
|
263
264
|
assert.equal(spec.provisioning.domain, null);
|
|
264
265
|
assert.equal(spec.domains, undefined);
|
|
265
266
|
});
|
|
@@ -42,7 +42,7 @@ const feed = require('../modules/platform-identity/projects-feed.js');
|
|
|
42
42
|
// something puts it there. Instance 18's shape — the one whose owner could not invite anyone.
|
|
43
43
|
const STANDALONE = Object.freeze({
|
|
44
44
|
id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
|
|
45
|
-
hosting_shape: '
|
|
45
|
+
hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 42,
|
|
46
46
|
description: 'A harbour for spikes.', type: 'research',
|
|
47
47
|
settings: { visibility: 'public' },
|
|
48
48
|
});
|
|
@@ -51,7 +51,7 @@ function mockPool(responses = []) {
|
|
|
51
51
|
// R18 proof watched reach `publishable: true` and never reach the map.
|
|
52
52
|
const STANDALONE = Object.freeze({
|
|
53
53
|
id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
|
|
54
|
-
hosting_shape: '
|
|
54
|
+
hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 42,
|
|
55
55
|
description: 'A harbour for spikes.', type: 'research',
|
|
56
56
|
settings: { visibility: 'public', joinability: 'apply' },
|
|
57
57
|
});
|
|
@@ -78,7 +78,7 @@ test('a standalone instance projects a complete catalog entry', () => {
|
|
|
78
78
|
test('the projection is BLIND to hosting shape — the old gate is what this task deletes', () => {
|
|
79
79
|
// shouldFederate() refused anything that was not 'co-tenant', which made hosting topology
|
|
80
80
|
// decide who is on the map. ADR 0182 D4 says the gate is IDENTITY. Three shapes, one row.
|
|
81
|
-
const shapes = ['
|
|
81
|
+
const shapes = ['cloud-host', 'co-tenant', 'dedicated'].map((hosting_shape) =>
|
|
82
82
|
bridge.catalogEntryForInstance({ ...STANDALONE, hosting_shape }, { ownerGithubId: 9001 }));
|
|
83
83
|
assert.deepEqual(shapes[0], shapes[1]);
|
|
84
84
|
assert.deepEqual(shapes[1], shapes[2]);
|
|
@@ -38,7 +38,7 @@ const api = require('../src/module-api.js');
|
|
|
38
38
|
const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
39
39
|
const BASE_ROW = Object.freeze({
|
|
40
40
|
id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
|
|
41
|
-
hosting_shape: '
|
|
41
|
+
hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 7,
|
|
42
42
|
description: null, type: 'research', settings: { visibility: 'public' },
|
|
43
43
|
released_domains: [], cost_estimate_usd: 0,
|
|
44
44
|
});
|
|
@@ -130,10 +130,10 @@ function call(method, path, body) {
|
|
|
130
130
|
beforeEach(() => { instance = { ...BASE_ROW }; published = []; healed = []; });
|
|
131
131
|
|
|
132
132
|
test('CREATE catalogues the project at birth, whatever its hosting shape', async () => {
|
|
133
|
-
// The wizard hard-codes hosting_shape '
|
|
133
|
+
// The wizard hard-codes hosting_shape 'cloud-host', which is exactly the shape
|
|
134
134
|
// shouldFederate() refused — so this is the call that ends "never catalogued at all".
|
|
135
135
|
const res = await call('POST', '/provisioning/instances', {
|
|
136
|
-
slug: 'spike-harbor', hosting_shape: '
|
|
136
|
+
slug: 'spike-harbor', hosting_shape: 'cloud-host', description: 'A harbour for spikes.',
|
|
137
137
|
});
|
|
138
138
|
assert.equal(res.status, 201);
|
|
139
139
|
assert.equal(published.length, 1);
|
|
@@ -145,7 +145,7 @@ test('CREATE catalogues even when the owner SKIPS the description', async () =>
|
|
|
145
145
|
// Skipping is the supported default: the row exists, the publish gate keeps it dark
|
|
146
146
|
// matter, and PATCH …/detail is the way back onto the map. The row has to be there for
|
|
147
147
|
// that way back to reach anything.
|
|
148
|
-
const res = await call('POST', '/provisioning/instances', { slug: 'spike-harbor', hosting_shape: '
|
|
148
|
+
const res = await call('POST', '/provisioning/instances', { slug: 'spike-harbor', hosting_shape: 'cloud-host' });
|
|
149
149
|
assert.equal(res.status, 201);
|
|
150
150
|
assert.equal(published.length, 1);
|
|
151
151
|
assert.equal(published[0].row.description, null);
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -2186,7 +2186,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2186
2186
|
assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
|
|
2187
2187
|
// what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
|
|
2188
2188
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
2189
|
-
assert.match(SCRIPT, /hosting_shape: '
|
|
2189
|
+
assert.match(SCRIPT, /hosting_shape: 'cloud-host',/, 'the shape is unchanged');
|
|
2190
2190
|
assert.match(SCRIPT, /target_ref: state\.owner \+ '\/' \+ state\.repo,/, 'the repo ref is unchanged');
|
|
2191
2191
|
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
|
|
2192
2192
|
// the kit walks every step and files against the stub
|