mandrel-platform 1.9.0 → 1.10.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 +254 -31
- package/scripts/env-doctor.test.mjs +385 -0
package/package.json
CHANGED
package/scripts/env-doctor.mjs
CHANGED
|
@@ -121,6 +121,8 @@ export const SHAPE_NAMES = Object.freeze(Object.keys(SHAPE_VOCABULARY));
|
|
|
121
121
|
*/
|
|
122
122
|
export const MANIFEST_SCHEMA = Object.freeze({
|
|
123
123
|
environments: "string[] — environment slugs, e.g. ['staging','production']",
|
|
124
|
+
environmentSlugs:
|
|
125
|
+
"object? — surface name -> {environment: slug}. Only 'infisical' is honored; an unmapped environment resolves to itself.",
|
|
124
126
|
workers:
|
|
125
127
|
"object? — worker id -> {config, scriptName}. `scriptName` may contain '{env}', substituted per environment.",
|
|
126
128
|
keys: "object[] — one entry per env var / secret (see KEY_SCHEMA)",
|
|
@@ -132,12 +134,97 @@ export const KEY_SCHEMA = Object.freeze({
|
|
|
132
134
|
sensitivity: "'public' | 'secret'",
|
|
133
135
|
residency:
|
|
134
136
|
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
|
|
135
|
-
infisical:
|
|
137
|
+
infisical:
|
|
138
|
+
"{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
|
|
136
139
|
shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
|
|
137
140
|
placeholderPattern: "string? — literal, case-insensitive substring marking an unset placeholder value",
|
|
138
141
|
note: "string? — free text",
|
|
139
142
|
});
|
|
140
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The surfaces whose environment namespace can be remapped.
|
|
146
|
+
*
|
|
147
|
+
* `manifest.environments` names DEPLOY environments, and until Story #464 that
|
|
148
|
+
* one list was substituted verbatim into three namespaces that are not the
|
|
149
|
+
* same namespace — the Worker script name, the GitHub Environment API path,
|
|
150
|
+
* and the Infisical environment slug. A project whose Infisical slugs differ
|
|
151
|
+
* from its deploy names (`prod` against a `production` environment) could not
|
|
152
|
+
* be probed at all: the surface went to `error`, which is deliberately
|
|
153
|
+
* unsuppressable, so the lane was permanently red with no downstream fix.
|
|
154
|
+
*
|
|
155
|
+
* Only `infisical` is remappable, and deliberately so. Cloudflare already has
|
|
156
|
+
* its own escape hatch — `resolveScriptName` substitutes `{env}` into
|
|
157
|
+
* `workers[].scriptName`, so `acme-site-{env}` resolves whatever the deploy
|
|
158
|
+
* name is — and giving it a slug map too would be two mechanisms for one job.
|
|
159
|
+
* The GitHub Environment API is read at `manifest.environments` verbatim and
|
|
160
|
+
* no divergence has been observed there. The CONTAINER is nonetheless keyed by
|
|
161
|
+
* surface rather than being an Infisical-only field, so a surface that ever
|
|
162
|
+
* does diverge adopts it without inventing a second idiom.
|
|
163
|
+
*/
|
|
164
|
+
export const SLUG_MAPPED_SURFACES = Object.freeze(["infisical"]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Normalize `manifest.environmentSlugs` to a total map over the slug-mapped
|
|
168
|
+
* surfaces, so a caller never has to distinguish "absent" from "empty".
|
|
169
|
+
*
|
|
170
|
+
* Validation fails closed, matching this module's posture on an unknown
|
|
171
|
+
* `shape`: silently ignoring a misspelled surface or environment is how a
|
|
172
|
+
* manifest author comes to believe they have remapped something they have not,
|
|
173
|
+
* and the symptom — a 404 from the store — looks nothing like the cause.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} raw
|
|
176
|
+
* @param {string[]} environments
|
|
177
|
+
* @returns {Record<string, Record<string, string>>}
|
|
178
|
+
*/
|
|
179
|
+
function normalizeEnvironmentSlugs(raw, environments) {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const surface of SLUG_MAPPED_SURFACES) out[surface] = {};
|
|
182
|
+
if (raw === undefined || raw === null) return out;
|
|
183
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
184
|
+
throw new Error("manifest.environmentSlugs must be an object mapping a surface name -> {environment: slug}");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [surface, map] of Object.entries(raw)) {
|
|
188
|
+
if (!SLUG_MAPPED_SURFACES.includes(surface)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`manifest.environmentSlugs."${surface}" is not a slug-mapped surface — supported: ` +
|
|
191
|
+
`${SLUG_MAPPED_SURFACES.join(", ")}. Cloudflare resolves each environment through the ` +
|
|
192
|
+
`"{env}" substitution in workers[].scriptName, and the GitHub Environment API is read at ` +
|
|
193
|
+
`manifest.environments verbatim, so neither takes a slug map.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
197
|
+
throw new Error(`manifest.environmentSlugs.${surface} must be an object mapping environment -> slug`);
|
|
198
|
+
}
|
|
199
|
+
for (const [environment, slug] of Object.entries(map)) {
|
|
200
|
+
if (!environments.includes(environment)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`manifest.environmentSlugs.${surface} maps "${environment}", absent from manifest.environments`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (typeof slug !== "string" || !slug) {
|
|
206
|
+
throw new Error(`manifest.environmentSlugs.${surface}["${environment}"] must be a non-empty slug string`);
|
|
207
|
+
}
|
|
208
|
+
out[surface][environment] = slug;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the slug one surface uses for a manifest environment. Identity for
|
|
216
|
+
* any environment the manifest does not remap, which is what keeps an absent
|
|
217
|
+
* `environmentSlugs` a no-op for every manifest written before Story #464.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} manifest A parsed manifest.
|
|
220
|
+
* @param {string} surface
|
|
221
|
+
* @param {string} environment
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function resolveSurfaceEnvironment(manifest, surface, environment) {
|
|
225
|
+
return manifest?.environmentSlugs?.[surface]?.[environment] ?? environment;
|
|
226
|
+
}
|
|
227
|
+
|
|
141
228
|
/**
|
|
142
229
|
* Parse and validate a raw manifest object. Throws with an actionable message
|
|
143
230
|
* on any violation — a manifest is authored by hand, so a vague error costs a
|
|
@@ -175,10 +262,12 @@ export function parseManifest(raw) {
|
|
|
175
262
|
throw new Error("manifest.keys must be a non-empty array");
|
|
176
263
|
}
|
|
177
264
|
|
|
265
|
+
const environmentSlugs = normalizeEnvironmentSlugs(raw.environmentSlugs, environments);
|
|
266
|
+
|
|
178
267
|
const seen = new Set();
|
|
179
268
|
const keys = raw.keys.map((entry, i) => validateKeyEntry(entry, i, workers, environments, seen));
|
|
180
269
|
|
|
181
|
-
return { environments: [...environments], workers, keys };
|
|
270
|
+
return { environments: [...environments], environmentSlugs, workers, keys };
|
|
182
271
|
}
|
|
183
272
|
|
|
184
273
|
/**
|
|
@@ -271,6 +360,102 @@ function normalizeGitHubResidency(raw, { at, name, environments }) {
|
|
|
271
360
|
});
|
|
272
361
|
}
|
|
273
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Normalize `infisical` residency to its canonical `{folders: [...]}` form.
|
|
365
|
+
*
|
|
366
|
+
* The field accepts EITHER a single `{folder, environments}` object — the shape
|
|
367
|
+
* every manifest written before Story #464 uses — or `{folders: [...]}` whose
|
|
368
|
+
* entries are a bare folder path or a `{folder, environments}` object, because
|
|
369
|
+
* two residencies that occur in practice cannot be said with one folder:
|
|
370
|
+
*
|
|
371
|
+
* 1. **Multi-folder residency.** A key can legitimately be resident in more
|
|
372
|
+
* than one folder, and folder IMPORTS are what make that normal rather
|
|
373
|
+
* than sloppy: when `/cloudflare` imports `/shared`, a value entering at
|
|
374
|
+
* `/shared` is genuinely read through `/cloudflare` as well. Both
|
|
375
|
+
* statements are true and one field could hold only one, so the same
|
|
376
|
+
* misplacement was counted TWICE — `missing` from the declared folder and
|
|
377
|
+
* `orphan` in the folder that actually held it.
|
|
378
|
+
* 2. **Per-environment residency.** A key can live in a different folder per
|
|
379
|
+
* environment (an operator-held credential that arrives via `/github` in
|
|
380
|
+
* staging only). With no per-entry `environments`, one of the two folders
|
|
381
|
+
* was always wrong.
|
|
382
|
+
*
|
|
383
|
+
* Both authored shapes normalize to one array of `{folder, environments}` with
|
|
384
|
+
* `environments` defaulted and materialized to `manifest.environments`, so
|
|
385
|
+
* `probeInfisical` has exactly one shape to read — the same normalize-at-parse
|
|
386
|
+
* treatment `residency.github` received in Story #459, so there is one
|
|
387
|
+
* precedent for expressive residency rather than two idioms.
|
|
388
|
+
*
|
|
389
|
+
* @param {unknown} raw
|
|
390
|
+
* @param {{at: string, name: string, environments: string[]}} ctx
|
|
391
|
+
* @returns {"unmanaged" | {folders: Array<{folder: string, environments: string[]}>}}
|
|
392
|
+
*/
|
|
393
|
+
function normalizeInfisicalResidency(raw, { at, name, environments }) {
|
|
394
|
+
if (raw === undefined || raw === null || raw === "unmanaged") return "unmanaged";
|
|
395
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
396
|
+
throw new Error(`${at}.infisical must be "unmanaged", {folder, environments} or {folders: [...]} (key ${name})`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const hasFolder = raw.folder !== undefined;
|
|
400
|
+
const hasFolders = raw.folders !== undefined;
|
|
401
|
+
if (hasFolder && hasFolders) {
|
|
402
|
+
throw new Error(`${at}.infisical declares both "folder" and "folders" — use one or the other (key ${name})`);
|
|
403
|
+
}
|
|
404
|
+
if (!hasFolder && !hasFolders) {
|
|
405
|
+
throw new Error(`${at}.infisical must declare "folder" or "folders", or be "unmanaged" (key ${name})`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let authored;
|
|
409
|
+
if (hasFolder) {
|
|
410
|
+
authored = [{ folder: raw.folder, environments: raw.environments, where: `${at}.infisical` }];
|
|
411
|
+
} else {
|
|
412
|
+
if (!Array.isArray(raw.folders) || raw.folders.length === 0) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`${at}.infisical.folders must be a non-empty array — use "unmanaged" when the key does not ` +
|
|
415
|
+
`belong in Infisical (key ${name})`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (raw.environments !== undefined) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`${at}.infisical.environments is meaningful only beside a single "folder" — under "folders" ` +
|
|
421
|
+
`each entry carries its own environments (key ${name})`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
authored = raw.folders.map((folderEntry, j) => {
|
|
425
|
+
const where = `${at}.infisical.folders[${j}]`;
|
|
426
|
+
if (typeof folderEntry === "string") return { folder: folderEntry, environments: undefined, where };
|
|
427
|
+
if (!folderEntry || typeof folderEntry !== "object" || Array.isArray(folderEntry)) {
|
|
428
|
+
throw new Error(`${where} must be a folder path string or {folder, environments} (key ${name})`);
|
|
429
|
+
}
|
|
430
|
+
return { folder: folderEntry.folder, environments: folderEntry.environments, where };
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const seenFolders = new Set();
|
|
435
|
+
const folders = authored.map(({ folder, environments: authoredEnvs, where }) => {
|
|
436
|
+
if (typeof folder !== "string" || !folder) {
|
|
437
|
+
throw new Error(`${where}.folder must be a non-empty folder path string (key ${name})`);
|
|
438
|
+
}
|
|
439
|
+
if (seenFolders.has(folder)) {
|
|
440
|
+
throw new Error(`${at}.infisical repeats the folder "${folder}" — declare one entry per folder (key ${name})`);
|
|
441
|
+
}
|
|
442
|
+
seenFolders.add(folder);
|
|
443
|
+
|
|
444
|
+
const envs = authoredEnvs ?? environments;
|
|
445
|
+
if (!Array.isArray(envs) || !envs.every((e) => typeof e === "string")) {
|
|
446
|
+
throw new Error(`${at}.infisical.environments must be an array of environment slugs (key ${name})`);
|
|
447
|
+
}
|
|
448
|
+
for (const e of envs) {
|
|
449
|
+
if (!environments.includes(e)) {
|
|
450
|
+
throw new Error(`${at}.infisical.environments names "${e}", absent from manifest.environments (key ${name})`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { folder, environments: [...envs] };
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return { folders };
|
|
457
|
+
}
|
|
458
|
+
|
|
274
459
|
/**
|
|
275
460
|
* @param {unknown} entry
|
|
276
461
|
* @param {number} index
|
|
@@ -324,21 +509,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
324
509
|
}
|
|
325
510
|
}
|
|
326
511
|
|
|
327
|
-
const infisical = entry.infisical
|
|
328
|
-
if (infisical !== "unmanaged") {
|
|
329
|
-
if (!infisical || typeof infisical !== "object" || typeof infisical.folder !== "string") {
|
|
330
|
-
throw new Error(`${at}.infisical must be "unmanaged" or {folder, environments} (key ${name})`);
|
|
331
|
-
}
|
|
332
|
-
const envs = infisical.environments ?? environments;
|
|
333
|
-
if (!Array.isArray(envs) || !envs.every((e) => typeof e === "string")) {
|
|
334
|
-
throw new Error(`${at}.infisical.environments must be an array of environment slugs (key ${name})`);
|
|
335
|
-
}
|
|
336
|
-
for (const e of envs) {
|
|
337
|
-
if (!environments.includes(e)) {
|
|
338
|
-
throw new Error(`${at}.infisical.environments names "${e}", absent from manifest.environments (key ${name})`);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
512
|
+
const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
|
|
342
513
|
|
|
343
514
|
if (entry.shape !== undefined) {
|
|
344
515
|
if (typeof entry.shape !== "string" || !Object.hasOwn(SHAPE_VOCABULARY, entry.shape)) {
|
|
@@ -361,10 +532,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
361
532
|
github,
|
|
362
533
|
cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
|
|
363
534
|
},
|
|
364
|
-
infisical
|
|
365
|
-
infisical === "unmanaged"
|
|
366
|
-
? "unmanaged"
|
|
367
|
-
: { folder: infisical.folder, environments: [...(infisical.environments ?? environments)] },
|
|
535
|
+
infisical,
|
|
368
536
|
shape: entry.shape ?? null,
|
|
369
537
|
placeholderPattern: entry.placeholderPattern ?? null,
|
|
370
538
|
note: typeof entry.note === "string" ? entry.note : null,
|
|
@@ -1305,6 +1473,48 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
|
|
|
1305
1473
|
}
|
|
1306
1474
|
}
|
|
1307
1475
|
|
|
1476
|
+
/**
|
|
1477
|
+
* Does a key declare residency at this folder in this environment?
|
|
1478
|
+
*
|
|
1479
|
+
* @param {object} key A parsed manifest key with normalized infisical residency.
|
|
1480
|
+
* @param {string} folder
|
|
1481
|
+
* @param {string} environment
|
|
1482
|
+
* @returns {boolean}
|
|
1483
|
+
*/
|
|
1484
|
+
function residentAt(key, folder, environment) {
|
|
1485
|
+
return key.infisical.folders.some((f) => f.folder === folder && f.environments.includes(environment));
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Names declared in a SIBLING declared folder of the same environment.
|
|
1490
|
+
*
|
|
1491
|
+
* Infisical is probed once per (environment, folder) pair, so a key resident
|
|
1492
|
+
* in two folders is read in two partitions — and reporting it as an orphan in
|
|
1493
|
+
* the one the manifest happens not to be reconciling is the false positive the
|
|
1494
|
+
* `folders` array exists to remove. Suppression is deliberately no wider:
|
|
1495
|
+
*
|
|
1496
|
+
* - **Cross-folder, same environment** is suppressed: that is multi-folder
|
|
1497
|
+
* residency, including the folder-import case.
|
|
1498
|
+
* - **Cross-environment is NOT suppressed.** A key declared for staging only
|
|
1499
|
+
* but present in production is undeclared presence in that environment —
|
|
1500
|
+
* the most interesting thing this surface can find — so it still orphans.
|
|
1501
|
+
*
|
|
1502
|
+
* A name declared in no folder at all orphans exactly as before, which is what
|
|
1503
|
+
* keeps `--strict-orphans` worth enabling once a manifest is correct. This is
|
|
1504
|
+
* the `declaredElsewhere` treatment `probeGitHub` has had since Story #459;
|
|
1505
|
+
* its absence here is why a single misplacement was reported twice.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {object[]} managed
|
|
1508
|
+
* @param {string} folder
|
|
1509
|
+
* @param {string} environment
|
|
1510
|
+
* @returns {string[]}
|
|
1511
|
+
*/
|
|
1512
|
+
function infisicalNamesElsewhere(managed, folder, environment) {
|
|
1513
|
+
return managed
|
|
1514
|
+
.filter((k) => k.infisical.folders.some((f) => f.folder !== folder && f.environments.includes(environment)))
|
|
1515
|
+
.map((k) => k.name);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1308
1518
|
/**
|
|
1309
1519
|
* The Infisical probe, plus the shape stage — the only place a value is read.
|
|
1310
1520
|
*
|
|
@@ -1321,27 +1531,39 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
|
|
|
1321
1531
|
return;
|
|
1322
1532
|
}
|
|
1323
1533
|
try {
|
|
1324
|
-
const folders = new Set(managed.
|
|
1534
|
+
const folders = new Set(managed.flatMap((k) => k.infisical.folders.map((f) => f.folder)));
|
|
1325
1535
|
const shapeChecked = [];
|
|
1536
|
+
// A key resident in several folders is READ in each of them, but its value
|
|
1537
|
+
// is one value — so it earns at most one shape verdict per environment.
|
|
1538
|
+
// Scoring it once per folder would re-introduce, in the shape stage, the
|
|
1539
|
+
// very double-reporting this Story removes from the residency stage.
|
|
1540
|
+
const shapeSeen = new Set();
|
|
1326
1541
|
for (const environment of environments) {
|
|
1542
|
+
// The store's own environment slug, which need not be the deploy name.
|
|
1543
|
+
const slug = resolveSurfaceEnvironment(manifest, "infisical", environment);
|
|
1327
1544
|
for (const folder of folders) {
|
|
1328
|
-
const expected = managed
|
|
1329
|
-
|
|
1330
|
-
.map((k) => k.name);
|
|
1331
|
-
const present = await infisical.listNames({ environment, folder });
|
|
1545
|
+
const expected = managed.filter((k) => residentAt(k, folder, environment)).map((k) => k.name);
|
|
1546
|
+
const present = await infisical.listNames({ environment: slug, folder });
|
|
1332
1547
|
findings.push(
|
|
1333
|
-
...reconcileNames({
|
|
1548
|
+
...reconcileNames({
|
|
1549
|
+
expected,
|
|
1550
|
+
present,
|
|
1551
|
+
surface: "infisical",
|
|
1552
|
+
environment,
|
|
1553
|
+
scope: `folder ${folder}`,
|
|
1554
|
+
declaredElsewhere: infisicalNamesElsewhere(managed, folder, environment),
|
|
1555
|
+
})
|
|
1334
1556
|
);
|
|
1335
1557
|
|
|
1336
1558
|
// Shape stage. Values enter this scope and leave it as verdicts.
|
|
1337
1559
|
const needShape = managed.filter(
|
|
1338
1560
|
(k) =>
|
|
1339
|
-
k
|
|
1340
|
-
k.
|
|
1341
|
-
(k.
|
|
1561
|
+
residentAt(k, folder, environment) &&
|
|
1562
|
+
(k.shape || k.placeholderPattern) &&
|
|
1563
|
+
!shapeSeen.has(`${environment}\u241f${k.name}`)
|
|
1342
1564
|
);
|
|
1343
1565
|
if (needShape.length === 0) continue;
|
|
1344
|
-
const values = await infisical.listValues({ environment, folder });
|
|
1566
|
+
const values = await infisical.listValues({ environment: slug, folder });
|
|
1345
1567
|
for (const key of needShape) {
|
|
1346
1568
|
if (!values.has(key.name)) continue;
|
|
1347
1569
|
const verdict = checkShape({
|
|
@@ -1350,6 +1572,7 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
|
|
|
1350
1572
|
placeholderPattern: key.placeholderPattern,
|
|
1351
1573
|
});
|
|
1352
1574
|
shapeChecked.push(key.name);
|
|
1575
|
+
shapeSeen.add(`${environment}\u241f${key.name}`);
|
|
1353
1576
|
if (!verdict.ok) {
|
|
1354
1577
|
findings.push({
|
|
1355
1578
|
severity: "fail",
|
|
@@ -31,7 +31,9 @@ import { promisify } from "node:util";
|
|
|
31
31
|
import { test } from "node:test";
|
|
32
32
|
|
|
33
33
|
import {
|
|
34
|
+
KEY_SCHEMA,
|
|
34
35
|
MANIFEST_SCHEMA,
|
|
36
|
+
SLUG_MAPPED_SURFACES,
|
|
35
37
|
SHAPE_NAMES,
|
|
36
38
|
SHAPE_VOCABULARY,
|
|
37
39
|
applyExceptions,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
redactUrl,
|
|
50
52
|
renderReport,
|
|
51
53
|
resolveScriptName,
|
|
54
|
+
resolveSurfaceEnvironment,
|
|
52
55
|
runDoctor,
|
|
53
56
|
runOfflineChecks,
|
|
54
57
|
} from "./env-doctor.mjs";
|
|
@@ -1043,6 +1046,388 @@ test("no secret VALUE reaches stdout or stderr on a real run against a mock Infi
|
|
|
1043
1046
|
}
|
|
1044
1047
|
});
|
|
1045
1048
|
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
// Infisical environment slugs and folder residency (Story #464)
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* A manifest with no local residency and no GitHub residency, so every finding
|
|
1055
|
+
* under test comes from the Infisical probe. One Worker is kept so the
|
|
1056
|
+
* Cloudflare surface can be asserted to keep the DEPLOY name while Infisical
|
|
1057
|
+
* is asked for the mapped slug.
|
|
1058
|
+
*/
|
|
1059
|
+
function infisicalOnlyManifest(infisical, { environmentSlugs, keys } = {}) {
|
|
1060
|
+
return parseManifest({
|
|
1061
|
+
environments: ["staging", "production"],
|
|
1062
|
+
...(environmentSlugs ? { environmentSlugs } : {}),
|
|
1063
|
+
workers: { site: { scriptName: "acme-site-{env}" } },
|
|
1064
|
+
keys:
|
|
1065
|
+
keys ??
|
|
1066
|
+
[
|
|
1067
|
+
{
|
|
1068
|
+
name: "SHARED_TOKEN",
|
|
1069
|
+
kind: "secret",
|
|
1070
|
+
sensitivity: "secret",
|
|
1071
|
+
residency: { local: null, github: null, cloudflare: null },
|
|
1072
|
+
infisical,
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Run the Infisical probe against a `{ "<environment-slug><folder>": [names] }`
|
|
1080
|
+
* map, recording every (environment, folder) pair the client was asked for.
|
|
1081
|
+
* The map is keyed by the slug the CLIENT sees, which is the whole point: a
|
|
1082
|
+
* remapped environment must be looked up under its store slug.
|
|
1083
|
+
*/
|
|
1084
|
+
async function infisicalRun({ manifest, present, environments = ["staging", "production"], cloudflare = null }) {
|
|
1085
|
+
const root = makeRepo({});
|
|
1086
|
+
const asked = [];
|
|
1087
|
+
try {
|
|
1088
|
+
const report = await runDoctor({
|
|
1089
|
+
manifest,
|
|
1090
|
+
repoRoot: root,
|
|
1091
|
+
environments,
|
|
1092
|
+
cloudflare,
|
|
1093
|
+
infisical: {
|
|
1094
|
+
listNames: async ({ environment, folder }) => {
|
|
1095
|
+
asked.push(`${environment}${folder}`);
|
|
1096
|
+
return present[`${environment}${folder}`] ?? [];
|
|
1097
|
+
},
|
|
1098
|
+
listValues: async () => new Map(),
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
return { asked, findings: report.findings.filter((f) => f.surface === "infisical"), report };
|
|
1102
|
+
} finally {
|
|
1103
|
+
rmSync(root, { recursive: true, force: true });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
test("a remapped Infisical slug is probed at the slug while Cloudflare keeps the deploy name", async () => {
|
|
1108
|
+
// The motivating case: deploy environments are staging/production, but the
|
|
1109
|
+
// Infisical project's slugs are staging/prod. Before this, `production`
|
|
1110
|
+
// reached Infisical verbatim and 404'd the whole surface into `error`.
|
|
1111
|
+
const scripts = [];
|
|
1112
|
+
const { asked } = await infisicalRun({
|
|
1113
|
+
manifest: infisicalOnlyManifest(
|
|
1114
|
+
{ folder: "/shared" },
|
|
1115
|
+
{ environmentSlugs: { infisical: { production: "prod" } } }
|
|
1116
|
+
),
|
|
1117
|
+
present: { "staging/shared": ["SHARED_TOKEN"], "prod/shared": ["SHARED_TOKEN"] },
|
|
1118
|
+
cloudflare: {
|
|
1119
|
+
secretNames: async (scriptName) => {
|
|
1120
|
+
scripts.push(scriptName);
|
|
1121
|
+
return [];
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
assert.deepEqual(asked, ["staging/shared", "prod/shared"]);
|
|
1127
|
+
assert.ok(!asked.some((a) => a.startsWith("production")), "the deploy name must not reach Infisical");
|
|
1128
|
+
// The Cloudflare surface resolves {env} from the DEPLOY name, unmapped —
|
|
1129
|
+
// which is why it needs no slug map of its own.
|
|
1130
|
+
assert.deepEqual(scripts, []);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("a manifest declaring a Cloudflare secret still resolves {env} from the unmapped deploy name", async () => {
|
|
1134
|
+
const scripts = [];
|
|
1135
|
+
await infisicalRun({
|
|
1136
|
+
manifest: infisicalOnlyManifest(undefined, {
|
|
1137
|
+
environmentSlugs: { infisical: { production: "prod" } },
|
|
1138
|
+
keys: [
|
|
1139
|
+
{
|
|
1140
|
+
name: "SHARED_TOKEN",
|
|
1141
|
+
kind: "secret",
|
|
1142
|
+
sensitivity: "secret",
|
|
1143
|
+
residency: { local: null, github: null, cloudflare: { workers: ["site"], kind: "secret" } },
|
|
1144
|
+
infisical: { folder: "/shared" },
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
}),
|
|
1148
|
+
present: {},
|
|
1149
|
+
cloudflare: {
|
|
1150
|
+
secretNames: async (scriptName) => {
|
|
1151
|
+
scripts.push(scriptName);
|
|
1152
|
+
return ["SHARED_TOKEN"];
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
});
|
|
1156
|
+
assert.deepEqual(scripts, ["acme-site-staging", "acme-site-production"]);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
test("environmentSlugs rejects a surface that is not slug-mapped, and names it", () => {
|
|
1160
|
+
assert.throws(
|
|
1161
|
+
() =>
|
|
1162
|
+
infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { cloudflare: { production: "prod" } } }),
|
|
1163
|
+
(err) => {
|
|
1164
|
+
assert.match(err.message, /"cloudflare" is not a slug-mapped surface/);
|
|
1165
|
+
assert.match(err.message, /infisical/);
|
|
1166
|
+
// The message must say WHY, or the author re-files the same request.
|
|
1167
|
+
assert.match(err.message, /scriptName/);
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("environmentSlugs rejects an unmapped environment and a slug that is not a non-empty string", () => {
|
|
1174
|
+
assert.throws(
|
|
1175
|
+
() => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { preview: "prev" } } }),
|
|
1176
|
+
/maps "preview", absent from manifest.environments/
|
|
1177
|
+
);
|
|
1178
|
+
assert.throws(
|
|
1179
|
+
() => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { production: "" } } }),
|
|
1180
|
+
/must be a non-empty slug string/
|
|
1181
|
+
);
|
|
1182
|
+
assert.throws(
|
|
1183
|
+
() => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { production: 7 } } }),
|
|
1184
|
+
/must be a non-empty slug string/
|
|
1185
|
+
);
|
|
1186
|
+
assert.throws(
|
|
1187
|
+
() => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: ["prod"] } }),
|
|
1188
|
+
/must be an object mapping environment -> slug/
|
|
1189
|
+
);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
test("with no environmentSlugs every environment resolves to itself", async () => {
|
|
1193
|
+
const manifest = infisicalOnlyManifest({ folder: "/shared" });
|
|
1194
|
+
// The container is normalized to a total-but-empty map, so no caller has to
|
|
1195
|
+
// distinguish "absent" from "empty".
|
|
1196
|
+
assert.deepEqual(manifest.environmentSlugs, { infisical: {} });
|
|
1197
|
+
assert.equal(resolveSurfaceEnvironment(manifest, "infisical", "production"), "production");
|
|
1198
|
+
const { asked } = await infisicalRun({ manifest, present: { "staging/shared": ["SHARED_TOKEN"] } });
|
|
1199
|
+
assert.deepEqual(asked, ["staging/shared", "production/shared"]);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test("parseManifest normalizes the single-object infisical form to a one-entry folders array", () => {
|
|
1203
|
+
// The pre-#464 authored shape, parsed: one shape reaches probeInfisical, the
|
|
1204
|
+
// same treatment residency.github received in #459.
|
|
1205
|
+
const m = infisicalOnlyManifest({ folder: "/shared", environments: ["production"] });
|
|
1206
|
+
assert.deepEqual(m.keys[0].infisical, { folders: [{ folder: "/shared", environments: ["production"] }] });
|
|
1207
|
+
|
|
1208
|
+
const defaulted = infisicalOnlyManifest({ folder: "/shared" });
|
|
1209
|
+
assert.deepEqual(defaulted.keys[0].infisical, {
|
|
1210
|
+
folders: [{ folder: "/shared", environments: ["staging", "production"] }],
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test("the folders array accepts bare paths and per-entry environments", () => {
|
|
1215
|
+
const m = infisicalOnlyManifest({
|
|
1216
|
+
folders: ["/shared", { folder: "/github", environments: ["staging"] }],
|
|
1217
|
+
});
|
|
1218
|
+
assert.deepEqual(m.keys[0].infisical, {
|
|
1219
|
+
folders: [
|
|
1220
|
+
{ folder: "/shared", environments: ["staging", "production"] },
|
|
1221
|
+
{ folder: "/github", environments: ["staging"] },
|
|
1222
|
+
],
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test("a key resident in two folders and present in both reports no finding", async () => {
|
|
1227
|
+
// The folder-import case: /cloudflare imports /shared, so the value is
|
|
1228
|
+
// genuinely readable through both. Both statements are true.
|
|
1229
|
+
const { findings } = await infisicalRun({
|
|
1230
|
+
manifest: infisicalOnlyManifest({ folders: ["/shared", "/cloudflare"] }),
|
|
1231
|
+
present: {
|
|
1232
|
+
"staging/shared": ["SHARED_TOKEN"],
|
|
1233
|
+
"staging/cloudflare": ["SHARED_TOKEN"],
|
|
1234
|
+
"production/shared": ["SHARED_TOKEN"],
|
|
1235
|
+
"production/cloudflare": ["SHARED_TOKEN"],
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
assert.deepEqual(findings, []);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test("a misplacement across two declared folders is reported ONCE, as the missing", async () => {
|
|
1242
|
+
// Declared in /shared, actually resident in /cloudflare. Before #464 this
|
|
1243
|
+
// was two findings for one fact — a missing AND an orphan — and the orphan
|
|
1244
|
+
// was unsuppressable by an exception, so --strict-orphans could never go
|
|
1245
|
+
// green on a manifest that was merely imprecise about placement.
|
|
1246
|
+
const { findings } = await infisicalRun({
|
|
1247
|
+
manifest: infisicalOnlyManifest({ folders: ["/shared", "/cloudflare"] }),
|
|
1248
|
+
present: {
|
|
1249
|
+
"staging/cloudflare": ["SHARED_TOKEN"],
|
|
1250
|
+
"production/cloudflare": ["SHARED_TOKEN"],
|
|
1251
|
+
},
|
|
1252
|
+
environments: ["staging"],
|
|
1253
|
+
});
|
|
1254
|
+
assert.equal(findings.length, 1);
|
|
1255
|
+
assert.equal(findings[0].kind, "missing");
|
|
1256
|
+
assert.equal(findings[0].key, "SHARED_TOKEN");
|
|
1257
|
+
assert.match(findings[0].detail, /folder \/shared/);
|
|
1258
|
+
assert.equal(
|
|
1259
|
+
findings.filter((f) => f.kind === "orphan").length,
|
|
1260
|
+
0,
|
|
1261
|
+
"the sibling declared folder must not also orphan the same key"
|
|
1262
|
+
);
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test("suppression does not cross environment — a staging-only key found in production orphans", async () => {
|
|
1266
|
+
// SHARED_TOKEN is declared in /github for staging only. Finding it in
|
|
1267
|
+
// /shared in production is undeclared presence in that environment, which
|
|
1268
|
+
// is the most interesting thing this surface can report.
|
|
1269
|
+
const { findings } = await infisicalRun({
|
|
1270
|
+
manifest: infisicalOnlyManifest({
|
|
1271
|
+
folders: [{ folder: "/github", environments: ["staging"] }, { folder: "/shared", environments: ["staging"] }],
|
|
1272
|
+
}),
|
|
1273
|
+
present: {
|
|
1274
|
+
"staging/github": ["SHARED_TOKEN"],
|
|
1275
|
+
"staging/shared": ["SHARED_TOKEN"],
|
|
1276
|
+
"production/shared": ["SHARED_TOKEN"],
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
assert.equal(findings.length, 1);
|
|
1280
|
+
assert.equal(findings[0].kind, "orphan");
|
|
1281
|
+
assert.equal(findings[0].environment, "production");
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("a per-environment folder entry reports no missing for an environment it never names", async () => {
|
|
1285
|
+
const { findings } = await infisicalRun({
|
|
1286
|
+
manifest: infisicalOnlyManifest({ folders: [{ folder: "/operator", environments: ["production"] }] }),
|
|
1287
|
+
present: { "production/operator": ["SHARED_TOKEN"] },
|
|
1288
|
+
});
|
|
1289
|
+
assert.deepEqual(findings, []);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
test("infisical folder residency fails closed on every malformed shape", () => {
|
|
1293
|
+
const bad = (infisical) => () => infisicalOnlyManifest(infisical);
|
|
1294
|
+
assert.throws(bad({ folders: [] }), /folders must be a non-empty array/);
|
|
1295
|
+
assert.throws(bad({ folders: ["/shared", "/shared"] }), /repeats the folder "\/shared"/);
|
|
1296
|
+
assert.throws(bad({ folders: [{ folder: "/s", environments: ["preview"] }] }), /absent from manifest.environments/);
|
|
1297
|
+
assert.throws(bad({ folder: "/s", folders: ["/t"] }), /declares both "folder" and "folders"/);
|
|
1298
|
+
assert.throws(bad({ folders: ["/s"], environments: ["staging"] }), /meaningful only beside a single "folder"/);
|
|
1299
|
+
assert.throws(bad({}), /must declare "folder" or "folders"/);
|
|
1300
|
+
assert.throws(bad({ folders: [""] }), /must be a non-empty folder path string/);
|
|
1301
|
+
assert.throws(bad({ folders: [42] }), /must be a folder path string or \{folder, environments\}/);
|
|
1302
|
+
assert.throws(bad("nope"), /must be "unmanaged", \{folder, environments\} or \{folders: \[\.\.\.\]\}/);
|
|
1303
|
+
// Unchanged from before #464: the single-object form's own env validation.
|
|
1304
|
+
assert.throws(bad({ folder: "/s", environments: ["preview"] }), /absent from manifest.environments/);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
test("a multi-folder key with a shape earns ONE verdict per environment, not one per folder", async () => {
|
|
1308
|
+
// A key resident in two folders is READ twice, but it is one value — so
|
|
1309
|
+
// scoring it per folder would re-introduce double-reporting in the shape
|
|
1310
|
+
// stage, the very defect #464 removes from the residency stage.
|
|
1311
|
+
const root = makeRepo({});
|
|
1312
|
+
try {
|
|
1313
|
+
const manifest = infisicalOnlyManifest(undefined, {
|
|
1314
|
+
keys: [
|
|
1315
|
+
{
|
|
1316
|
+
name: "SHARED_TOKEN",
|
|
1317
|
+
kind: "var",
|
|
1318
|
+
sensitivity: "public",
|
|
1319
|
+
residency: { local: null, github: null, cloudflare: null },
|
|
1320
|
+
infisical: { folders: ["/shared", "/cloudflare"] },
|
|
1321
|
+
shape: "url",
|
|
1322
|
+
},
|
|
1323
|
+
],
|
|
1324
|
+
});
|
|
1325
|
+
const report = await runDoctor({
|
|
1326
|
+
manifest,
|
|
1327
|
+
repoRoot: root,
|
|
1328
|
+
environments: ["staging"],
|
|
1329
|
+
infisical: {
|
|
1330
|
+
listNames: async () => ["SHARED_TOKEN"],
|
|
1331
|
+
listValues: async () => new Map([["SHARED_TOKEN", "example.test"]]),
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
const shapeFails = report.findings.filter((f) => f.kind === "shape-fail");
|
|
1335
|
+
assert.equal(shapeFails.length, 1, "one value, one verdict");
|
|
1336
|
+
assert.equal(shapeFails[0].key, "SHARED_TOKEN");
|
|
1337
|
+
} finally {
|
|
1338
|
+
rmSync(root, { recursive: true, force: true });
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
test("MANIFEST_SCHEMA and KEY_SCHEMA describe the slug container and the folders array", () => {
|
|
1343
|
+
assert.ok(Object.hasOwn(MANIFEST_SCHEMA, "environmentSlugs"));
|
|
1344
|
+
assert.match(MANIFEST_SCHEMA.environmentSlugs, /infisical/);
|
|
1345
|
+
assert.match(KEY_SCHEMA.infisical, /folders/);
|
|
1346
|
+
assert.deepEqual(SLUG_MAPPED_SURFACES, ["infisical"]);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
test("the documented manifest schema block names the new shapes", () => {
|
|
1350
|
+
// The script exports the schema so the doc and the code describe one shape;
|
|
1351
|
+
// this asserts the DOC kept its half of that bargain.
|
|
1352
|
+
const doc = readFileSync(join(HERE, "..", "docs", "reusable-workflows.md"), "utf8");
|
|
1353
|
+
assert.match(doc, /"environmentSlugs"/);
|
|
1354
|
+
assert.match(doc, /"folders"/);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
test("no secret VALUE reaches stdout or stderr through the remapped-slug, multi-folder path", async () => {
|
|
1358
|
+
// The values-safety guarantee, re-asserted over the shapes #464 adds: a
|
|
1359
|
+
// remapped environment slug and a key resident in two folders. Same
|
|
1360
|
+
// low-entropy dictionary canaries as the sibling leak test, for the same
|
|
1361
|
+
// reason (a key-shaped fixture is a true positive for gitleaks).
|
|
1362
|
+
const INJECTED = {
|
|
1363
|
+
PUBLIC_SITE_URL: "example.test/no-scheme-here",
|
|
1364
|
+
TURSO_AUTH_TOKEN: "second-canary-that-must-never-be-printed",
|
|
1365
|
+
};
|
|
1366
|
+
const requested = [];
|
|
1367
|
+
|
|
1368
|
+
const server = createServer((req, res) => {
|
|
1369
|
+
if (req.method === "POST" && req.url.startsWith("/api/v1/auth/universal-auth/login")) {
|
|
1370
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1371
|
+
res.end(JSON.stringify({ accessToken: "mock-token", expiresIn: 3600, tokenType: "Bearer" }));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (req.url.startsWith("/api/v4/secrets")) {
|
|
1375
|
+
const params = new URL(req.url, "http://localhost").searchParams;
|
|
1376
|
+
requested.push(`${params.get("environment")}${params.get("secretPath")}`);
|
|
1377
|
+
const withValues = params.get("viewSecretValue") === "true";
|
|
1378
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1379
|
+
res.end(
|
|
1380
|
+
JSON.stringify({
|
|
1381
|
+
secrets: Object.entries(INJECTED).map(([secretKey, secretValue]) => ({
|
|
1382
|
+
secretKey,
|
|
1383
|
+
...(withValues ? { secretValue } : {}),
|
|
1384
|
+
})),
|
|
1385
|
+
})
|
|
1386
|
+
);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
res.writeHead(404).end("{}");
|
|
1390
|
+
});
|
|
1391
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
1392
|
+
const site = `http://127.0.0.1:${server.address().port}`;
|
|
1393
|
+
|
|
1394
|
+
const raw = singleWorkerManifest();
|
|
1395
|
+
raw.environmentSlugs = { infisical: { production: "prod" } };
|
|
1396
|
+
for (const key of raw.keys) key.infisical = { folders: ["/", "/shared"] };
|
|
1397
|
+
|
|
1398
|
+
const root = makeRepo(CONSISTENT_REPO);
|
|
1399
|
+
const manifestPath = join(root, "env.manifest.json");
|
|
1400
|
+
writeFileSync(manifestPath, JSON.stringify(raw));
|
|
1401
|
+
|
|
1402
|
+
try {
|
|
1403
|
+
const run = await runCli(
|
|
1404
|
+
[
|
|
1405
|
+
"--manifest", manifestPath,
|
|
1406
|
+
"--repo-root", root,
|
|
1407
|
+
"--environments", "production",
|
|
1408
|
+
"--infisical-project", "proj-1",
|
|
1409
|
+
"--infisical-site", site,
|
|
1410
|
+
"--json",
|
|
1411
|
+
],
|
|
1412
|
+
{ env: { INFISICAL_CLIENT_ID: "id", INFISICAL_CLIENT_SECRET: "sec", ENV_DRIFT_GITHUB_TOKEN: "", CLOUDFLARE_API_TOKEN: "" } }
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1415
|
+
// End-to-end proof that the slug reaches the wire through the real CLI:
|
|
1416
|
+
// every request names `prod`, never the `production` deploy name.
|
|
1417
|
+
assert.ok(requested.length > 0, "the mock store should have been asked for something");
|
|
1418
|
+
assert.deepEqual([...new Set(requested.map((r) => r.split("/")[0]))], ["prod"]);
|
|
1419
|
+
|
|
1420
|
+
const captured = run.stdout + run.stderr;
|
|
1421
|
+
assert.ok(captured.includes("PUBLIC_SITE_URL"), "the failing key's NAME should be reported");
|
|
1422
|
+
for (const [name, value] of Object.entries(INJECTED)) {
|
|
1423
|
+
assert.ok(!captured.includes(value), `the VALUE of ${name} leaked into the doctor's output`);
|
|
1424
|
+
}
|
|
1425
|
+
} finally {
|
|
1426
|
+
rmSync(root, { recursive: true, force: true });
|
|
1427
|
+
await new Promise((r) => server.close(r));
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1046
1431
|
// ---------------------------------------------------------------------------
|
|
1047
1432
|
// Shipped workflow shape
|
|
1048
1433
|
// ---------------------------------------------------------------------------
|