mandrel-platform 1.8.1 → 1.9.0
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/package.json +1 -1
- package/scripts/env-doctor.mjs +148 -15
- package/scripts/env-doctor.test.mjs +280 -0
package/package.json
CHANGED
package/scripts/env-doctor.mjs
CHANGED
|
@@ -131,7 +131,7 @@ export const KEY_SCHEMA = Object.freeze({
|
|
|
131
131
|
kind: "'var' | 'secret'",
|
|
132
132
|
sensitivity: "'public' | 'secret'",
|
|
133
133
|
residency:
|
|
134
|
-
"object — {local: 'var'|'secret'|'file'|null, github: {scope,kind}
|
|
134
|
+
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
|
|
135
135
|
infisical: "{folder, environments} | 'unmanaged'",
|
|
136
136
|
shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
|
|
137
137
|
placeholderPattern: "string? — literal, case-insensitive substring marking an unset placeholder value",
|
|
@@ -181,6 +181,96 @@ export function parseManifest(raw) {
|
|
|
181
181
|
return { environments: [...environments], workers, keys };
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Normalize `residency.github` to its canonical array form.
|
|
186
|
+
*
|
|
187
|
+
* The field accepts EITHER a single `{scope, kind}` object — the shape every
|
|
188
|
+
* manifest written before Story #459 uses — or an array of them, because two
|
|
189
|
+
* residencies that occur in practice cannot be said with one object:
|
|
190
|
+
*
|
|
191
|
+
* 1. **Dual scope.** A key can legitimately live at repository level AND at
|
|
192
|
+
* environment level, for different consumers: a deploy job declares
|
|
193
|
+
* `environment:` and reads the per-environment value while a CI job
|
|
194
|
+
* declares none and reads a repo-level credential of the same name.
|
|
195
|
+
* Under a single object, whichever scope the manifest declared, the other
|
|
196
|
+
* reported as an orphan — permanently, on a healthy repo.
|
|
197
|
+
* 2. **Per-environment presence.** A key can be deliberately present in one
|
|
198
|
+
* environment and absent from another (a production-only analytics token
|
|
199
|
+
* whose staging counterpart is meant to resolve empty). With no
|
|
200
|
+
* per-entry `environments`, the absence read as a `missing` failure.
|
|
201
|
+
*
|
|
202
|
+
* Both authored shapes normalize to one array of `{scope, kind, environments}`
|
|
203
|
+
* with `environments` defaulted and materialized to `manifest.environments`,
|
|
204
|
+
* so `probeGitHub` has exactly one shape to read — the same normalize-at-parse
|
|
205
|
+
* treatment `infisical.environments` already receives.
|
|
206
|
+
*
|
|
207
|
+
* Validation fails closed, matching this module's posture on an unknown
|
|
208
|
+
* `shape`: silently ignoring a misplaced or misspelled `environments` is how a
|
|
209
|
+
* manifest author comes to believe they have scoped something they have not.
|
|
210
|
+
*
|
|
211
|
+
* @param {unknown} raw
|
|
212
|
+
* @param {{at: string, name: string, environments: string[]}} ctx
|
|
213
|
+
* @returns {Array<{scope: string, kind: string, environments: string[]}> | null}
|
|
214
|
+
*/
|
|
215
|
+
function normalizeGitHubResidency(raw, { at, name, environments }) {
|
|
216
|
+
if (raw === undefined || raw === null) return null;
|
|
217
|
+
|
|
218
|
+
const authoredAsArray = Array.isArray(raw);
|
|
219
|
+
const entries = authoredAsArray ? raw : [raw];
|
|
220
|
+
if (entries.length === 0) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${at}.residency.github must not be an empty array — use null when the key does not belong in GitHub (key ${name})`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const seenPairs = new Set();
|
|
227
|
+
return entries.map((entry, j) => {
|
|
228
|
+
const where = authoredAsArray ? `${at}.residency.github[${j}]` : `${at}.residency.github`;
|
|
229
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
230
|
+
throw new Error(`${where} must be an object with {scope, kind} (key ${name})`);
|
|
231
|
+
}
|
|
232
|
+
if (entry.scope !== "environment" && entry.scope !== "repository") {
|
|
233
|
+
throw new Error(`${where}.scope must be "environment" or "repository" (key ${name})`);
|
|
234
|
+
}
|
|
235
|
+
if (entry.kind !== "secret" && entry.kind !== "var") {
|
|
236
|
+
throw new Error(`${where}.kind must be "secret" or "var" (key ${name})`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pair = `${entry.scope}/${entry.kind}`;
|
|
240
|
+
if (seenPairs.has(pair)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`${where} repeats the (scope, kind) pair "${pair}" — declare one entry per pair (key ${name})`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
seenPairs.add(pair);
|
|
246
|
+
|
|
247
|
+
if (entry.environments !== undefined) {
|
|
248
|
+
if (entry.scope === "repository") {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${where}.environments is meaningful only under scope "environment" — a repository-scope ` +
|
|
251
|
+
`secret or variable belongs to no environment (key ${name})`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(entry.environments) || !entry.environments.every((e) => typeof e === "string")) {
|
|
255
|
+
throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
|
|
256
|
+
}
|
|
257
|
+
for (const e of entry.environments) {
|
|
258
|
+
if (!environments.includes(e)) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`${where}.environments names "${e}", absent from manifest.environments (key ${name})`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
scope: entry.scope,
|
|
268
|
+
kind: entry.kind,
|
|
269
|
+
environments: entry.environments ? [...entry.environments] : [...environments],
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
184
274
|
/**
|
|
185
275
|
* @param {unknown} entry
|
|
186
276
|
* @param {number} index
|
|
@@ -217,15 +307,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
217
307
|
throw new Error(`${at}.residency.local must be "var", "secret", "file" or null (key ${name})`);
|
|
218
308
|
}
|
|
219
309
|
|
|
220
|
-
const github = residency.github
|
|
221
|
-
if (github !== null) {
|
|
222
|
-
if (github.scope !== "environment" && github.scope !== "repository") {
|
|
223
|
-
throw new Error(`${at}.residency.github.scope must be "environment" or "repository" (key ${name})`);
|
|
224
|
-
}
|
|
225
|
-
if (github.kind !== "secret" && github.kind !== "var") {
|
|
226
|
-
throw new Error(`${at}.residency.github.kind must be "secret" or "var" (key ${name})`);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
310
|
+
const github = normalizeGitHubResidency(residency.github, { at, name, environments });
|
|
229
311
|
|
|
230
312
|
const cloudflare = residency.cloudflare ?? null;
|
|
231
313
|
if (cloudflare !== null) {
|
|
@@ -774,12 +856,22 @@ export function runOfflineChecks({ manifest, repoRoot }) {
|
|
|
774
856
|
* @param {string} opts.surface
|
|
775
857
|
* @param {string | null} opts.environment
|
|
776
858
|
* @param {string} [opts.scope] Free-text scope for the detail line.
|
|
859
|
+
* @param {Iterable<string> | null} [opts.declaredElsewhere]
|
|
860
|
+
* Names the caller has already accounted for in a SIBLING partition of the
|
|
861
|
+
* same surface. A name in this set is never reported as an orphan here; it
|
|
862
|
+
* has no effect on `missing`. The option exists because a surface can be
|
|
863
|
+
* probed in more than one partition — GitHub is read at repository scope and
|
|
864
|
+
* again per environment — and a key legitimately resident in two of them is
|
|
865
|
+
* not drift. Only the caller knows which partitions are siblings, so the set
|
|
866
|
+
* is supplied rather than inferred: the default is `null`, which keeps this
|
|
867
|
+
* function's behaviour identical for every caller that omits it.
|
|
777
868
|
* @returns {object[]} findings
|
|
778
869
|
*/
|
|
779
|
-
export function reconcileNames({ expected, present, surface, environment, scope = "" }) {
|
|
870
|
+
export function reconcileNames({ expected, present, surface, environment, scope = "", declaredElsewhere = null }) {
|
|
780
871
|
const findings = [];
|
|
781
872
|
const presentSet = new Set(present);
|
|
782
873
|
const expectedSet = new Set(expected);
|
|
874
|
+
const elsewhere = declaredElsewhere ? new Set(declaredElsewhere) : null;
|
|
783
875
|
const where = scope ? ` (${scope})` : "";
|
|
784
876
|
for (const name of expected) {
|
|
785
877
|
if (!presentSet.has(name)) {
|
|
@@ -795,6 +887,7 @@ export function reconcileNames({ expected, present, surface, environment, scope
|
|
|
795
887
|
}
|
|
796
888
|
for (const name of present) {
|
|
797
889
|
if (!expectedSet.has(name)) {
|
|
890
|
+
if (elsewhere?.has(name)) continue;
|
|
798
891
|
findings.push({
|
|
799
892
|
severity: "orphan",
|
|
800
893
|
kind: "orphan",
|
|
@@ -1065,6 +1158,45 @@ function unavailableNotice(unavailability, surface) {
|
|
|
1065
1158
|
}
|
|
1066
1159
|
|
|
1067
1160
|
/**
|
|
1161
|
+
* Names declared at one GitHub `(scope, kind)`, optionally narrowed to the
|
|
1162
|
+
* entries that name a given environment.
|
|
1163
|
+
*
|
|
1164
|
+
* @param {object[]} keys Parsed manifest keys.
|
|
1165
|
+
* @param {string} scope "repository" | "environment"
|
|
1166
|
+
* @param {string} kind "secret" | "var"
|
|
1167
|
+
* @param {string | null} environment Non-null narrows to entries naming it.
|
|
1168
|
+
* @returns {string[]}
|
|
1169
|
+
*/
|
|
1170
|
+
function githubNamesAt(keys, scope, kind, environment) {
|
|
1171
|
+
return keys
|
|
1172
|
+
.filter((k) =>
|
|
1173
|
+
(k.residency.github ?? []).some(
|
|
1174
|
+
(g) =>
|
|
1175
|
+
g.scope === scope && g.kind === kind && (environment === null || g.environments.includes(environment))
|
|
1176
|
+
)
|
|
1177
|
+
)
|
|
1178
|
+
.map((k) => k.name);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Reconcile the GitHub surface per `(scope, kind, environment)` triple.
|
|
1183
|
+
*
|
|
1184
|
+
* Two partitions are read — repository scope once, environment scope once per
|
|
1185
|
+
* environment — and a key may legitimately be resident in both. So each
|
|
1186
|
+
* partition suppresses orphans for names declared at the OTHER scope with the
|
|
1187
|
+
* SAME kind, and nothing wider:
|
|
1188
|
+
*
|
|
1189
|
+
* - **Cross-scope, same kind** is suppressed: that is the dual-scope residency
|
|
1190
|
+
* the array form exists to describe, and reporting it was the false orphan.
|
|
1191
|
+
* - **Cross-kind is NOT suppressed.** A name declared as a secret but present
|
|
1192
|
+
* as a variable is a real mismatch, and the orphan is how it surfaces.
|
|
1193
|
+
* - **Cross-environment is NOT suppressed.** A production-only key turning up
|
|
1194
|
+
* in staging is undeclared presence in that environment — arguably the most
|
|
1195
|
+
* interesting thing this surface can find — so it still orphans.
|
|
1196
|
+
*
|
|
1197
|
+
* A name declared in no GitHub scope at all orphans exactly as before, which
|
|
1198
|
+
* is what keeps `--strict-orphans` worth enabling once a manifest is correct.
|
|
1199
|
+
*
|
|
1068
1200
|
* @param {object} ctx
|
|
1069
1201
|
*/
|
|
1070
1202
|
async function probeGitHub({ manifest, environments, github, surfaces, findings, unavailability = {} }) {
|
|
@@ -1076,18 +1208,18 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
|
|
|
1076
1208
|
});
|
|
1077
1209
|
return;
|
|
1078
1210
|
}
|
|
1079
|
-
const
|
|
1080
|
-
const envKeys = manifest.keys.filter((k) => k.residency.github?.scope === "environment");
|
|
1211
|
+
const keys = manifest.keys;
|
|
1081
1212
|
try {
|
|
1082
1213
|
const present = await github.repositoryNames();
|
|
1083
1214
|
for (const kind of ["secret", "var"]) {
|
|
1084
1215
|
findings.push(
|
|
1085
1216
|
...reconcileNames({
|
|
1086
|
-
expected:
|
|
1217
|
+
expected: githubNamesAt(keys, "repository", kind, null),
|
|
1087
1218
|
present: present[kind] ?? [],
|
|
1088
1219
|
surface: "github",
|
|
1089
1220
|
environment: null,
|
|
1090
1221
|
scope: `repository ${kind}s`,
|
|
1222
|
+
declaredElsewhere: githubNamesAt(keys, "environment", kind, null),
|
|
1091
1223
|
})
|
|
1092
1224
|
);
|
|
1093
1225
|
}
|
|
@@ -1096,11 +1228,12 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
|
|
|
1096
1228
|
for (const kind of ["secret", "var"]) {
|
|
1097
1229
|
findings.push(
|
|
1098
1230
|
...reconcileNames({
|
|
1099
|
-
expected:
|
|
1231
|
+
expected: githubNamesAt(keys, "environment", kind, environment),
|
|
1100
1232
|
present: envPresent[kind] ?? [],
|
|
1101
1233
|
surface: "github",
|
|
1102
1234
|
environment,
|
|
1103
1235
|
scope: `environment ${kind}s`,
|
|
1236
|
+
declaredElsewhere: githubNamesAt(keys, "repository", kind, null),
|
|
1104
1237
|
})
|
|
1105
1238
|
);
|
|
1106
1239
|
}
|
|
@@ -186,6 +186,78 @@ test("parseManifest rejects a duplicate key name and an infisical env outside th
|
|
|
186
186
|
assert.throws(() => parseManifest(badEnv), /absent from manifest.environments/);
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
test("parseManifest normalizes the single-object residency.github to a one-entry array", () => {
|
|
190
|
+
const m = parseManifest(singleWorkerManifest());
|
|
191
|
+
// The authored shape is an object; the parsed shape is always an array, so
|
|
192
|
+
// probeGitHub reads exactly one shape. `environments` defaults to all of
|
|
193
|
+
// manifest.environments, the same treatment infisical.environments gets.
|
|
194
|
+
assert.deepEqual(m.keys[0].residency.github, [
|
|
195
|
+
{ scope: "environment", kind: "var", environments: ["staging", "production"] },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("parseManifest accepts the array form and defaults environments per entry", () => {
|
|
200
|
+
const raw = singleWorkerManifest();
|
|
201
|
+
raw.keys[0].residency.github = [
|
|
202
|
+
{ scope: "repository", kind: "var" },
|
|
203
|
+
{ scope: "environment", kind: "var", environments: ["production"] },
|
|
204
|
+
];
|
|
205
|
+
const m = parseManifest(raw);
|
|
206
|
+
assert.deepEqual(m.keys[0].residency.github, [
|
|
207
|
+
{ scope: "repository", kind: "var", environments: ["staging", "production"] },
|
|
208
|
+
{ scope: "environment", kind: "var", environments: ["production"] },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("parseManifest rejects an residency.github environments slug outside manifest.environments", () => {
|
|
213
|
+
const raw = singleWorkerManifest();
|
|
214
|
+
raw.keys[0].residency.github = [{ scope: "environment", kind: "var", environments: ["preview"] }];
|
|
215
|
+
assert.throws(() => parseManifest(raw), (err) => {
|
|
216
|
+
assert.match(err.message, /absent from manifest.environments/);
|
|
217
|
+
assert.match(err.message, /PUBLIC_SITE_URL/);
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("parseManifest rejects environments on a repository-scope entry rather than ignoring it", () => {
|
|
223
|
+
// Silently ignoring it is how an author comes to believe they scoped
|
|
224
|
+
// something they did not — the same fail-closed posture as an unknown shape.
|
|
225
|
+
const raw = singleWorkerManifest();
|
|
226
|
+
raw.keys[0].residency.github = [{ scope: "repository", kind: "var", environments: ["production"] }];
|
|
227
|
+
assert.throws(() => parseManifest(raw), (err) => {
|
|
228
|
+
assert.match(err.message, /meaningful only under scope "environment"/);
|
|
229
|
+
assert.match(err.message, /PUBLIC_SITE_URL/);
|
|
230
|
+
return true;
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("parseManifest rejects a duplicate (scope, kind) pair and an empty residency.github array", () => {
|
|
235
|
+
const dup = singleWorkerManifest();
|
|
236
|
+
dup.keys[0].residency.github = [
|
|
237
|
+
{ scope: "environment", kind: "var" },
|
|
238
|
+
{ scope: "environment", kind: "var", environments: ["production"] },
|
|
239
|
+
];
|
|
240
|
+
assert.throws(() => parseManifest(dup), (err) => {
|
|
241
|
+
assert.match(err.message, /repeats the \(scope, kind\) pair "environment\/var"/);
|
|
242
|
+
assert.match(err.message, /PUBLIC_SITE_URL/);
|
|
243
|
+
return true;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const empty = singleWorkerManifest();
|
|
247
|
+
empty.keys[0].residency.github = [];
|
|
248
|
+
assert.throws(() => parseManifest(empty), /must not be an empty array/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("parseManifest still rejects a malformed scope and kind under both authored shapes", () => {
|
|
252
|
+
const objForm = singleWorkerManifest();
|
|
253
|
+
objForm.keys[0].residency.github = { scope: "org", kind: "var" };
|
|
254
|
+
assert.throws(() => parseManifest(objForm), /\.scope must be "environment" or "repository"/);
|
|
255
|
+
|
|
256
|
+
const arrForm = singleWorkerManifest();
|
|
257
|
+
arrForm.keys[0].residency.github = [{ scope: "repository", kind: "file" }];
|
|
258
|
+
assert.throws(() => parseManifest(arrForm), /residency\.github\[0\]\.kind must be "secret" or "var"/);
|
|
259
|
+
});
|
|
260
|
+
|
|
189
261
|
test("resolveScriptName substitutes {env} per environment", () => {
|
|
190
262
|
assert.equal(resolveScriptName("acme-site-{env}", "staging"), "acme-site-staging");
|
|
191
263
|
assert.equal(resolveScriptName("acme-site-{env}", "production"), "acme-site-production");
|
|
@@ -458,6 +530,214 @@ test("a non-404 probe failure becomes an error surface and exits 1 — never 'no
|
|
|
458
530
|
}
|
|
459
531
|
});
|
|
460
532
|
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// GitHub residency — dual scope and per-environment presence (Story #459)
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* A manifest with no workers and no local residency, so the offline arm over an
|
|
539
|
+
* empty repo root contributes no findings and every finding under test comes
|
|
540
|
+
* from the GitHub probe.
|
|
541
|
+
*/
|
|
542
|
+
function githubOnlyManifest(github) {
|
|
543
|
+
return parseManifest({
|
|
544
|
+
environments: ["staging", "production"],
|
|
545
|
+
keys: [{ name: "SHARED_TOKEN", kind: "secret", sensitivity: "secret", residency: { local: null, github } }],
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Mock the two GitHub probe calls from a `{repository, staging, production}` map. */
|
|
550
|
+
function githubProbe(present) {
|
|
551
|
+
const at = (slot) => ({ secret: [], var: [], ...(present[slot] ?? {}) });
|
|
552
|
+
return {
|
|
553
|
+
repositoryNames: async () => at("repository"),
|
|
554
|
+
environmentNames: async (environment) => at(environment),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function githubFindings({ manifest, present, environments = ["staging", "production"] }) {
|
|
559
|
+
const root = makeRepo({});
|
|
560
|
+
try {
|
|
561
|
+
const report = await runDoctor({ manifest, repoRoot: root, environments, github: githubProbe(present) });
|
|
562
|
+
return report.findings.filter((f) => f.surface === "github");
|
|
563
|
+
} finally {
|
|
564
|
+
rmSync(root, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const DUAL_SCOPE = [
|
|
569
|
+
{ scope: "repository", kind: "secret" },
|
|
570
|
+
{ scope: "environment", kind: "secret" },
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
test("a dual-scope key present at both scopes reports no finding", async () => {
|
|
574
|
+
const findings = await githubFindings({
|
|
575
|
+
manifest: githubOnlyManifest(DUAL_SCOPE),
|
|
576
|
+
present: {
|
|
577
|
+
repository: { secret: ["SHARED_TOKEN"] },
|
|
578
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
579
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
assert.deepEqual(findings, []);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("a dual-scope key absent from one scope reports a missing naming that scope only", async () => {
|
|
586
|
+
const findings = await githubFindings({
|
|
587
|
+
manifest: githubOnlyManifest(DUAL_SCOPE),
|
|
588
|
+
present: {
|
|
589
|
+
repository: { secret: [] },
|
|
590
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
591
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
assert.equal(findings.length, 1);
|
|
595
|
+
assert.equal(findings[0].kind, "missing");
|
|
596
|
+
assert.equal(findings[0].key, "SHARED_TOKEN");
|
|
597
|
+
assert.equal(findings[0].environment, null);
|
|
598
|
+
assert.match(findings[0].detail, /repository secrets/);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("a dual-scope key absent from one environment reports a missing naming that environment only", async () => {
|
|
602
|
+
const findings = await githubFindings({
|
|
603
|
+
manifest: githubOnlyManifest(DUAL_SCOPE),
|
|
604
|
+
present: {
|
|
605
|
+
repository: { secret: ["SHARED_TOKEN"] },
|
|
606
|
+
staging: { secret: [] },
|
|
607
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
assert.equal(findings.length, 1);
|
|
611
|
+
assert.equal(findings[0].kind, "missing");
|
|
612
|
+
assert.equal(findings[0].environment, "staging");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("an environments-scoped entry reports no missing for an environment it never names", async () => {
|
|
616
|
+
// The motivating case: a production-only analytics token whose staging
|
|
617
|
+
// counterpart is meant to resolve empty. That is design, not drift.
|
|
618
|
+
const manifest = githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]);
|
|
619
|
+
assert.deepEqual(
|
|
620
|
+
await githubFindings({ manifest, present: { production: { secret: ["SHARED_TOKEN"] } } }),
|
|
621
|
+
[]
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const missing = await githubFindings({ manifest, present: {} });
|
|
625
|
+
assert.equal(missing.length, 1, "production still fails when it lacks the key");
|
|
626
|
+
assert.equal(missing[0].kind, "missing");
|
|
627
|
+
assert.equal(missing[0].environment, "production");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("a key declared only at environment scope is not a repository-level orphan", async () => {
|
|
631
|
+
const findings = await githubFindings({
|
|
632
|
+
manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
|
|
633
|
+
present: {
|
|
634
|
+
repository: { secret: ["SHARED_TOKEN"] },
|
|
635
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
636
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
assert.deepEqual(findings, []);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("suppression is cross-scope only — an undeclared name still orphans", async () => {
|
|
643
|
+
// The half that keeps --strict-orphans worth enabling: nothing about the
|
|
644
|
+
// suppression hides a name the manifest never declared anywhere.
|
|
645
|
+
const findings = await githubFindings({
|
|
646
|
+
manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
|
|
647
|
+
present: {
|
|
648
|
+
repository: { secret: ["SHARED_TOKEN", "UNDECLARED_TOKEN"] },
|
|
649
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
650
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
assert.equal(findings.length, 1);
|
|
654
|
+
assert.equal(findings[0].kind, "orphan");
|
|
655
|
+
assert.equal(findings[0].key, "UNDECLARED_TOKEN");
|
|
656
|
+
assert.equal(findings[0].environment, null);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("suppression does not cross kind — a secret declared, a variable present, still orphans", async () => {
|
|
660
|
+
const findings = await githubFindings({
|
|
661
|
+
manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
|
|
662
|
+
present: {
|
|
663
|
+
repository: { var: ["SHARED_TOKEN"] },
|
|
664
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
665
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
assert.equal(findings.length, 1);
|
|
669
|
+
assert.equal(findings[0].kind, "orphan");
|
|
670
|
+
assert.match(findings[0].detail, /repository vars/);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("suppression does not cross environment — a production-only key found in staging orphans", async () => {
|
|
674
|
+
// Undeclared presence in the wrong environment is the most interesting thing
|
|
675
|
+
// this surface can find, so the cross-scope rule must not reach it.
|
|
676
|
+
const findings = await githubFindings({
|
|
677
|
+
manifest: githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]),
|
|
678
|
+
present: {
|
|
679
|
+
staging: { secret: ["SHARED_TOKEN"] },
|
|
680
|
+
production: { secret: ["SHARED_TOKEN"] },
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
assert.equal(findings.length, 1);
|
|
684
|
+
assert.equal(findings[0].kind, "orphan");
|
|
685
|
+
assert.equal(findings[0].environment, "staging");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("the single-object residency.github form yields exactly the findings it did before", async () => {
|
|
689
|
+
// The regression case, over the unchanged existing fixture: both keys are
|
|
690
|
+
// authored as single objects at environment scope, so a repo-level probe
|
|
691
|
+
// holding neither must produce two per-environment missings and nothing else.
|
|
692
|
+
const root = makeRepo(CONSISTENT_REPO);
|
|
693
|
+
try {
|
|
694
|
+
const report = await runDoctor({
|
|
695
|
+
manifest: parseManifest(singleWorkerManifest()),
|
|
696
|
+
repoRoot: root,
|
|
697
|
+
environments: ["staging", "production"],
|
|
698
|
+
github: githubProbe({
|
|
699
|
+
staging: { var: ["PUBLIC_SITE_URL"], secret: ["TURSO_AUTH_TOKEN"] },
|
|
700
|
+
production: { var: ["PUBLIC_SITE_URL"] },
|
|
701
|
+
}),
|
|
702
|
+
});
|
|
703
|
+
const gh = report.findings.filter((f) => f.surface === "github");
|
|
704
|
+
assert.equal(gh.length, 1);
|
|
705
|
+
assert.deepEqual(
|
|
706
|
+
{ kind: gh[0].kind, key: gh[0].key, environment: gh[0].environment },
|
|
707
|
+
{ kind: "missing", key: "TURSO_AUTH_TOKEN", environment: "production" }
|
|
708
|
+
);
|
|
709
|
+
assert.equal(report.surfaces.find((s) => s.surface === "github").status, "checked");
|
|
710
|
+
} finally {
|
|
711
|
+
rmSync(root, { recursive: true, force: true });
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("declaredElsewhere suppresses only orphans, never a missing", async () => {
|
|
716
|
+
// The new argument is opt-in and orphan-only, so the cloudflare and infisical
|
|
717
|
+
// call sites that omit it cannot change behaviour.
|
|
718
|
+
const suppressed = reconcileNames({
|
|
719
|
+
expected: ["A"],
|
|
720
|
+
present: ["B"],
|
|
721
|
+
surface: "github",
|
|
722
|
+
environment: null,
|
|
723
|
+
declaredElsewhere: ["A", "B"],
|
|
724
|
+
});
|
|
725
|
+
assert.deepEqual(
|
|
726
|
+
suppressed.map((f) => [f.kind, f.key]),
|
|
727
|
+
[["missing", "A"]]
|
|
728
|
+
);
|
|
729
|
+
assert.deepEqual(reconcileNames({ expected: [], present: ["B"], surface: "github", environment: null }), [
|
|
730
|
+
{
|
|
731
|
+
severity: "orphan",
|
|
732
|
+
kind: "orphan",
|
|
733
|
+
key: "B",
|
|
734
|
+
surface: "github",
|
|
735
|
+
environment: null,
|
|
736
|
+
detail: "present in github but declared by no manifest key",
|
|
737
|
+
},
|
|
738
|
+
]);
|
|
739
|
+
});
|
|
740
|
+
|
|
461
741
|
test("the Cloudflare probe resolves {env} in scriptName once per environment", async () => {
|
|
462
742
|
const root = makeRepo(CONSISTENT_REPO);
|
|
463
743
|
const asked = [];
|