@bongos/core 1.19.1033 → 1.19.1035
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 +204 -99
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +120 -4
- package/docs/api-reference.md +9 -2
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/autonomy/db.js +19 -0
- package/modules/autonomy/routes/autonomy.js +28 -0
- package/modules/government/catalog.js +2 -2
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/lifecycle/ship-assurance.js +17 -1
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +80 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/core-update.js +23 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/routine_observability_api.mjs +195 -0
- package/tests/ship_assurance.mjs +86 -3
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
|
@@ -31,7 +31,8 @@ import { createRequire } from 'node:module';
|
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import path from 'node:path';
|
|
33
33
|
// An ESM named import, not R.parseSchemaPending: knip credits a CJS export only when a test imports it this way.
|
|
34
|
-
import { parseSchemaPending } from '../scripts/gds/provision-core-upgrade.js';
|
|
34
|
+
import { parseSchemaPending, repoNote, REPO_NOTES } from '../scripts/gds/provision-core-upgrade.js';
|
|
35
|
+
import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
|
|
35
36
|
|
|
36
37
|
const require = createRequire(import.meta.url);
|
|
37
38
|
const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
@@ -196,9 +197,9 @@ await t('and an ordinary slug still runs', async () => {
|
|
|
196
197
|
});
|
|
197
198
|
|
|
198
199
|
await t('the guard error carries a status and a sentence, so a route CAN answer with it', async () => {
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
200
|
+
// Every route answers it through failFrom now (task 1004126): the owner reads this
|
|
201
|
+
// sentence as a 409, and the route still logs the stack. The wording is settled here,
|
|
202
|
+
// once; tests/provisioning_public_refusal.mjs pins that it arrives.
|
|
202
203
|
const selfRow = { query: async () => ({ rows: [{ hosting_shape: 'control-plane' }] }) };
|
|
203
204
|
await assert.rejects(() => G.assertIntentAllowed(selfRow, 1, 'teardown'), (err) => {
|
|
204
205
|
assert.equal(err.code, 'control_plane_protected');
|
|
@@ -1159,5 +1160,240 @@ await t('the dry run records the list beside the verdict, keyed to where the box
|
|
|
1159
1160
|
assert.equal(stored.changes.entries[0].text, 'a thing landed');
|
|
1160
1161
|
});
|
|
1161
1162
|
|
|
1163
|
+
console.log('\nthe pin reaches the project\'s GitHub repo — pushed from the HUB (task 1004065):');
|
|
1164
|
+
|
|
1165
|
+
// THE BUG. A hosted project's upgrade runs connected to the PROJECT's database, which
|
|
1166
|
+
// has no provisioning table (ADR 0281), so upgrade.js could never find the owner's
|
|
1167
|
+
// token, fell back to `git push origin` over a read-only deploy key (ADR 0176), was
|
|
1168
|
+
// refused, and the intent still resolved `done`. hermeslines-marketing's box ran
|
|
1169
|
+
// 1.19.994 while its GitHub repo still pinned 1.19.881. These cases drive the whole
|
|
1170
|
+
// move so the push is proven to happen, from where the token actually lives, AFTER a
|
|
1171
|
+
// confirmed upgrade, and to be reported when it cannot.
|
|
1172
|
+
|
|
1173
|
+
const OWNER_TOKEN = 'ghu_OWNER_TOKEN_never_in_a_command';
|
|
1174
|
+
|
|
1175
|
+
function pinDeps({ served = '1.19.762', token = OWNER_TOKEN, branch = 'main', pushResult = null, originResult = null, upgradeThrows = false } = {}) {
|
|
1176
|
+
const d = makeDeps({ served });
|
|
1177
|
+
const ran = [];
|
|
1178
|
+
const writes = [];
|
|
1179
|
+
const tokenAsks = [];
|
|
1180
|
+
d.ran = [];
|
|
1181
|
+
d.ranWith = ran;
|
|
1182
|
+
d.exec = (cmd, opts) => {
|
|
1183
|
+
ran.push({ cmd, cwd: opts && opts.cwd, env: opts && opts.env });
|
|
1184
|
+
d.ran.push(cmd);
|
|
1185
|
+
if (/upgrade\.js/.test(cmd) && upgradeThrows) throw new Error('health check failed after restart — rolled back');
|
|
1186
|
+
if (/\/version/.test(cmd)) return { ok: true, stdout: JSON.stringify({ coreVersion: served }) };
|
|
1187
|
+
if (/symbolic-ref/.test(cmd)) {
|
|
1188
|
+
return branch ? { ok: true, stdout: `${branch}\n` } : { ok: true, softFailed: true, stdout: '', stderr: 'fatal: ref HEAD is not a symbolic ref' };
|
|
1189
|
+
}
|
|
1190
|
+
if (/push https:\/\/x-access-token@github\.com\//.test(cmd)) return pushResult || { ok: true, stdout: '' };
|
|
1191
|
+
if (/ push origin /.test(cmd)) return originResult || { ok: true, stdout: '' };
|
|
1192
|
+
return { ok: true, stdout: '' };
|
|
1193
|
+
};
|
|
1194
|
+
d.controlWriteFile = (p, body, o) => { writes.push({ p, body, mode: o && o.mode }); return { ok: true }; };
|
|
1195
|
+
d.getGithubToken = async (builderId) => { tokenAsks.push(builderId); return token ? { token } : null; };
|
|
1196
|
+
d._writes = writes;
|
|
1197
|
+
d._tokenAsks = tokenAsks;
|
|
1198
|
+
return d;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const hosted = (over = {}) => inst({
|
|
1202
|
+
hosting_shape: 'cloud-host', slug: 'acme-site', port: 3011, db_name: 'acme-site',
|
|
1203
|
+
target_ref: 'acme/site', owner_builder_id: 5, ...over,
|
|
1204
|
+
});
|
|
1205
|
+
const tokenPush = (d) => d.ranWith.find((c) => /push https:\/\/x-access-token@github\.com\//.test(c.cmd));
|
|
1206
|
+
const originPush = (d) => d.ranWith.find((c) => / push origin /.test(c.cmd));
|
|
1207
|
+
const refusedBy = (stderr) => ({ ok: true, softFailed: true, stdout: '', stderr });
|
|
1208
|
+
|
|
1209
|
+
await t('a hosted project\'s upgrade COMMITS the pin on the box and leaves the push to the hub', async () => {
|
|
1210
|
+
const d = pinDeps();
|
|
1211
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1212
|
+
const cmd = upgradeCall(d);
|
|
1213
|
+
assert.ok(cmd.includes('--commit-pin'), 'the pin is still committed — an uncommitted pin is the silent-revert bug');
|
|
1214
|
+
assert.ok(cmd.includes('--pin-no-push'), 'and upgrade.js no longer attempts a push it has no credential for');
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
await t('the platform itself keeps pushing its own pin — its key CAN write its own repo', async () => {
|
|
1218
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
1219
|
+
await R.coreUpgradeInstance(inst(), d, intent());
|
|
1220
|
+
assert.doesNotMatch(upgradeCall(d), /--pin-no-push/, 'unchanged for the control plane');
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
await t('the preview carries no push flag at all — it never gets as far as a pin', () => {
|
|
1224
|
+
const peek = R.upgradeInvocation(hosted(), '1.19.762', { dryRun: true });
|
|
1225
|
+
assert.doesNotMatch(peek.cmd, /--pin-no-push|--commit-pin/);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
await t('THE HUB PUSHES the committed pin to the project\'s repo with the owner\'s token read from the HUB db', async () => {
|
|
1229
|
+
const d = pinDeps();
|
|
1230
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1231
|
+
assert.equal(r.ok, true);
|
|
1232
|
+
assert.deepEqual(d._tokenAsks, [5], 'the OWNER\'s token, looked up by the owner id on the provisioning row');
|
|
1233
|
+
const push = tokenPush(d);
|
|
1234
|
+
assert.ok(push, 'the hub pushed');
|
|
1235
|
+
assert.ok(push.cmd.includes('https://x-access-token@github.com/acme/site.git HEAD:main'), `to the row's target_ref, main — got ${push.cmd}`);
|
|
1236
|
+
assert.ok(push.cmd.includes(`-C ${RP.standaloneRoot(hosted())} `), 'from the project\'s own checkout');
|
|
1237
|
+
assert.equal(r.repo.pushed, true);
|
|
1238
|
+
assert.equal(r.repo.via, 'owner-token');
|
|
1239
|
+
assert.ok(!r.doneNote, 'a clean push leaves nothing to report');
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
await t('the push happens AFTER the confirmed upgrade, never before it', async () => {
|
|
1243
|
+
const d = pinDeps();
|
|
1244
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1245
|
+
const at = (re) => d.ranWith.findIndex((c) => re.test(c.cmd));
|
|
1246
|
+
assert.ok(at(/upgrade\.js/) >= 0 && at(/upgrade\.js/) < at(/x-access-token@github\.com/), 'the pin must exist before it can be pushed');
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
await t('the TOKEN never appears in a command line, and its credential file is removed', async () => {
|
|
1250
|
+
const d = pinDeps();
|
|
1251
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1252
|
+
for (const c of d.ranWith) assert.ok(!c.cmd.includes(OWNER_TOKEN), `argv reaches ps and the journal — leaked in: ${c.cmd}`);
|
|
1253
|
+
assert.equal(d._writes.length, 1, 'one credential file');
|
|
1254
|
+
const cred = d._writes[0];
|
|
1255
|
+
assert.ok(cred.body.includes(OWNER_TOKEN), 'the token rides a file');
|
|
1256
|
+
assert.equal(cred.mode, 0o600, 'readable by the runner alone');
|
|
1257
|
+
assert.ok(tokenPush(d).cmd.includes(`store --file=${cred.p}`), 'git reads it through the store helper');
|
|
1258
|
+
assert.ok(d.ranWith.some((c) => c.cmd === `rm -f ${cred.p}`), 'and it is deleted afterwards');
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
await t('the push is HARDENED: helper list reset, no hooks, no prompt', async () => {
|
|
1262
|
+
// Helpers are cumulative — a global one would answer first with some other account.
|
|
1263
|
+
// A hook in the project's checkout would otherwise run as the RUNNER on this push.
|
|
1264
|
+
const d = pinDeps();
|
|
1265
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1266
|
+
const push = tokenPush(d);
|
|
1267
|
+
assert.match(push.cmd, /-c credential\.helper= -c credential\.helper='store --file=/, 'the empty entry resets the list first');
|
|
1268
|
+
assert.match(push.cmd, /-c core\.hooksPath=\/dev\/null /, 'no hook runs');
|
|
1269
|
+
assert.equal(push.env && push.env.GIT_TERMINAL_PROMPT, '0', 'a refused credential fails instead of waiting at a prompt');
|
|
1270
|
+
assert.doesNotMatch(push.cmd, /--force|\+HEAD/, 'never forced');
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
await t('the credential file is removed even when the push FAILS', async () => {
|
|
1274
|
+
const d = pinDeps({ pushResult: refusedBy('remote: Write access to repository not granted.\nfatal: unable to access: The requested URL returned error: 403') });
|
|
1275
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1276
|
+
assert.ok(d.ranWith.some((c) => c.cmd === `rm -f ${d._writes[0].p}`));
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
await t('NO TOKEN STORED: the box\'s own remote is tried, and a refusal resolves done WITH a note', async () => {
|
|
1280
|
+
const d = pinDeps({ token: null, originResult: refusedBy('ERROR: The key you are authenticating with has been marked as read only.\nfatal: Could not read from remote repository.') });
|
|
1281
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1282
|
+
assert.equal(r.ok, true, 'the upgrade DID land and the site runs it — that is not a failure');
|
|
1283
|
+
assert.ok(originPush(d), 'the remote was tried, in case it can write');
|
|
1284
|
+
assert.equal(d._writes.length, 0, 'no credential file without a credential');
|
|
1285
|
+
assert.equal(r.repo.pushed, false);
|
|
1286
|
+
assert.equal(r.repo.code, 'no_access');
|
|
1287
|
+
assert.match(r.doneNote, /^repo_not_updated:no_access: /, 'the note the web tier reads');
|
|
1288
|
+
assert.match(r.doneNote, /an hour/, 'and it says WHY there was no access, in words');
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
await t('NO TOKEN, but the box\'s remote CAN write: pushed that way, nothing to report', async () => {
|
|
1292
|
+
const d = pinDeps({ token: null });
|
|
1293
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1294
|
+
assert.equal(r.repo.pushed, true);
|
|
1295
|
+
assert.equal(r.repo.via, 'origin');
|
|
1296
|
+
assert.ok(!r.doneNote);
|
|
1297
|
+
assert.match(originPush(d).cmd, /-c core\.hooksPath=\/dev\/null /, 'hardened the same way');
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
await t('GitHub REFUSES the owner\'s token: named as a refusal, not as "no token stored"', async () => {
|
|
1301
|
+
const d = pinDeps({ pushResult: refusedBy('remote: Write access to repository not granted.\nfatal: unable to access: The requested URL returned error: 403') });
|
|
1302
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1303
|
+
assert.equal(r.repo.code, 'refused');
|
|
1304
|
+
assert.match(r.doneNote, /^repo_not_updated:refused: /);
|
|
1305
|
+
assert.match(r.doneNote, /Write access to repository not granted/, 'GitHub\'s own words ride along');
|
|
1306
|
+
assert.equal(originPush(d), undefined, 'a read-only remote cannot succeed where the token was refused');
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
await t('GitHub HAS COMMITS THE BOX DOES NOT: reported, and nothing is forced over them', async () => {
|
|
1310
|
+
const d = pinDeps({ pushResult: refusedBy(' ! [rejected] HEAD -> main (fetch first)\nerror: failed to push some refs to \'https://github.com/acme/site.git\'') });
|
|
1311
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1312
|
+
assert.equal(r.repo.code, 'behind');
|
|
1313
|
+
assert.match(r.doneNote, /^repo_not_updated:behind: /);
|
|
1314
|
+
assert.ok(!d.ranWith.some((c) => /--force|\+HEAD|reset --hard|rebase/.test(c.cmd)), 'the box never rewrites anyone\'s history');
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
await t('a push that fails some OTHER way is reported with git\'s line, not guessed at', async () => {
|
|
1318
|
+
const d = pinDeps({ pushResult: refusedBy('fatal: unable to access \'https://github.com/acme/site.git/\': Could not resolve host: github.com') });
|
|
1319
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1320
|
+
assert.equal(r.repo.code, 'failed');
|
|
1321
|
+
assert.match(r.doneNote, /Could not resolve host/);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
await t('NO REPOSITORY RECORDED: reported, and no git is run after the upgrade', async () => {
|
|
1325
|
+
const d = pinDeps();
|
|
1326
|
+
const r = await R.coreUpgradeInstance(hosted({ target_ref: null }), d, intent());
|
|
1327
|
+
assert.equal(r.repo.code, 'no_repo');
|
|
1328
|
+
assert.match(r.doneNote, /^repo_not_updated:no_repo: /);
|
|
1329
|
+
assert.equal(d._tokenAsks.length, 0, 'no token is read for a push that cannot happen');
|
|
1330
|
+
assert.ok(!d.ranWith.some((c) => /\bgit\b/.test(c.cmd)), 'nothing touched the checkout');
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
await t('a checkout not on a branch is pushed NOWHERE — HEAD:main from a detached head would be a guess', async () => {
|
|
1334
|
+
const d = pinDeps({ branch: null });
|
|
1335
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1336
|
+
assert.equal(r.repo.code, 'no_branch');
|
|
1337
|
+
assert.equal(tokenPush(d), undefined);
|
|
1338
|
+
assert.equal(d._tokenAsks.length, 0, 'and no token is read for it');
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
await t('a branch name that is not a plain ref never reaches the shell', async () => {
|
|
1342
|
+
const d = pinDeps({ branch: 'main;touch /tmp/pwn' });
|
|
1343
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1344
|
+
assert.equal(r.repo.code, 'no_branch');
|
|
1345
|
+
assert.ok(!d.ranWith.some((c) => /pwn/.test(c.cmd)), 'the name was refused, not interpolated');
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
await t('a FAILED upgrade pushes nothing — only a confirmed pin is worth saving', async () => {
|
|
1349
|
+
const d = pinDeps({ upgradeThrows: true });
|
|
1350
|
+
const r = await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1351
|
+
assert.equal(r.ok, false);
|
|
1352
|
+
assert.equal(tokenPush(d), undefined);
|
|
1353
|
+
assert.equal(d._tokenAsks.length, 0);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
await t('the event log says GitHub was not updated, so the history is not a clean success either', async () => {
|
|
1357
|
+
const events = [];
|
|
1358
|
+
const d = pinDeps({ token: null, originResult: refusedBy('ERROR: The key you are authenticating with has been marked as read only.') });
|
|
1359
|
+
d.provisioning = { ...d.provisioning, recordEvent: async (_db, e) => { events.push(e); } };
|
|
1360
|
+
await R.coreUpgradeInstance(hosted(), d, intent());
|
|
1361
|
+
assert.equal(events.length, 1);
|
|
1362
|
+
assert.match(events[0].detail, /GitHub was not updated/);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
await t('every note the runner writes is one the web tier reads back, code and sentence intact', () => {
|
|
1366
|
+
for (const code of Object.keys(REPO_NOTES)) {
|
|
1367
|
+
const note = repoNote(code, '(git: something)');
|
|
1368
|
+
const w = parseRepoNote(note);
|
|
1369
|
+
assert.ok(w, `${code} parses`);
|
|
1370
|
+
assert.equal(w.code, code);
|
|
1371
|
+
assert.ok(w.text.startsWith(REPO_NOTES[code]), `${code}: the sentence survives`);
|
|
1372
|
+
assert.ok(note.length <= 600, `${code}: fits the page's bound`);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
await t('the DRAIN LOOP resolves a done-with-a-note move WITH its note, still as done', async () => {
|
|
1377
|
+
// Driven through the real dispatch: the note is only worth anything if it reaches the
|
|
1378
|
+
// intent row the deploy page reads.
|
|
1379
|
+
const P2 = require('../scripts/gds/provision.js');
|
|
1380
|
+
let claims = 0; const resolved = []; const statusWrites = [];
|
|
1381
|
+
const d = pinDeps({ token: null, originResult: refusedBy('ERROR: The key you are authenticating with has been marked as read only.') });
|
|
1382
|
+
d.provisioning = {
|
|
1383
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 12, instance_id: 7, action: 'core-upgrade', attempts: 1, target_version: '1.19.762', requested_by: 'api:archon:42' } : null),
|
|
1384
|
+
getInstanceById: async () => hosted(),
|
|
1385
|
+
resolveIntent: async (_db, id, state, note) => { resolved.push({ id, state, note }); },
|
|
1386
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
1387
|
+
recordEvent: async () => {}, clearErrorNote: async () => {},
|
|
1388
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1389
|
+
};
|
|
1390
|
+
const r = await P2.cmdRunIntents(d);
|
|
1391
|
+
assert.equal(r.errors, 0, JSON.stringify(r));
|
|
1392
|
+
assert.equal(resolved.length, 1);
|
|
1393
|
+
assert.equal(resolved[0].state, 'done', 'the upgrade landed — never an error');
|
|
1394
|
+
assert.match(String(resolved[0].note), /^repo_not_updated:no_access: /, 'and the note travels to the row');
|
|
1395
|
+
assert.equal(statusWrites.length, 0, 'a healthy instance is not marked anything');
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1162
1398
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
1163
1399
|
if (failed) process.exit(1);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/credential_presence.mjs — the host credential sweep reports names and presence,
|
|
2
|
+
// never a value, and the inventory it sweeps is the one docs/credentials.md documents
|
|
3
|
+
// (task 1002880).
|
|
4
|
+
//
|
|
5
|
+
// Every value below is invented for the test and is chosen to be easy to spot: the
|
|
6
|
+
// "never a value" cases scan the whole report for every value AND every 4-character
|
|
7
|
+
// fragment of one, because a leak of a prefix ("ghp_…") is still a leak.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/credential_presence.mjs
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'credential-presence.js');
|
|
23
|
+
const { CREDENTIALS, parseEnvFile, sweep, format, readFiles } = require('../scripts/gds/credential-presence.js');
|
|
24
|
+
const { envPrefix } = require('../src/instance-config.js');
|
|
25
|
+
const P = envPrefix();
|
|
26
|
+
|
|
27
|
+
// Random-looking on purpose: a value that shares four characters with a variable name
|
|
28
|
+
// ("CLIE" in CLIENT_ID) would make the fragment scan below flag the name, not a leak.
|
|
29
|
+
const SECRETS = {
|
|
30
|
+
client: 'q7Zx9vLp2Rk8', pat: 'w3Ty6uHb1Nm4', box: 'e5Rc8oJd0Qs2', key: 'f9Gh2kAa7Vb3',
|
|
31
|
+
};
|
|
32
|
+
const WEB_ENV = [
|
|
33
|
+
'# /etc/example/web.env — test fixture',
|
|
34
|
+
`${P}_GITHUB_CLIENT_ID=Iv1clientIDqq9`,
|
|
35
|
+
`${P}_GITHUB_CLIENT_SECRET="${SECRETS.client}"`,
|
|
36
|
+
`GITHUB_PUSH_TOKEN=${SECRETS.pat}`,
|
|
37
|
+
`${P}_GITHUB_OWNER=example-owner`,
|
|
38
|
+
`${P}_GITHUB_REPO=example-repo`,
|
|
39
|
+
`BUILDER_SECRET_KEY=${SECRETS.key}`,
|
|
40
|
+
'DISCORD_BOT_TOKEN=',
|
|
41
|
+
'PORT=4100',
|
|
42
|
+
].join('\n');
|
|
43
|
+
|
|
44
|
+
function fragmentsOf(value) {
|
|
45
|
+
const out = new Set([value]);
|
|
46
|
+
for (let i = 0; i + 4 <= value.length; i++) out.add(value.slice(i, i + 4));
|
|
47
|
+
return [...out];
|
|
48
|
+
}
|
|
49
|
+
function assertNoValueIn(text) {
|
|
50
|
+
for (const v of Object.values(SECRETS)) {
|
|
51
|
+
for (const frag of fragmentsOf(v)) assert.ok(!text.includes(frag), `the report leaked a fragment of a value: "${frag}"`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('parseEnvFile reads a systemd EnvironmentFile: comments, blanks, quotes, empties', () => {
|
|
56
|
+
const env = parseEnvFile('# comment\n\nA=1\nB="two words"\nC=\'three\'\nD=\n E = spaced \nnot a line\n');
|
|
57
|
+
assert.deepEqual(env, { A: '1', B: 'two words', C: 'three', D: '', E: 'spaced' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a configured host reads as present, row by row, with the NAME that satisfied each', () => {
|
|
61
|
+
const r = sweep({ files: { 'web.env': { env: parseEnvFile(WEB_ENV) }, 'box.env': { env: { DO_API_TOKEN: SECRETS.box } }, 'spaces.env': { env: {} } } });
|
|
62
|
+
const row = (id) => r.rows.find((x) => x.id === id);
|
|
63
|
+
assert.equal(row('github-sign-in').state, 'set');
|
|
64
|
+
assert.deepEqual(row('github-sign-in').satisfiedBy, [`${P}_GITHUB_CLIENT_ID`, `${P}_GITHUB_CLIENT_SECRET`]);
|
|
65
|
+
assert.equal(row('publish-pat').state, 'set');
|
|
66
|
+
assert.equal(row('publish-app').state, 'missing', 'the App is absent...');
|
|
67
|
+
assert.equal(r.groups.publish.satisfied, true, '...but the publish group is satisfied by the PAT');
|
|
68
|
+
assert.equal(row('discord-bot').state, 'empty', 'a name present with no value is called out, not treated as set');
|
|
69
|
+
assert.equal(row('digitalocean').state, 'set');
|
|
70
|
+
assert.equal(row('cloudflare').state, 'missing');
|
|
71
|
+
assert.deepEqual(r.requiredMissing, [], 'every required row, or its alternative, is present');
|
|
72
|
+
assert.equal(r.ok, true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('THE ONE RULE: no value, and no fragment of one, ever reaches the report or --json', () => {
|
|
76
|
+
const r = sweep({ files: { 'web.env': { env: parseEnvFile(WEB_ENV) }, 'box.env': { env: { DO_API_TOKEN: SECRETS.box } }, 'spaces.env': { env: {} } } });
|
|
77
|
+
assertNoValueIn(format(r, { etc: '/etc/example' }));
|
|
78
|
+
assertNoValueIn(JSON.stringify(r));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a missing required credential fails the sweep and is named', () => {
|
|
82
|
+
const env = parseEnvFile(WEB_ENV);
|
|
83
|
+
delete env.GITHUB_PUSH_TOKEN;
|
|
84
|
+
const r = sweep({ files: { 'web.env': { env }, 'box.env': { env: {} }, 'spaces.env': { env: {} } } });
|
|
85
|
+
assert.equal(r.groups.publish.satisfied, false, 'neither the App nor the PAT is set');
|
|
86
|
+
assert.ok(r.requiredMissing.includes('publish-app') && r.requiredMissing.includes('publish-pat'));
|
|
87
|
+
assert.equal(r.ok, false);
|
|
88
|
+
assert.match(format(r), /REQUIRED and missing: .*publish-app/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the App needs BOTH halves: an id without a key is not a publish credential', () => {
|
|
92
|
+
const r = sweep({ files: { 'web.env': { env: { GITHUB_APP_ID: '123' } }, 'box.env': { env: {} }, 'spaces.env': { env: {} } } });
|
|
93
|
+
const app = r.rows.find((x) => x.id === 'publish-app');
|
|
94
|
+
assert.equal(app.state, 'missing');
|
|
95
|
+
assert.deepEqual(app.missing, ['GITHUB_APP_PRIVATE_KEY']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a prefixed name also answers to its legacy spelling, the way the server reads it', () => {
|
|
99
|
+
const env = { CLOUDBONGOS_GITHUB_CLIENT_ID: 'x-id', CLOUDBONGOS_GITHUB_CLIENT_SECRET: 'x-secret' };
|
|
100
|
+
const r = sweep({ files: { 'web.env': { env }, 'box.env': { env: {} }, 'spaces.env': { env: {} } } });
|
|
101
|
+
const row = r.rows.find((x) => x.id === 'github-sign-in');
|
|
102
|
+
if (P === 'CLOUDBONGOS') return; // the legacy spelling IS canonical on such an instance
|
|
103
|
+
assert.equal(row.state, 'set');
|
|
104
|
+
assert.deepEqual(row.satisfiedBy, ['CLOUDBONGOS_GITHUB_CLIENT_ID', 'CLOUDBONGOS_GITHUB_CLIENT_SECRET']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the secret-box key counts when it lives in the key file instead of the env', () => {
|
|
108
|
+
const r = sweep({ files: { 'web.env': { env: {} }, 'box.env': { env: {} }, 'spaces.env': { env: {} } }, keyFileExists: true });
|
|
109
|
+
assert.equal(r.rows.find((x) => x.id === 'secret-box-key').state, 'set');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an unreadable web.env makes the answer UNKNOWN (exit 2), never a clean pass', () => {
|
|
113
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cred-etc-'));
|
|
114
|
+
try {
|
|
115
|
+
const files = readFiles(dir);
|
|
116
|
+
assert.ok(files['web.env'].error, 'no web.env here');
|
|
117
|
+
assert.deepEqual(files['spaces.env'], { env: {} }, 'an absent spaces.env only means offsite backup was never set up');
|
|
118
|
+
const r = spawnSync(process.execPath, [SCRIPT, '--etc', dir], { cwd: ROOT, encoding: 'utf8' });
|
|
119
|
+
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
120
|
+
assert.match(r.stdout, /could not read/);
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the CLI, run against a real env dir, reports presence and prints no value', () => {
|
|
127
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cred-etc-'));
|
|
128
|
+
try {
|
|
129
|
+
fs.writeFileSync(path.join(dir, 'web.env'), WEB_ENV);
|
|
130
|
+
fs.writeFileSync(path.join(dir, 'box.env'), `DO_API_TOKEN=${SECRETS.box}\nCLOUDFLARE_API_TOKEN=\n`);
|
|
131
|
+
const r = spawnSync(process.execPath, [SCRIPT, '--etc', dir], { cwd: ROOT, encoding: 'utf8' });
|
|
132
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
133
|
+
assert.match(r.stdout, /✓ github-sign-in/);
|
|
134
|
+
assert.match(r.stdout, /! cloudflare .*set but EMPTY: CLOUDFLARE_API_TOKEN/);
|
|
135
|
+
assertNoValueIn(r.stdout + r.stderr);
|
|
136
|
+
const j = spawnSync(process.execPath, [SCRIPT, '--etc', dir, '--json'], { cwd: ROOT, encoding: 'utf8' });
|
|
137
|
+
assert.equal(JSON.parse(j.stdout).ok, true);
|
|
138
|
+
assertNoValueIn(j.stdout + j.stderr);
|
|
139
|
+
} finally {
|
|
140
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('docs/credentials.md documents every variable the sweep checks', () => {
|
|
145
|
+
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'credentials.md'), 'utf8');
|
|
146
|
+
for (const row of CREDENTIALS) {
|
|
147
|
+
for (const name of row.names) {
|
|
148
|
+
const shown = row.prefixed ? `<PREFIX>_${name}` : name;
|
|
149
|
+
assert.ok(doc.includes('`' + shown + '`'), `docs/credentials.md must name \`${shown}\` (row ${row.id})`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
assert.match(doc, /never a value|No value of any credential/i, 'the page states the one rule');
|
|
153
|
+
});
|
|
@@ -89,13 +89,16 @@ test('the range handed to the auditor is the exact deploy delta, not a window',
|
|
|
89
89
|
try {
|
|
90
90
|
const gdsDir = path.join(repo.dir, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
|
|
91
91
|
fs.mkdirSync(gdsDir, { recursive: true });
|
|
92
|
+
// The range rides --range, not an env var (task 1001441): a shell cannot compute
|
|
93
|
+
// the instance's branded env prefix, and argv needs none.
|
|
92
94
|
fs.writeFileSync(path.join(gdsDir, 'main-audit.js'),
|
|
93
|
-
'
|
|
95
|
+
'const a = process.argv; const i = a.indexOf("--range");\n'
|
|
96
|
+
+ 'require("fs").writeFileSync("range.txt", i >= 0 ? a[i + 1] : "UNSET");\n');
|
|
94
97
|
const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
|
|
95
98
|
assert.equal(r.code, 0, r.out);
|
|
96
99
|
assert.equal(
|
|
97
100
|
fs.readFileSync(path.join(repo.dir, 'range.txt'), 'utf8'), `${repo.prev}..HEAD`,
|
|
98
|
-
'the auditor must receive <previously-deployed>..HEAD',
|
|
101
|
+
'the auditor must receive --range <previously-deployed>..HEAD',
|
|
99
102
|
);
|
|
100
103
|
} finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
|
|
101
104
|
});
|
|
@@ -37,7 +37,7 @@ const FLEET = [
|
|
|
37
37
|
{ id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
|
|
38
38
|
];
|
|
39
39
|
|
|
40
|
-
function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null } = {}) {
|
|
40
|
+
function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null, href = null } = {}) {
|
|
41
41
|
const els = new Map();
|
|
42
42
|
const posts = [];
|
|
43
43
|
const gets = [];
|
|
@@ -94,7 +94,7 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
|
|
|
94
94
|
OTB: { escapeHtml: (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])), toast(m) { toasts.push(String(m)); } },
|
|
95
95
|
// `search` is what decides which project the page is about. A stub without it is the
|
|
96
96
|
// shape the page has to survive, and the default here keeps that path exercised.
|
|
97
|
-
location: { assign() {}, search: scope ? `?project=${encodeURIComponent(scope)}` : '' },
|
|
97
|
+
location: { assign() {}, search: scope ? `?project=${encodeURIComponent(scope)}` : '', ...(href ? { href } : {}) },
|
|
98
98
|
};
|
|
99
99
|
// The page's poll interval, driven rather than slept through. Injecting the REAL
|
|
100
100
|
// setTimeout made every case whose preview never converges sit out the page's full
|
|
@@ -583,3 +583,64 @@ test('a queue that is REFUSED leaves no in-flight state to wait on', async () =>
|
|
|
583
583
|
assert.ok(b.toasts.some((t) => /currently running/i.test(t)), 'and the refusal is reported');
|
|
584
584
|
assert.match(b.panel(), /id="dp-confirm-32"/, 'the button comes back so it can be retried');
|
|
585
585
|
});
|
|
586
|
+
|
|
587
|
+
// ---- landed, but GitHub was not updated (task 1004065) ----------------------------
|
|
588
|
+
//
|
|
589
|
+
// The hub pushes a hosted project's pin to its GitHub repo after the upgrade. When it
|
|
590
|
+
// cannot, the move is still DONE (the site runs the new core) but the page must not
|
|
591
|
+
// read "and the box confirmed it" as though everything was saved — the box and the
|
|
592
|
+
// repo have split, and changes pushed to GitHub can stop reaching the live site.
|
|
593
|
+
|
|
594
|
+
const landedWith = (warning) => () => ({
|
|
595
|
+
served_version: '1.19.871', recommended_version: '1.19.871', behind: false, open_intent: null,
|
|
596
|
+
last_move: { target_version: '1.19.871', state: 'done', at: new Date(Date.now() - 60 * 1000).toISOString(), error: null, warning },
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('a move that landed but did NOT reach GitHub says so, instead of a clean confirmation', async () => {
|
|
600
|
+
const b = boot({ scope: 'mercury', instanceOver: landedWith({ code: 'no_access', text: 'The hub had no permission to write to the project’s GitHub repository.' }) });
|
|
601
|
+
await b.settle();
|
|
602
|
+
const html = b.panel();
|
|
603
|
+
assert.match(html, /Now running core 1\.19\.871/, 'the upgrade itself is still stated — it worked');
|
|
604
|
+
assert.match(html, /GitHub was not updated/i, 'and the half that did not happen is the headline beside it');
|
|
605
|
+
assert.match(html, /no permission to write/, 'with the runner’s reason in plain words');
|
|
606
|
+
assert.match(html, /dp-settled--warn/, 'a warning mark — not the success mark, not the failure one');
|
|
607
|
+
assert.doesNotMatch(html, /dp-settled--ok/);
|
|
608
|
+
assert.doesNotMatch(html, /dp-settled--failed/);
|
|
609
|
+
assert.doesNotMatch(html, /did not land/i, 'it DID land');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('when the fix is reconnecting GitHub, the page links straight to it and comes back here', async () => {
|
|
613
|
+
for (const code of ['no_access', 'refused']) {
|
|
614
|
+
const b = boot({ scope: 'mercury', instanceOver: landedWith({ code, text: 'x' }) });
|
|
615
|
+
await b.settle();
|
|
616
|
+
const html = b.panel();
|
|
617
|
+
assert.match(html, /href="\/api\/bongos\/auth\/web\/start\?repos=1&return=%2Fdeploy%3Fproject%3Dmercury"/,
|
|
618
|
+
`${code}: the repo-access sign-in, returning to this project's deploy page — got ${html}`);
|
|
619
|
+
assert.match(html, /within the hour/i, `${code}: and it says the access is short-lived, rather than promising a fix`);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test('when reconnecting cannot help, no reconnect link is offered', async () => {
|
|
624
|
+
for (const code of ['behind', 'no_repo', 'failed', 'no_branch']) {
|
|
625
|
+
const b = boot({ scope: 'mercury', instanceOver: landedWith({ code, text: 'x' }) });
|
|
626
|
+
await b.settle();
|
|
627
|
+
assert.doesNotMatch(b.panel(), /auth\/web\/start/, `${code}: a sign-in does not fix this`);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('a clean move still reads as the plain confirmation', async () => {
|
|
632
|
+
const b = boot({ scope: 'mercury', instanceOver: landedWith(null) });
|
|
633
|
+
await b.settle();
|
|
634
|
+
const html = b.panel();
|
|
635
|
+
assert.match(html, /dp-settled--ok/);
|
|
636
|
+
assert.doesNotMatch(html, /GitHub was not updated/i);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('the reconnect link returns to the page\u2019s full https address when there is one', async () => {
|
|
640
|
+
// A relative return resolves against the sign-in callback's host, which is not the
|
|
641
|
+
// hall's. The absolute address is admitted by the same allowlist and lands back here.
|
|
642
|
+
const here = 'https://builders.example.com/deploy?project=mercury';
|
|
643
|
+
const b = boot({ scope: 'mercury', href: here, instanceOver: landedWith({ code: 'no_access', text: 'x' }) });
|
|
644
|
+
await b.settle();
|
|
645
|
+
assert.ok(b.panel().includes(`return=${encodeURIComponent(here)}`), b.panel());
|
|
646
|
+
});
|
package/tests/dev_lib.mjs
CHANGED
|
@@ -86,7 +86,8 @@ t('resolveRunParams: port from branding, db from repo dir, TCP localhost, gdsOnl
|
|
|
86
86
|
'config/branding.neutral.json': NEUTRAL,
|
|
87
87
|
'config/modules.json': JSON.stringify({ modules: { game: false } }),
|
|
88
88
|
});
|
|
89
|
-
|
|
89
|
+
// platform pinned: on Windows the default role is `postgres` (tests/dev_windows.mjs).
|
|
90
|
+
const p = lib.resolveRunParams({ instanceRoot: '/Users/x/test4', coreRoot: '/core', f, env: {}, userInfo: () => ({ username: 'lars' }), argv: [], platform: 'darwin' });
|
|
90
91
|
assert.equal(p.port, 3006);
|
|
91
92
|
assert.equal(p.dbName, 'test4');
|
|
92
93
|
assert.equal(p.pgHost, 'localhost'); // NOT the /var/run/postgresql linux socket
|