@bongos/core 1.19.1032 → 1.19.1034
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 +322 -117
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +5 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
- package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
- package/docs/adr/README.md +4 -4
- package/docs/api/openapi.json +1 -1
- package/docs/architecture.md +4 -3
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +3 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +3 -1
- package/docs/recipes/redteam-patrol.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/autonomy/gauge.js +57 -9
- 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/hall-ui/records/builders-roster.md +10 -0
- package/modules/hall-ui/records/catalog.md +10 -0
- package/modules/hall-ui/records/collaboration.md +14 -0
- package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
- package/modules/hall-ui/records/oversight-family.md +19 -0
- package/modules/hall-ui/records/panel-family.md +19 -0
- package/modules/hall-ui/records/reading-rooms.md +12 -0
- package/modules/hall-ui/records/record-family.md +13 -0
- package/modules/hall-ui/records/reference-room.md +9 -0
- package/modules/hall-ui/records/roster-and-ledger.md +9 -0
- package/modules/hall-ui/records/sky.md +23 -0
- package/modules/lifecycle/dependency-advisory.js +2 -1
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/lifecycle/ship-preflight.js +91 -0
- 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 +7481 -0
- package/scripts/gds/address-park-sweep.sh +3 -1
- package/scripts/gds/autobongos-run.js +91 -3
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/baseline-staleness.js +1 -1
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/db-backup-nightly.sh +9 -4
- package/scripts/gds/db-backup-offsite.sh +4 -2
- 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/package-core.js +72 -1
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +282 -80
- 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/redteam-patrol.js +4 -4
- package/scripts/gds/release-notes.js +271 -0
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/seed-board-room-tasks.js +1 -1
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-flow.js +5 -3
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +241 -16
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +86 -11
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/stray-unit-sweep.sh +3 -1
- package/scripts/gds/upgrade-migrate.js +67 -0
- package/scripts/gds/upgrade.js +9 -7
- 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/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/adr_0162_citation.mjs +184 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/autobongos_loop.mjs +114 -0
- package/tests/autonomy_gauge.mjs +56 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +329 -5
- package/tests/credential_presence.mjs +153 -0
- package/tests/db_backup_nightly.mjs +21 -3
- package/tests/db_backup_offsite.mjs +81 -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/hall_records.mjs +85 -0
- package/tests/helpers.mjs +25 -0
- package/tests/instance_db_migrated_on_code_move.mjs +8 -1
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/plain_cards.mjs +1 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/redteam_patrol.mjs +1 -1
- package/tests/release_notes.mjs +327 -0
- package/tests/shell_stat_portability.mjs +49 -0
- package/tests/ship_land_conflict.mjs +181 -0
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +226 -3
- package/tests/start_env_guard.mjs +16 -0
- package/tests/sweep_app_account.mjs +15 -2
- package/tests/upgrade.mjs +20 -1
- package/tests/upgrade_migrate.mjs +195 -0
|
@@ -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');
|
|
@@ -886,15 +887,26 @@ await t('a paragraph-long summary is bounded — it reaches a DOM', () => {
|
|
|
886
887
|
// A box that really does hold a core checkout. `repos` maps a directory to what git
|
|
887
888
|
// there answers: `core` (a real core checkout), `notCore` (a git repo of something else),
|
|
888
889
|
// `shallow`, `owned` (git refuses on ownership) or absent (not a git repo).
|
|
890
|
+
//
|
|
891
|
+
// The release notes the core PACKAGE carries (task 1004218) are asked first, and are
|
|
892
|
+
// stubbed here: none unless a test hands some in, so the checkout tests below exercise the
|
|
893
|
+
// fallback — and no test in this file can ever reach the real registry.
|
|
889
894
|
const changeDeps = ({
|
|
890
895
|
shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) },
|
|
891
896
|
log = LOG, logOk = true, repos = { '/srv/core': 'core' }, env = {}, privileged = false,
|
|
897
|
+
installedNotes = null, packagedNotes = null,
|
|
892
898
|
} = {}) => {
|
|
893
899
|
const ran = [];
|
|
900
|
+
const downloaded = [];
|
|
894
901
|
return {
|
|
895
|
-
ran, env, privileged,
|
|
902
|
+
ran, downloaded, env, privileged,
|
|
896
903
|
selfInstanceRoot: () => '/srv/core',
|
|
897
904
|
appUser: () => 'appacct',
|
|
905
|
+
readInstalledNotes: () => (installedNotes ? { notes: installedNotes } : { missing: 'this core carries no release notes' }),
|
|
906
|
+
readPackagedNotes: (v) => {
|
|
907
|
+
downloaded.push(v);
|
|
908
|
+
return packagedNotes ? { notes: packagedNotes } : { missing: `the core ${v} package carries no release notes` };
|
|
909
|
+
},
|
|
898
910
|
exec: (cmd) => {
|
|
899
911
|
ran.push(cmd);
|
|
900
912
|
const dir = (/git -C (\S+) /.exec(cmd) || [])[1];
|
|
@@ -1051,6 +1063,83 @@ await t('a long range is capped, and says how many it did not list', () => {
|
|
|
1051
1063
|
assert.equal(r.more, 5, 'the remainder is counted, not silently dropped');
|
|
1052
1064
|
});
|
|
1053
1065
|
|
|
1066
|
+
// ---- THE NOTES THE PACKAGE CARRIES, asked first (task 1004218) --------------------
|
|
1067
|
+
|
|
1068
|
+
// What the packer writes into release-notes.json: one key per release, what each brought in.
|
|
1069
|
+
const NOTES = {
|
|
1070
|
+
'1.19.900': [{ text: 'already live before the move' }],
|
|
1071
|
+
'1.19.901': [{ id: '1004001', text: 'the first thing the move brings' }],
|
|
1072
|
+
'1.19.920': [],
|
|
1073
|
+
'1.19.949': [{ id: '1004003', text: 'the target itself' }, { text: 'a hand-landed ship in the target' }],
|
|
1074
|
+
'1.19.950': [{ text: 'after the target, so not part of this move' }],
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
await t('THE PACKAGE’S NOTES ANSWER FIRST — on a box with no checkout, and with no git run at all', () => {
|
|
1078
|
+
// A tenant box, which is every box but the one that builds the core: no core history.
|
|
1079
|
+
const d = changeDeps({ packagedNotes: NOTES, repos: {} });
|
|
1080
|
+
const r = R.changesBetween('1.19.900', '1.19.949', d);
|
|
1081
|
+
assert.equal(r.error, undefined);
|
|
1082
|
+
assert.deepEqual(r.entries.map((e) => e.text),
|
|
1083
|
+
['the target itself', 'a hand-landed ship in the target', 'the first thing the move brings'], 'newest release first');
|
|
1084
|
+
assert.equal(r.source, 'release-notes');
|
|
1085
|
+
assert.deepEqual(d.ran, [], 'not one git command');
|
|
1086
|
+
assert.deepEqual(d.downloaded, ['1.19.949'], "the TARGET's own package is the one read");
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
await t('the range is (from, to]: what the box already runs is not listed, what the target carries is', () => {
|
|
1090
|
+
const texts = R.changesBetween('1.19.900', '1.19.949', changeDeps({ packagedNotes: NOTES })).entries.map((e) => e.text);
|
|
1091
|
+
assert.ok(!texts.includes('already live before the move'), '`from` is exclusive');
|
|
1092
|
+
assert.ok(texts.includes('the target itself'), '`to` is inclusive');
|
|
1093
|
+
assert.ok(!texts.includes('after the target, so not part of this move'));
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
await t('the INSTALLED core’s notes answer alone when they already reach the target — nothing is downloaded', () => {
|
|
1097
|
+
const d = changeDeps({ installedNotes: NOTES });
|
|
1098
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 3);
|
|
1099
|
+
assert.deepEqual(d.downloaded, [], 'a tenant catching up to where the platform already is costs no download');
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
await t('installed notes that stop SHORT of the target send it to the target’s package', () => {
|
|
1103
|
+
const d = changeDeps({ installedNotes: { '1.19.900': [], '1.19.901': [{ text: 'x' }] }, packagedNotes: NOTES });
|
|
1104
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 3);
|
|
1105
|
+
assert.deepEqual(d.downloaded, ['1.19.949']);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
await t('notes that BEGIN after the box’s version are not used — the list would look whole and be short', () => {
|
|
1109
|
+
// A bounded file drops its oldest releases. Answering a range that reaches back past
|
|
1110
|
+
// where it begins would silently omit what it no longer holds.
|
|
1111
|
+
const r = R.changesBetween('1.19.850', '1.19.949', changeDeps({ packagedNotes: NOTES, repos: {} }));
|
|
1112
|
+
assert.equal(r.entries, undefined, 'no list at all');
|
|
1113
|
+
assert.match(r.error, /the release notes in the core 1\.19\.949 package begin at core 1\.19\.900, after 1\.19\.850/);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
await t('NEITHER SOURCE CAN ANSWER: the error carries both reasons, and is never an empty list', () => {
|
|
1117
|
+
// Every target published before the notes were packaged, on every tenant box.
|
|
1118
|
+
const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ repos: {} }));
|
|
1119
|
+
assert.equal(r.entries, undefined);
|
|
1120
|
+
assert.match(r.error, /the core 1\.19\.949 package carries no release notes/, "the notes' reason");
|
|
1121
|
+
assert.match(r.error, /no core checkout is reachable/, "and the checkout's own precise sentence (task 1004212)");
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
await t('no notes in the package, but a checkout on the box: the checkout still answers', () => {
|
|
1125
|
+
const r = R.changesBetween('1.19.900', '1.19.949', changeDeps());
|
|
1126
|
+
assert.equal(r.entries.length, 2);
|
|
1127
|
+
assert.equal(r.source, 'checkout');
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
await t('a download that fails is named, and the checkout is still tried', () => {
|
|
1131
|
+
const d = changeDeps({ repos: {} });
|
|
1132
|
+
d.readPackagedNotes = () => ({ error: 'could not download the core 1.19.949 package to read its release notes (npm error code E404)' });
|
|
1133
|
+
assert.match(R.changesBetween('1.19.900', '1.19.949', d).error, /could not download the core 1\.19\.949 package.*; no core checkout is reachable/);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
await t('a notes answer is capped exactly like a checkout answer', () => {
|
|
1137
|
+
const many = { '1.19.900': [], '1.19.949': Array.from({ length: R.CHANGE_CAP + 3 }, (_, i) => ({ text: `summary ${i}` })) };
|
|
1138
|
+
const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ packagedNotes: many }));
|
|
1139
|
+
assert.equal(r.entries.length, R.CHANGE_CAP);
|
|
1140
|
+
assert.equal(r.more, 3);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1054
1143
|
await t('the dry run records the list beside the verdict, keyed to where the box IS', async () => {
|
|
1055
1144
|
// Both answers are about ONE move, so they are stored together — and `from` is stored
|
|
1056
1145
|
// with them because the list is only true of that starting point.
|
|
@@ -1071,5 +1160,240 @@ await t('the dry run records the list beside the verdict, keyed to where the box
|
|
|
1071
1160
|
assert.equal(stored.changes.entries[0].text, 'a thing landed');
|
|
1072
1161
|
});
|
|
1073
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
|
+
|
|
1074
1398
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
1075
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
|
+
});
|
|
@@ -18,6 +18,7 @@ import fs from 'node:fs';
|
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { macShapedTools } from './helpers.mjs';
|
|
21
22
|
|
|
22
23
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
24
|
const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'db-backup-nightly.sh');
|
|
@@ -29,13 +30,15 @@ const ENV = 'gds_backup_'.toUpperCase();
|
|
|
29
30
|
// A fake pg_dump: a plain-format dump of `rows` rows, closed the way pg_dump closes one
|
|
30
31
|
// (including the \unrestrict line patched servers append) unless it is `cut` short, and
|
|
31
32
|
// exiting `exitCode`.
|
|
32
|
-
// `seed` pre-places dumps in the shared dir as [filename, ageInDays]
|
|
33
|
-
|
|
33
|
+
// `seed` pre-places dumps in the shared dir as [filename, ageInDays]; `mac` puts
|
|
34
|
+
// BSD/macOS-shaped stat and wc first on PATH too.
|
|
35
|
+
function runBackup({ rows = 40, cut = false, exitCode = 0, env = {}, seed = [], mac = false } = {}) {
|
|
34
36
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'db-backup-'));
|
|
35
37
|
const bin = path.join(tmp, 'bin');
|
|
36
38
|
const dir = path.join(tmp, 'backups');
|
|
37
39
|
fs.mkdirSync(bin);
|
|
38
40
|
fs.mkdirSync(dir);
|
|
41
|
+
if (mac) macShapedTools(bin);
|
|
39
42
|
for (const [name, ageDays] of seed) {
|
|
40
43
|
const f = path.join(dir, name);
|
|
41
44
|
fs.writeFileSync(f, '');
|
|
@@ -50,7 +53,7 @@ function runBackup({ rows = 40, cut = false, exitCode = 0, env = {}, seed = [] }
|
|
|
50
53
|
encoding: 'utf8',
|
|
51
54
|
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, [`${ENV}DB`]: 'smallproj', [`${ENV}DIR`]: dir, ...env },
|
|
52
55
|
});
|
|
53
|
-
return { status: r.status, stdout: r.stdout, stderr: r.stderr, dumps: fs.readdirSync(dir).filter((f) => f.endsWith('.sql.gz')) };
|
|
56
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr, dir, dumps: fs.readdirSync(dir).filter((f) => f.endsWith('.sql.gz')) };
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
test('a complete dump of a small project is kept, however small', { skip }, () => {
|
|
@@ -93,6 +96,21 @@ test('the prune and the count reach only this database\'s own dumps, never a lon
|
|
|
93
96
|
assert.match(r.stdout, /ok · 1 dump\(s\) retained/, 'the count is this database\'s dumps only');
|
|
94
97
|
});
|
|
95
98
|
|
|
99
|
+
test('it runs the same on macOS, where stat refuses -c and wc pads its numbers (task 1004170)', { skip }, () => {
|
|
100
|
+
// Every case above used to die on a Mac with "stat: illegal option -- c" while CI's
|
|
101
|
+
// Linux passed them, so the red never showed in Actions. This runs the Mac's tools here.
|
|
102
|
+
const attach = fs.mkdtempSync(path.join(os.tmpdir(), 'db-backup-attach-'));
|
|
103
|
+
for (const f of ['a.png', 'b.png']) fs.writeFileSync(path.join(attach, f), 'x');
|
|
104
|
+
const r = runBackup({ mac: true, seed: [['smallproj-2026-01-01T000000Z.sql.gz', 40]], env: { BUG_ATTACHMENTS_DIR: attach } });
|
|
105
|
+
assert.equal(r.status, 0, r.stderr);
|
|
106
|
+
assert.equal(r.dumps.length, 1, 'the expired dump is pruned and the new one kept');
|
|
107
|
+
const size = fs.statSync(path.join(r.dir, r.dumps[0])).size;
|
|
108
|
+
assert.match(r.stdout, new RegExp(`wrote .*\\.sql\\.gz \\(${size} bytes\\)`), 'the byte count is the real size, unpadded');
|
|
109
|
+
assert.match(r.stdout, /\(2 attachment\(s\)\)/);
|
|
110
|
+
assert.match(r.stdout, /pruned 1 dump\(s\)/);
|
|
111
|
+
assert.match(r.stdout, /ok · 1 dump\(s\) retained/);
|
|
112
|
+
});
|
|
113
|
+
|
|
96
114
|
test('no fixed size floor is baked in for every instance', () => {
|
|
97
115
|
const src = fs.readFileSync(SCRIPT, 'utf8');
|
|
98
116
|
assert.doesNotMatch(src, new RegExp(`${ENV}MIN_BYTES:-[1-9]`), 'a default floor fails every database smaller than it');
|