@bongos/core 1.19.1081 → 1.20.2
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 +528 -193
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1666 -1558
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +169 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/rot_inbox.mjs +8 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// tests/pin_write_key.mjs — the hub's per-project WRITE deploy key (task 1004291, ADR 0350,
|
|
2
|
+
// amending ADR 0176).
|
|
3
|
+
//
|
|
4
|
+
// A hosted project's upgraded pin must reach its GitHub repo days after the owner's
|
|
5
|
+
// one-hour token is gone. The hub holds a second, WRITABLE deploy key per project, kept
|
|
6
|
+
// outside every checkout, readable by the runner alone. What these pin, in the order it
|
|
7
|
+
// would hurt if wrong:
|
|
8
|
+
// * the key is never readable by the project's own account: outside the checkout, in a
|
|
9
|
+
// 0700 directory, mode 0600, the modes VERIFIED — and a project with no account of its
|
|
10
|
+
// own (same uid as the runner) is refused a key altogether
|
|
11
|
+
// * it is registered read_only:false under its own title (distinct from the pull key)
|
|
12
|
+
// * pushUpgradePin tries it first, then the owner token, then origin, never forced
|
|
13
|
+
// * teardown (which disconnect runs first) and its no-token path remove it
|
|
14
|
+
// * /deploy tells the owner reconnecting sets up LASTING access
|
|
15
|
+
// Hermetic: recording fakes for exec, GitHub and the filesystem — no ssh-keygen, no git,
|
|
16
|
+
// no network.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/pin_write_key.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
// ESM named imports: knip credits a CJS export only when a test imports it this way.
|
|
24
|
+
import { pushUpgradePin } from '../scripts/gds/provision-core-upgrade.js';
|
|
25
|
+
import { ensurePinWriteKey, pinKeyDir, pinKeyPath, pinKeyTitle } from '../scripts/gds/provision-pin-key.js';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
process.env.PROVISION_APP_USER = 'appsvc';
|
|
29
|
+
process.env.PROVISION_PROCESS_MANAGER = 'systemd';
|
|
30
|
+
const RP = require('../scripts/gds/provision-repo.js');
|
|
31
|
+
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
32
|
+
const P = require('../scripts/gds/provision.js');
|
|
33
|
+
|
|
34
|
+
let passed = 0, failed = 0;
|
|
35
|
+
const t = async (name, fn) => {
|
|
36
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
37
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const KEYDIR = '/var/lib/cb-pin-keys';
|
|
41
|
+
const PUB = 'ssh-ed25519 AAAApinkey cloudbongos-pin-acme-site';
|
|
42
|
+
const inst = (over = {}) => ({ id: 9, slug: 'acme-site', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 5, target_ref: 'acme/site', port: 3011, db_name: 'acme-site', ...over });
|
|
43
|
+
|
|
44
|
+
// One bag: recorded shell, a fake GitHub, an in-memory "filesystem" of key files.
|
|
45
|
+
function rig({ account = true, accountSaid = null, token = 'ghu_TOK', priv = true, admin = true, keys = [], files = [], modes = '700 600', addReadOnly = false, addThrows = [], apply = true } = {}) {
|
|
46
|
+
const cmds = [], api = [], logs = [];
|
|
47
|
+
const present = new Set(files);
|
|
48
|
+
const throwQueue = [...addThrows];
|
|
49
|
+
const deps = {
|
|
50
|
+
apply, pinKeyDir: KEYDIR, log: (l) => logs.push(String(l)),
|
|
51
|
+
getGithubToken: async () => (token ? { token } : null),
|
|
52
|
+
exec: (cmd) => {
|
|
53
|
+
cmds.push(cmd);
|
|
54
|
+
if (/^id -u /.test(cmd)) return account ? { ok: true, stdout: '1001\n' } : { ok: true, softFailed: true, stdout: '', stderr: accountSaid || 'id: bongos-acme-site: no such user' };
|
|
55
|
+
if (/ssh-keygen/.test(cmd)) { const m = /-f (\S+)\)/.exec(cmd); present.add(m[1]); present.add(`${m[1]}.pub`); }
|
|
56
|
+
if (/^stat -c/.test(cmd)) return { ok: true, stdout: `${modes.split(' ').join('\n')}\n` };
|
|
57
|
+
if (/^rm -f /.test(cmd)) for (const p of cmd.slice(6).split(' ')) present.delete(p);
|
|
58
|
+
return { ok: true, stdout: '' };
|
|
59
|
+
},
|
|
60
|
+
fsImpl: { existsSync: (p) => present.has(p), readFileSync: () => `${PUB}\n` },
|
|
61
|
+
githubRepos: {
|
|
62
|
+
getUserRepo: async (_t, a) => { api.push(['get', `${a.owner}/${a.name}`]); return { private: priv, admin }; },
|
|
63
|
+
listDeployKeys: async () => { api.push(['list']); return keys; },
|
|
64
|
+
addDeployKey: async (_t, a) => {
|
|
65
|
+
api.push(['add', a.title, a.key, a.readOnly]);
|
|
66
|
+
const e = throwQueue.shift(); if (e) throw Object.assign(new Error(e), { code: e });
|
|
67
|
+
return { id: 77, title: a.title, read_only: addReadOnly };
|
|
68
|
+
},
|
|
69
|
+
deleteDeployKey: async (_t, a) => { api.push(['del', a.keyId]); return true; },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return { deps, cmds, api, logs, present };
|
|
73
|
+
}
|
|
74
|
+
const keyPath = (i = inst()) => `${KEYDIR}/${i.slug}.key`;
|
|
75
|
+
|
|
76
|
+
console.log('\nminting the write key:');
|
|
77
|
+
|
|
78
|
+
await t('the key is minted OUTSIDE the checkout, in a 0700 directory, 0600, with a umask, and the modes are CHECKED', async () => {
|
|
79
|
+
const { deps, cmds } = rig();
|
|
80
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
81
|
+
assert.equal(r.minted, true);
|
|
82
|
+
assert.equal(r.keyPath, keyPath());
|
|
83
|
+
assert.ok(!keyPath().startsWith(RP.standaloneRoot(inst())), 'never inside the project checkout');
|
|
84
|
+
assert.ok(!keyPath().includes(RP.deployKeyPath(inst())), 'and not the read-only pull key\'s location');
|
|
85
|
+
const all = cmds.join('\n');
|
|
86
|
+
assert.match(all, new RegExp(`umask 077 && mkdir -p ${KEYDIR}\\) && chmod 700 ${KEYDIR}`), 'directory 0700');
|
|
87
|
+
assert.match(all, /umask 077 && ssh-keygen -q -t ed25519 -N '' -C cloudbongos-pin-acme-site -f \S+acme-site\.key/, 'ed25519, no passphrase, born under umask 077');
|
|
88
|
+
assert.match(all, new RegExp(`chmod 600 ${keyPath()}`), 'key 0600');
|
|
89
|
+
assert.match(all, new RegExp(`stat -c '%a' ${KEYDIR} ${keyPath()}`), 'and the modes are read back');
|
|
90
|
+
assert.ok(!cmds.some((c) => c.includes('cloudbongos-deploy-key')), 'the read-only pull key is left exactly as it was');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await t('a key whose modes cannot be confirmed as 700/600 is DELETED and never registered', async () => {
|
|
94
|
+
for (const modes of ['700 644', '755 600', '700 640']) {
|
|
95
|
+
const { deps, cmds, api } = rig({ modes });
|
|
96
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
97
|
+
assert.equal(r.skipped, 'failed', modes);
|
|
98
|
+
assert.ok(!api.some((c) => c[0] === 'add'), `${modes}: nothing was registered`);
|
|
99
|
+
assert.ok(cmds.some((c) => c === `rm -f ${keyPath()} ${keyPath()}.pub`), `${modes}: the loose key is removed`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await t('it is registered WRITABLE (read_only:false) under its OWN title, distinct from the pull key', async () => {
|
|
104
|
+
const { deps, api } = rig();
|
|
105
|
+
await ensurePinWriteKey(inst(), deps);
|
|
106
|
+
const add = api.find((c) => c[0] === 'add');
|
|
107
|
+
assert.equal(add[1], 'cloudbongos-pin-acme-site');
|
|
108
|
+
assert.notEqual(add[1], RP.deployKeyTitle(inst()));
|
|
109
|
+
assert.equal(add[3], false, 'a write key: read_only false');
|
|
110
|
+
assert.equal(add[2], PUB, 'the local public half');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await t('a PUBLIC repo gets one too — today it has no per-repo key at all', async () => {
|
|
114
|
+
const { deps, api } = rig({ priv: false });
|
|
115
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
116
|
+
assert.equal(r.minted, true);
|
|
117
|
+
assert.ok(api.some((c) => c[0] === 'add'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await t('idempotent: an already-registered, current, writable key is left alone', async () => {
|
|
121
|
+
const { deps, api, cmds } = rig({ files: [keyPath(), `${keyPath()}.pub`], keys: [{ id: 5, title: 'cloudbongos-pin-acme-site', key: 'ssh-ed25519 AAAApinkey', read_only: false }] });
|
|
122
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
123
|
+
assert.equal(r.minted, false);
|
|
124
|
+
assert.ok(!api.some((c) => c[0] === 'add' || c[0] === 'del'));
|
|
125
|
+
assert.ok(!cmds.some((c) => /ssh-keygen/.test(c)), 'no new keypair');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await t('a same-title key registered READ-ONLY (or stale) is replaced, never accumulated', async () => {
|
|
129
|
+
for (const stale of [{ key: 'ssh-ed25519 AAAApinkey', read_only: true }, { key: 'ssh-ed25519 AAAAother', read_only: false }]) {
|
|
130
|
+
const { deps, api } = rig({ files: [keyPath(), `${keyPath()}.pub`], keys: [{ id: 5, title: 'cloudbongos-pin-acme-site', ...stale }] });
|
|
131
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
132
|
+
assert.equal(r.minted, true);
|
|
133
|
+
assert.deepEqual(api.filter((c) => c[0] === 'del'), [['del', 5]]);
|
|
134
|
+
assert.equal(api.find((c) => c[0] === 'add')[3], false);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await t('GitHub holding the key elsewhere (422): a fresh pair is minted and tried ONCE', async () => {
|
|
139
|
+
const { deps, api, cmds } = rig({ addThrows: ['key_in_use'] });
|
|
140
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
141
|
+
assert.equal(r.minted, true);
|
|
142
|
+
assert.equal(api.filter((c) => c[0] === 'add').length, 2);
|
|
143
|
+
assert.equal(cmds.filter((c) => /ssh-keygen/.test(c)).length, 2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await t('GitHub answering read-only for the "writable" key: it is removed and reported, not trusted', async () => {
|
|
147
|
+
const { deps, api } = rig({ addReadOnly: true });
|
|
148
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
149
|
+
assert.equal(r.skipped, 'registered_read_only');
|
|
150
|
+
assert.deepEqual(api.filter((c) => c[0] === 'del'), [['del', 77]]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('no token, or a token that is not repository admin: nothing is registered, and nothing throws', async () => {
|
|
154
|
+
const a = rig({ token: null });
|
|
155
|
+
assert.equal((await ensurePinWriteKey(inst(), a.deps)).skipped, 'no_token');
|
|
156
|
+
assert.ok(!a.api.length);
|
|
157
|
+
const b = rig({ admin: false });
|
|
158
|
+
assert.equal((await ensurePinWriteKey(inst(), b.deps)).skipped, 'not_admin');
|
|
159
|
+
assert.ok(!b.api.some((c) => c[0] === 'add'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await t('a dry run plans only: no shell, no GitHub', async () => {
|
|
163
|
+
const { deps, cmds, api } = rig({ apply: false });
|
|
164
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
165
|
+
assert.equal(r.planned, true);
|
|
166
|
+
assert.equal(cmds.length, 0);
|
|
167
|
+
assert.equal(api.length, 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
console.log('\na project with no account of its own is refused a key:');
|
|
171
|
+
|
|
172
|
+
await t('SHARED-UID project: refused "needs its own account first", and neither key-making nor GitHub is touched', async () => {
|
|
173
|
+
for (const account of [{ account: false }, { account: false, accountSaid: 'sudo: timed out' }]) {
|
|
174
|
+
const { deps, cmds, api } = rig(account);
|
|
175
|
+
const r = await ensurePinWriteKey(inst(), deps);
|
|
176
|
+
assert.equal(r.skipped, 'needs_own_account');
|
|
177
|
+
assert.match(r.note, /needs its own account first/);
|
|
178
|
+
assert.ok(!cmds.some((c) => /ssh-keygen|mkdir/.test(c)), 'no key was made');
|
|
179
|
+
assert.equal(api.length, 0, 'GitHub was not asked');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await t('the account probe asks for the project\'s own bongos-<slug> user, not the runner\'s', async () => {
|
|
184
|
+
const { deps, cmds } = rig();
|
|
185
|
+
await ensurePinWriteKey(inst(), deps);
|
|
186
|
+
assert.ok(cmds.includes(`id -u ${RP.instanceUser(inst())}`));
|
|
187
|
+
assert.notEqual(RP.instanceUser(inst()), 'appsvc');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await t('only a hosted (cloud-host) project with a repo is ever considered', async () => {
|
|
191
|
+
const a = rig();
|
|
192
|
+
assert.equal((await ensurePinWriteKey(inst({ hosting_shape: 'co-tenant' }), a.deps)).skipped, 'shape');
|
|
193
|
+
assert.equal((await ensurePinWriteKey(inst({ target_ref: null }), a.deps)).skipped, 'no_repo');
|
|
194
|
+
assert.equal(a.cmds.length, 0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await t('the default key directory is the runner\'s own home, never under the projects\' base', () => {
|
|
198
|
+
const dir = pinKeyDir({});
|
|
199
|
+
assert.ok(!dir.startsWith(RP.standaloneRoot({ slug: 'x' }).replace(/\/x$/, '')), dir);
|
|
200
|
+
assert.match(dir, /cloudbongos\/pin-keys$/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
console.log('\npushUpgradePin prefers it, and still falls back in order:');
|
|
204
|
+
|
|
205
|
+
// Drives pushUpgradePin directly: exec answers each command shape from a script.
|
|
206
|
+
function pushRig({ token = 'ghu_TOK', hasKey = true, keyPush = null, tokenPush = null, originPush = null, account = true, gh = {} } = {}) {
|
|
207
|
+
const r = rig({ token, account, files: hasKey ? [keyPath(), `${keyPath()}.pub`] : [], ...gh });
|
|
208
|
+
const ran = [];
|
|
209
|
+
const base = r.deps.exec;
|
|
210
|
+
r.deps.exec = (cmd, opts) => {
|
|
211
|
+
ran.push({ cmd, env: opts && opts.env });
|
|
212
|
+
if (/symbolic-ref/.test(cmd)) return { ok: true, stdout: 'main\n' };
|
|
213
|
+
if (/core\.sshCommand=/.test(cmd)) return keyPush || { ok: true, stdout: '' };
|
|
214
|
+
if (/push https:\/\/x-access-token@/.test(cmd)) return tokenPush || { ok: true, stdout: '' };
|
|
215
|
+
if (/ push origin /.test(cmd)) return originPush || { ok: true, stdout: '' };
|
|
216
|
+
return base(cmd, opts);
|
|
217
|
+
};
|
|
218
|
+
r.deps.controlWriteFile = () => ({ ok: true });
|
|
219
|
+
r.ran = ran;
|
|
220
|
+
return r;
|
|
221
|
+
}
|
|
222
|
+
const refused = (stderr) => ({ ok: true, softFailed: true, stdout: '', stderr });
|
|
223
|
+
const call = (r, re) => r.ran.find((c) => re.test(c.cmd));
|
|
224
|
+
const push = (r) => pushUpgradePin(inst(), r.deps, { instanceDir: RP.standaloneRoot(inst()) });
|
|
225
|
+
|
|
226
|
+
await t('the WRITE KEY is tried FIRST, pinned to the key alone, over ssh, hardened, never forced', async () => {
|
|
227
|
+
const r = pushRig();
|
|
228
|
+
const out = await push(r);
|
|
229
|
+
assert.equal(out.pushed, true);
|
|
230
|
+
assert.equal(out.via, 'write-key');
|
|
231
|
+
const c = call(r, /core\.sshCommand=/);
|
|
232
|
+
assert.ok(c.cmd.includes(`core.sshCommand="ssh -F /dev/null -i ${keyPath()} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`), c.cmd);
|
|
233
|
+
assert.ok(c.cmd.includes('push git@github.com:acme/site.git HEAD:main'), c.cmd);
|
|
234
|
+
assert.match(c.cmd, /-c core\.hooksPath=\/dev\/null /);
|
|
235
|
+
assert.equal(c.env.GIT_TERMINAL_PROMPT, '0');
|
|
236
|
+
assert.doesNotMatch(c.cmd, /--force|\+HEAD/);
|
|
237
|
+
assert.ok(!call(r, /x-access-token/), 'the owner token was not needed, so it was not used');
|
|
238
|
+
assert.ok(!call(r, / push origin /));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await t('write key REFUSED → the owner token is tried next', async () => {
|
|
242
|
+
const r = pushRig({ keyPush: refused('git@github.com: Permission denied (publickey).') });
|
|
243
|
+
const out = await push(r);
|
|
244
|
+
assert.equal(out.via, 'owner-token');
|
|
245
|
+
const at = (re) => r.ran.findIndex((c) => re.test(c.cmd));
|
|
246
|
+
assert.ok(at(/core\.sshCommand=/) < at(/x-access-token@/), 'key, then token');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await t('write key refused and NO token → the checkout\'s own remote is the last resort', async () => {
|
|
250
|
+
const r = pushRig({ token: null, keyPush: refused('Permission denied (publickey)') });
|
|
251
|
+
const out = await push(r);
|
|
252
|
+
assert.equal(out.via, 'origin');
|
|
253
|
+
const at = (re) => r.ran.findIndex((c) => re.test(c.cmd));
|
|
254
|
+
assert.ok(at(/core\.sshCommand=/) < at(/ push origin /));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await t('GitHub AHEAD of the box (rejected non-fast-forward) on the write key: reported "behind", no other credential is tried', async () => {
|
|
258
|
+
const r = pushRig({ keyPush: refused(' ! [rejected] HEAD -> main (fetch first)') });
|
|
259
|
+
const out = await push(r);
|
|
260
|
+
assert.equal(out.pushed, false);
|
|
261
|
+
assert.equal(out.code, 'behind');
|
|
262
|
+
assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await t('NO KEY yet but a FRESH TOKEN: the key is minted first (reconnecting is enough), then used', async () => {
|
|
266
|
+
const r = pushRig({ hasKey: false });
|
|
267
|
+
const out = await push(r);
|
|
268
|
+
assert.equal(out.via, 'write-key');
|
|
269
|
+
assert.ok(r.api.some((c) => c[0] === 'add' && c[3] === false), 'registered writable on the way');
|
|
270
|
+
assert.ok(r.ran.findIndex((c) => /ssh-keygen/.test(c.cmd)) < r.ran.findIndex((c) => /core\.sshCommand=/.test(c.cmd)));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await t('NO KEY and the mint cannot happen: the push still falls back to the token, as before', async () => {
|
|
274
|
+
const r = pushRig({ hasKey: false, gh: { admin: false } });
|
|
275
|
+
const out = await push(r);
|
|
276
|
+
assert.equal(out.via, 'owner-token');
|
|
277
|
+
assert.ok(!call(r, /core\.sshCommand=/), 'no key, so no key push');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await t('NO KEY, NO TOKEN, refused: the note says reconnecting sets up LASTING access', async () => {
|
|
281
|
+
const r = pushRig({ hasKey: false, token: null, originPush: refused('ERROR: The key you are authenticating with has been marked as read only.') });
|
|
282
|
+
const out = await push(r);
|
|
283
|
+
assert.equal(out.code, 'no_access');
|
|
284
|
+
assert.match(out.note, /Reconnecting GitHub with repository access sets up lasting access/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await t('a SHARED-UID project that cannot get a key is told it "needs its own account first", not to reconnect', async () => {
|
|
288
|
+
const r = pushRig({ hasKey: false, token: null, account: false, originPush: refused('ERROR: The key you are authenticating with has been marked as read only.') });
|
|
289
|
+
const out = await push(r);
|
|
290
|
+
assert.equal(out.code, 'needs_account');
|
|
291
|
+
assert.match(out.note, /needs its own account/);
|
|
292
|
+
const web = require('../modules/provisioning/core-upgrade.js').parseRepoNote(out.note);
|
|
293
|
+
assert.equal(web.code, 'needs_account', 'and the web tier reads it back');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
console.log('\na slug that is not a plain slug never reaches a shell:');
|
|
297
|
+
|
|
298
|
+
const EVIL = 'acme;touch /tmp/pwn';
|
|
299
|
+
|
|
300
|
+
await t('minting refuses an invalid slug before running anything', async () => {
|
|
301
|
+
const { deps, cmds, api } = rig();
|
|
302
|
+
const r = await ensurePinWriteKey(inst({ slug: EVIL }), deps);
|
|
303
|
+
assert.equal(r.skipped, 'invalid_slug');
|
|
304
|
+
assert.equal(cmds.length, 0, 'no command was built from it');
|
|
305
|
+
assert.equal(api.length, 0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await t('the key path and title builders throw on an invalid slug instead of interpolating it', () => {
|
|
309
|
+
assert.throws(() => pinKeyPath(inst({ slug: EVIL }), { pinKeyDir: KEYDIR }), /invalid slug/);
|
|
310
|
+
assert.throws(() => pinKeyTitle(inst({ slug: '../../etc' })), /invalid slug/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await t('pushUpgradePin, called on its own, refuses an invalid slug before any git or key command', async () => {
|
|
314
|
+
const r = pushRig();
|
|
315
|
+
const out = await pushUpgradePin(inst({ slug: EVIL }), r.deps, { instanceDir: '/srv/x' });
|
|
316
|
+
assert.equal(out.pushed, false);
|
|
317
|
+
assert.equal(out.code, 'failed');
|
|
318
|
+
assert.ok(!r.ran.some((c) => /pwn/.test(c.cmd)), 'the name was refused, not interpolated');
|
|
319
|
+
assert.equal(r.ran.length, 0, 'nothing ran at all');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
console.log('\nrevoked on teardown and disconnect:');
|
|
323
|
+
|
|
324
|
+
function teardownRig(over = {}) {
|
|
325
|
+
const r = rig({ files: [keyPath(), `${keyPath()}.pub`], keys: [
|
|
326
|
+
{ id: 11, title: 'cloudbongos-deploy-acme-site', key: 'x', read_only: true },
|
|
327
|
+
{ id: 12, title: 'cloudbongos-pin-acme-site', key: 'y', read_only: false },
|
|
328
|
+
], ...over });
|
|
329
|
+
Object.assign(r.deps, {
|
|
330
|
+
db: { query: async () => ({ rows: [] }) },
|
|
331
|
+
provisioning: { setInstanceStatus: async () => {}, recordEvent: async () => {} },
|
|
332
|
+
processManager: { removeService: () => {} },
|
|
333
|
+
controlExec: r.deps.exec, apply: true,
|
|
334
|
+
});
|
|
335
|
+
return r;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await t('teardown revokes BOTH keys by title on GitHub and removes the write key\'s local half', async () => {
|
|
339
|
+
const r = teardownRig();
|
|
340
|
+
await P.teardownInstance(inst(), r.deps);
|
|
341
|
+
assert.deepEqual(r.api.filter((c) => c[0] === 'del').map((c) => c[1]).sort(), [11, 12]);
|
|
342
|
+
assert.ok(r.cmds.includes(`rm -f ${keyPath()} ${keyPath()}.pub`), 'the private key does not outlive the teardown');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await t('teardown with NO token still removes the local write key and names it for the owner to remove', async () => {
|
|
346
|
+
const r = teardownRig({ token: null });
|
|
347
|
+
await P.teardownInstance(inst(), r.deps);
|
|
348
|
+
assert.ok(r.cmds.includes(`rm -f ${keyPath()} ${keyPath()}.pub`));
|
|
349
|
+
assert.match(r.logs.join('\n'), /cloudbongos-pin-acme-site/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await t('DISCONNECT runs the teardown first, so the write key goes with it', async () => {
|
|
353
|
+
const D = require('../scripts/gds/provision-disconnect.js');
|
|
354
|
+
const r = teardownRig();
|
|
355
|
+
r.deps.catalog = { forget: async () => ({ ok: true }) };
|
|
356
|
+
await D.disconnectInstance(inst({ status: 'torn_down' }), r.deps).catch(() => {});
|
|
357
|
+
assert.ok(r.api.some((c) => c[0] === 'del' && c[1] === 12), 'the writable key was revoked');
|
|
358
|
+
assert.ok(r.cmds.includes(`rm -f ${keyPath()} ${keyPath()}.pub`));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await t('provisioning a project mints the key at standup, after the scaffold and the pull-key wiring', async () => {
|
|
362
|
+
const src = readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
|
|
363
|
+
const i = src.indexOf('await ensurePrivateRepoAccess(inst, deps);');
|
|
364
|
+
const j = src.indexOf('ensurePinWriteKey(inst, deps)');
|
|
365
|
+
assert.ok(i > 0 && j > i && j - i < 400, 'ensurePinWriteKey follows ensurePrivateRepoAccess in the standalone branch');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
console.log('\nthe /deploy copy describes a LASTING fix:');
|
|
369
|
+
|
|
370
|
+
await t('the deploy page says reconnecting sets up lasting access — not an hour', () => {
|
|
371
|
+
const js = readFileSync(new URL('../modules/hall-ui/public/deploy.js', import.meta.url), 'utf8');
|
|
372
|
+
const reconnect = js.slice(js.indexOf('function reconnectHtml'), js.indexOf('function stepOneHtml'));
|
|
373
|
+
assert.match(reconnect, /Reconnecting sets up <strong>lasting<\/strong> access/);
|
|
374
|
+
assert.doesNotMatch(reconnect, /about an hour|within the hour/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await t('the no_access note the page shows says the same', () => {
|
|
378
|
+
assert.match(R.REPO_NOTES.no_access, /sets up lasting access/);
|
|
379
|
+
assert.match(R.REPO_NOTES.needs_account, /needs its own account/);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
console.log(`\npin_write_key: ${passed} passed, ${failed} failed`);
|
|
383
|
+
if (failed) process.exit(1);
|
|
@@ -134,5 +134,32 @@ test('the operator is told to dry-run the SELECT before the UPDATE', () => {
|
|
|
134
134
|
assert.ok(step.indexOf('SELECT id, title') < step.indexOf('UPDATE tasks'), 'the SELECT comes before the UPDATE');
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
// ---- task 1002865: a login that expires mid-session costs a re-auth, not the work ----
|
|
138
|
+
// The retro: the token was valid at the rank gate (200) and dead by Phase 5
|
|
139
|
+
// (401), and the whole interview was lost at seed time. An early probe ALONE
|
|
140
|
+
// would not have caught it, so the skill must have both halves plus a spool.
|
|
141
|
+
|
|
142
|
+
test('the gate probe names the expired-session case and routes to /builder-reauth before anything else', () => {
|
|
143
|
+
const gate = SKILL.slice(SKILL.indexOf('**Enforce the gate at the very top'), SKILL.indexOf('## The session — five phases'));
|
|
144
|
+
assert.match(gate, /401/, 'the gate says what an expired session looks like');
|
|
145
|
+
assert.match(gate, /no longer valid/, 'including the CLI\'s own words for it');
|
|
146
|
+
assert.match(gate, /\/builder-reauth/, 'and routes to the fast re-issue');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('Phase 4 spools every answer to the spec file as it lands, so an expiry cannot lose the interview', () => {
|
|
150
|
+
const p4 = SKILL.slice(SKILL.indexOf('### Phase 4'), SKILL.indexOf('### Phase 5'));
|
|
151
|
+
assert.match(p4, /spec file/i);
|
|
152
|
+
assert.match(p4, /as (each|it) (answer )?lands|after every round|before the next round/i, 'written immediately, not at the end');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('Phase 5 re-probes the session before the first write, and on a 401 re-auths and RESUMES rather than abandoning', () => {
|
|
156
|
+
const p5 = SKILL.slice(SKILL.indexOf('### Phase 5'));
|
|
157
|
+
assert.match(p5, /GET \/api\/bongos\/me/, 'a fresh probe right before the writes');
|
|
158
|
+
assert.match(p5, /401/);
|
|
159
|
+
assert.match(p5, /\/builder-reauth/);
|
|
160
|
+
assert.match(p5, /resume|re-run/i, 'the writes are resumed, not restarted from the interview');
|
|
161
|
+
assert.match(p5, /idempotent/i, 'and says why a re-run is safe');
|
|
162
|
+
});
|
|
163
|
+
|
|
137
164
|
console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
|
|
138
165
|
if (failed > 0) process.exit(1);
|
package/tests/platform_boot.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { spawn } from 'node:child_process';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import platformMod from '../src/platform-server.js';
|
|
18
18
|
import loader from '../src/module-loader/loader.js';
|
|
19
|
+
import seams from '../src/module-seams.js';
|
|
20
|
+
import bongosLogger from '../src/bongos/logger.js';
|
|
19
21
|
|
|
20
22
|
const { buildPlatformApp, startBackgroundServices } = platformMod;
|
|
21
23
|
|
|
@@ -336,3 +338,72 @@ test('task 1003820: platform-server refuses to boot when an enabled module consu
|
|
|
336
338
|
child.kill('SIGKILL');
|
|
337
339
|
}
|
|
338
340
|
});
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------------------
|
|
343
|
+
// task 1004300 (ADR 0349): the optional request.divert seam. A module may take a request
|
|
344
|
+
// before any surface answers; with no provider the kernel must not notice it exists.
|
|
345
|
+
// These two cases stay LAST in this file: the second registers a provider, and the seam
|
|
346
|
+
// registry has no unregister, so it clears the registry when it is done.
|
|
347
|
+
// ---------------------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
function requestWithCookie(pathname, cookie) {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
const req = http.request(
|
|
352
|
+
{ host: '127.0.0.1', port, path: pathname, method: 'GET', headers: { Host: '127.0.0.1', Cookie: cookie } },
|
|
353
|
+
(res) => {
|
|
354
|
+
let body = '';
|
|
355
|
+
res.on('data', (c) => (body += c));
|
|
356
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
req.on('error', reject);
|
|
360
|
+
req.end();
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
test('task 1004300: with no request.divert provider a request falls straight through, cookie or not', async () => {
|
|
365
|
+
assert.equal(seams.hasProvider('request.divert'), false, 'nothing provides it in a vanilla boot');
|
|
366
|
+
// Fall-through must be the guard, not the error handler catching a call on nothing: a
|
|
367
|
+
// request that only survives because a TypeError was logged and swallowed has not fallen through.
|
|
368
|
+
const errors = [];
|
|
369
|
+
const realError = bongosLogger.logger.error;
|
|
370
|
+
bongosLogger.logger.error = (...a) => { errors.push(a); };
|
|
371
|
+
let plain;
|
|
372
|
+
let steered;
|
|
373
|
+
try {
|
|
374
|
+
plain = await request('/api/gds/healthz');
|
|
375
|
+
steered = await requestWithCookie('/api/gds/healthz', 'bongos_preview=1.19.1000');
|
|
376
|
+
} finally { bongosLogger.logger.error = realError; }
|
|
377
|
+
assert.deepEqual(errors, [], 'no provider is not an error');
|
|
378
|
+
assert.equal(plain.status, 200);
|
|
379
|
+
assert.equal(steered.status, 200);
|
|
380
|
+
assert.equal(steered.body, plain.body, 'byte-identical: the cookie means nothing without a provider');
|
|
381
|
+
assert.equal(steered.headers['set-cookie'], undefined);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('task 1004300: a registered request.divert provider takes the request, is looked up per request, and can pass it on', async () => {
|
|
385
|
+
const seen = [];
|
|
386
|
+
seams.registerProvider('request.divert', (req, res, next) => {
|
|
387
|
+
seen.push(req.url);
|
|
388
|
+
if (/bongos_preview/.test(req.headers.cookie || '')) {
|
|
389
|
+
res.statusCode = 299;
|
|
390
|
+
res.end('diverted');
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return next();
|
|
394
|
+
});
|
|
395
|
+
const taken = await requestWithCookie('/api/gds/healthz', 'bongos_preview=1.19.1000');
|
|
396
|
+
assert.equal(taken.status, 299);
|
|
397
|
+
assert.equal(taken.body, 'diverted');
|
|
398
|
+
const passed = await request('/api/gds/healthz');
|
|
399
|
+
assert.equal(passed.status, 200, 'a request the provider passes on is served as usual');
|
|
400
|
+
assert.deepEqual(seen, ['/api/gds/healthz', '/api/gds/healthz']);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('task 1004300: a request.divert provider that throws is treated as absent for that request', async () => {
|
|
404
|
+
seams._reset();
|
|
405
|
+
seams.registerProvider('request.divert', () => { throw new Error('a broken preview'); });
|
|
406
|
+
const res = await request('/api/gds/healthz');
|
|
407
|
+
assert.equal(res.status, 200, 'the live hall is not taken down by a broken provider');
|
|
408
|
+
seams._reset();
|
|
409
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// tests/profile_nudge_links.mjs — the "Complete your profile" nudge sends each
|
|
2
|
+
// half to where it is actually edited (task 1003485, goal 1000110).
|
|
3
|
+
//
|
|
4
|
+
// The bug: the hall's nudge said "Add a short bio" and linked to /settings, which
|
|
5
|
+
// has no bio field — the bio is a platform-account field, edited on the hub's
|
|
6
|
+
// account page since task 1004259 (ADR 0337 D4.3). Now the bio half links to
|
|
7
|
+
// <hubOrigin>/account, the kinds-of-work half to this hall's /settings#disciplines,
|
|
8
|
+
// and a half with nowhere to go is not asked for at all.
|
|
9
|
+
//
|
|
10
|
+
// The page script is EXECUTED in a node:vm under a DOM stub (the vm-slice idiom),
|
|
11
|
+
// so these pin what the line renders, not how the source spells it.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --test tests/profile_nudge_links.mjs
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
24
|
+
const SRC = fs.readFileSync(path.join(HALL, 'profile-nudge.js'), 'utf8');
|
|
25
|
+
const INDEX = fs.readFileSync(path.join(HALL, 'index.html'), 'utf8');
|
|
26
|
+
|
|
27
|
+
// Boot the script with a hub origin (or none) and return its render() + the box.
|
|
28
|
+
function boot(hub) {
|
|
29
|
+
const box = { innerHTML: 'stale' };
|
|
30
|
+
const escapeHtml = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
31
|
+
const window = {
|
|
32
|
+
OTB: { escapeHtml, hubOrigin: () => hub },
|
|
33
|
+
BongosClient: { createClient: () => ({ request: async () => ({ ok: false, status: 404 }) }) },
|
|
34
|
+
OTBAttention: { refresh() {} },
|
|
35
|
+
};
|
|
36
|
+
const document = {
|
|
37
|
+
getElementById: (id) => (id === 'attention-profile' ? box : null),
|
|
38
|
+
addEventListener() {},
|
|
39
|
+
};
|
|
40
|
+
vm.runInNewContext(SRC, { window, document, console });
|
|
41
|
+
return { render: window.OTBProfileNudge.render, box };
|
|
42
|
+
}
|
|
43
|
+
const hrefs = (html) => [...html.matchAll(/<a class="ginbox__goal" href="([^"]+)">([^<]+)<\/a>/g)].map((m) => [m[1], m[2]]);
|
|
44
|
+
|
|
45
|
+
test('both halves missing: the bio goes to the hub account page, the kinds of work to Settings', () => {
|
|
46
|
+
const { render, box } = boot('https://cloudbongos.com');
|
|
47
|
+
render({ show: true, missing: ['disciplines', 'bio'] });
|
|
48
|
+
assert.deepEqual(hrefs(box.innerHTML), [
|
|
49
|
+
['https://cloudbongos.com/account', 'Add a short bio'],
|
|
50
|
+
['/settings#disciplines', 'the kinds of work you want'],
|
|
51
|
+
]);
|
|
52
|
+
assert.match(box.innerHTML, /<span><a [^>]+>Add a short bio<\/a> and <a [^>]+>the kinds of work you want<\/a><\/span>/,
|
|
53
|
+
'one flex item, so the line reads as one sentence');
|
|
54
|
+
assert.doesNotMatch(box.innerHTML, /href="\/settings"/, 'nothing is sent to a Settings page without the field');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('only the bio missing: one link, to the hub account page', () => {
|
|
58
|
+
const { render, box } = boot('https://cloudbongos.com');
|
|
59
|
+
render({ show: true, missing: ['bio'] });
|
|
60
|
+
assert.deepEqual(hrefs(box.innerHTML), [['https://cloudbongos.com/account', 'Add a short bio']]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('only the kinds of work missing: one link, to this hall’s Settings', () => {
|
|
64
|
+
const { render, box } = boot(null);
|
|
65
|
+
render({ show: true, missing: ['disciplines'] });
|
|
66
|
+
assert.deepEqual(hrefs(box.innerHTML), [['/settings#disciplines', 'Add the kinds of work you want']]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('no hub origin: the bio is not asked for — no line points at a page that cannot answer it', () => {
|
|
70
|
+
const { render, box } = boot(null);
|
|
71
|
+
render({ show: true, missing: ['bio'] });
|
|
72
|
+
assert.equal(box.innerHTML, '');
|
|
73
|
+
render({ show: true, missing: ['bio', 'disciplines'] });
|
|
74
|
+
assert.deepEqual(hrefs(box.innerHTML), [['/settings#disciplines', 'Add the kinds of work you want']]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('dismissed or complete: nothing renders', () => {
|
|
78
|
+
const { render, box } = boot('https://cloudbongos.com');
|
|
79
|
+
render({ show: false, missing: ['bio'] });
|
|
80
|
+
assert.equal(box.innerHTML, '');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the hall home page stamps the hub origin the nudge reads', () => {
|
|
84
|
+
assert.match(INDEX, /<meta name="hub-origin" content="\{\{hubOrigin\}\}">/);
|
|
85
|
+
assert.match(SRC, /window\.OTB\.hubOrigin\(\)/, 'through the one shared reader, not a private copy');
|
|
86
|
+
});
|
|
@@ -143,7 +143,7 @@ test('cross_project null, self view via ?id= → private line + the Settings lin
|
|
|
143
143
|
const { $ } = await boot({ search: '?id=7', me: meOk('7'), profile: profileOk(data) });
|
|
144
144
|
const html = $('#xp-body').innerHTML;
|
|
145
145
|
assert.match(html, /keeps their track record private/);
|
|
146
|
-
assert.ok(html.includes('/settings#cross-project'), 'the self hide-toggle links to Settings →
|
|
146
|
+
assert.ok(html.includes('/settings#cross-project'), 'the self hide-toggle links to Settings → Profile and privacy');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
test('self view with no ?id= (own-profile path) also gets the Settings link', async () => {
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -2165,8 +2165,8 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2165
2165
|
const at = STYLE.indexOf('the create wizard (task 1002763');
|
|
2166
2166
|
assert.ok(at > -1, 'the wizard block is still the page\'s own');
|
|
2167
2167
|
const block = STYLE.slice(at);
|
|
2168
|
-
assert.match(block, /#view-new input\[type=text\], #view-new textarea\{\s*width:100%;[^}]*border:0;border-bottom:1px solid var\(--rule\);border-radius:0;/, 'a field is one bottom hairline');
|
|
2169
|
-
assert.match(block, /#view-new input\[type=text\]:focus, #view-new textarea:focus\{border-bottom-color:var\(--ink\);box-shadow:none;\}/, 'focus takes ink, never a glow');
|
|
2168
|
+
assert.match(block, /#view-new input\[type=text\], #view-new input\[type=password\], #view-new textarea\{\s*width:100%;[^}]*border:0;border-bottom:1px solid var\(--rule\);border-radius:0;/, 'a field is one bottom hairline');
|
|
2169
|
+
assert.match(block, /#view-new input\[type=text\]:focus, #view-new input\[type=password\]:focus, #view-new textarea:focus\{border-bottom-color:var\(--ink\);box-shadow:none;\}/, 'focus takes ink, never a glow');
|
|
2170
2170
|
assert.match(block, /\.typeCards\{display:grid;[^}]*border-top:1px solid var\(--rule\);border-bottom:1px solid var\(--rule\);/, 'the type and team choices are cells of one band');
|
|
2171
2171
|
assert.match(block, /\.tpill\[aria-pressed=true\]::before\{background:var\(--accent\);/, 'the chosen cell carries the dot');
|
|
2172
2172
|
assert.match(block, /\.choices\{display:grid;grid-template-columns:1fr 1fr;gap:0;border-top:1px solid var\(--rule\);/, 'start fresh / adopt are two cells of one band');
|