Skip to content

Commit 5f7ebeb

Browse files
tyaginidhiclaude
andcommitted
Fix two readSettingsFile regressions surfaced by Copilot review
1. Dedupe loss in validateSettings → readSettingsFile path readSettingsFile dedupes by schemaName when no stageLabel filter is provided. This is correct for callers like verify-env-var-values that want a single "configured value of X" per schema. But it is WRONG for validate-deployment-settings, which must inspect every stage's value independently: - Staging: c311_api_secret = "https://kv.../secrets/api-secret" (valid) - Production: c311_api_secret = "@KeyVault(vaultName=...)" (invalid) Pre-fix, dedupe kept Staging's value and Production's broken value was silently skipped. validateSettings would report invalid: 0 even though the deploy would fail at ImportAsHolding on Production. Fix: added a `preserveAllStages` option to readSettingsFile. validate-deployment-settings now calls it with that flag set so every per-stage entry is inspected. 2. Mixed-case `Stages` key when value is an object Comment said "either casing of stages/Stages key" but code only checked `parsed.stages || parsed.STAGES`. A hand-authored file using `Stages: { ... }` (capital-S + object form — falls through shape 2's array check because the value isn't an array) returned 0 entries. Fix: added `parsed.Stages` to the fallback chain. All three casings (`stages` / `Stages` / `STAGES`) now resolve to the object-shape path when the value is a plain object. 3. Regression tests (+3, total 996/996) - Mixed-case `Stages` object is read correctly. - readSettingsFile dedupe (default) vs preserveAllStages behavior pinned with concrete expectations. - End-to-end: validateSettings catches a Production-only invalid value even when Staging is valid (the dedupe-loss scenario). Verified against real Citizens portal site — still correctly catches the @KeyVault(...) placeholder on c311_api_secret. Lint: 0 findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 371a6e1 commit 5f7ebeb

3 files changed

Lines changed: 138 additions & 13 deletions

File tree

plugins/power-pages/scripts/lib/validate-deployment-settings.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,19 @@ function parseArgs(argv) {
327327
}
328328

329329
// Entry reader: delegates to verify-env-var-values.js#readSettingsFile,
330-
// which handles all three deployment-settings.json shapes and now returns
331-
// `{ schemaName, value, stageLabel }` on each entry (so we don't need a
332-
// duplicate parser to preserve stage attribution).
330+
// which handles all three deployment-settings.json shapes and returns
331+
// `{ schemaName, value, stageLabel }` on each entry.
332+
//
333+
// `preserveAllStages: true` is critical here — without it, the default
334+
// readSettingsFile path dedupes by schemaName (keeping only the first
335+
// stage's value for each env var). For VALIDATION we must inspect every
336+
// stage's value independently: the same schema can be valid in Staging
337+
// and invalid in Production, and the validator must catch both.
333338
async function validateSettings({ settingsFile, envUrl, stageLabel, token }) {
334339
if (!settingsFile) throw new Error('--settingsFile is required');
335-
const entries = readSettingsFile(settingsFile, stageLabel);
340+
const entries = readSettingsFile(settingsFile, stageLabel, {
341+
preserveAllStages: true,
342+
});
336343

337344
// Collect unique schema names for the type lookup pass.
338345
const uniqueSchemas = Array.from(new Set(entries.map((e) => e.schemaName).filter(Boolean)));

plugins/power-pages/scripts/lib/verify-env-var-values.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,22 @@ function parseArgs(argv) {
9696
// consumed by Power Platform Pipelines — see schemas.microsoft.com/
9797
// power-platform/deployment-settings/2024. Discovered as a real-world gap
9898
// against the Citizens portal site, 2026-05-26.)
99-
// Returns `[{ schemaName, value, stageLabel }]` filtered to stageLabel when provided.
100-
// stageLabel is included on each entry for shape 2/3; shape 1 sets stageLabel: null.
101-
function readSettingsFile(filePath, stageLabel) {
99+
//
100+
// Options:
101+
// stageLabel — narrows the result to a single stage's entries (matched
102+
// case-insensitively against `Name` for shape 2 or the
103+
// keyed-object's key for shape 3).
104+
// preserveAllStages — when true, returns EVERY entry verbatim without
105+
// dedupe — critical for validators that must inspect
106+
// each stage's value independently. Default false
107+
// (matches the old single-source-of-truth semantics for
108+
// legacy callers like verify-env-var-values that report
109+
// a single value per schema).
110+
//
111+
// Returns `[{ schemaName, value, stageLabel }]`.
112+
// stageLabel is the stage name for shape 2/3 entries; null for shape 1.
113+
function readSettingsFile(filePath, stageLabel, options = {}) {
114+
const preserveAllStages = options.preserveAllStages === true;
102115
let raw;
103116
try {
104117
raw = fs.readFileSync(filePath, 'utf8');
@@ -129,7 +142,7 @@ function readSettingsFile(filePath, stageLabel) {
129142
}
130143
}
131144
}
132-
return dedupeBySchemaName(all);
145+
return preserveAllStages ? all : dedupeBySchemaName(all);
133146
}
134147
const stage = parsed.Stages.find(
135148
(s) => (s.Name || '').toLowerCase() === lowerLabel
@@ -142,11 +155,15 @@ function readSettingsFile(filePath, stageLabel) {
142155
}));
143156
}
144157

145-
// Shape 3: per-stage keyed object (`stages: { "<name>": {...} }`).
158+
// Shape 3: per-stage keyed object (e.g. `stages: { "<name>": {...} }`).
146159
// This is the schema emitted by configure-env-variables and the one
147-
// Power Platform Pipelines actually accepts. Accept either casing of
148-
// the `stages`/`Stages` key + match stage labels case-insensitively.
149-
const stagesObj = parsed.stages || parsed.STAGES;
160+
// Power Platform Pipelines actually accepts. Accept any common casing of
161+
// the top-level key (`stages`, `Stages`, `STAGES`) so hand-authored files
162+
// with mixed casing still resolve. (The shape 2 array check above only
163+
// matches when `parsed.Stages` is an Array; if it's a plain object, this
164+
// branch picks it up.)
165+
const stagesObj =
166+
parsed.stages || parsed.Stages || parsed.STAGES || null;
150167
if (
151168
stagesObj &&
152169
typeof stagesObj === 'object' &&
@@ -165,7 +182,7 @@ function readSettingsFile(filePath, stageLabel) {
165182
}
166183
}
167184
}
168-
return dedupeBySchemaName(all);
185+
return preserveAllStages ? all : dedupeBySchemaName(all);
169186
}
170187
// Case-insensitive key match
171188
const matchKey = Object.keys(stagesObj).find(

plugins/power-pages/scripts/tests/verify-env-var-values.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,107 @@ test('readSettingsFile with no stageLabel flattens Stages[]', async (t) => {
153153
assert.equal(entries[1].schemaName, 'foo_b');
154154
});
155155

156+
test('readSettingsFile accepts mixed-case `Stages` key when it is an object (regression — Copilot review)', async (t) => {
157+
// Shape 2's array check handles `Stages: []`; shape 3's object check
158+
// must handle `stages` / `Stages` / `STAGES` when the value is a plain
159+
// object. Earlier code only checked `parsed.stages || parsed.STAGES`,
160+
// so a hand-authored file with `Stages: { ... }` (mixed case + object)
161+
// returned 0 entries.
162+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-env-'));
163+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
164+
const file = path.join(dir, 'deployment-settings.json');
165+
fs.writeFileSync(
166+
file,
167+
JSON.stringify({
168+
Stages: {
169+
Staging: { EnvironmentVariables: [{ SchemaName: 'foo_a', Value: 'sv' }] },
170+
Production: { EnvironmentVariables: [{ SchemaName: 'foo_b', Value: 'pv' }] },
171+
},
172+
})
173+
);
174+
const all = readSettingsFile(file);
175+
// Without preserveAllStages, distinct schemas → no dedupe collision.
176+
assert.equal(all.length, 2);
177+
assert.equal(all[0].stageLabel, 'Staging');
178+
assert.equal(all[1].stageLabel, 'Production');
179+
});
180+
181+
test('readSettingsFile dedupes by schemaName by default; preserveAllStages keeps per-stage entries (regression — Copilot review)', async (t) => {
182+
// Real-world case: same env var ships under multiple stages with
183+
// different per-stage values. The default (dedupe) is correct for
184+
// "tell me the configured value of X" callers, but VALIDATION must
185+
// see every stage's value to catch a Staging-valid / Production-invalid
186+
// mismatch.
187+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-env-'));
188+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
189+
const file = path.join(dir, 'deployment-settings.json');
190+
fs.writeFileSync(
191+
file,
192+
JSON.stringify({
193+
stages: {
194+
Staging: {
195+
EnvironmentVariables: [
196+
{ SchemaName: 'c311_api_secret', Value: 'https://kv.vault.azure.net/secrets/staging-secret' },
197+
],
198+
},
199+
Production: {
200+
EnvironmentVariables: [
201+
{ SchemaName: 'c311_api_secret', Value: '@KeyVault(vaultName=prod-kv;secretName=secret)' },
202+
],
203+
},
204+
},
205+
})
206+
);
207+
// Default: dedupe — caller sees one value for c311_api_secret.
208+
const deduped = readSettingsFile(file);
209+
assert.equal(deduped.length, 1);
210+
// preserveAllStages: every stage's entry preserved.
211+
const all = readSettingsFile(file, null, { preserveAllStages: true });
212+
assert.equal(all.length, 2);
213+
const stages = all.map((e) => e.stageLabel).sort();
214+
assert.deepEqual(stages, ['Production', 'Staging']);
215+
});
216+
217+
test('validateSettings catches a Production-only invalid value even when Staging is valid (regression — Copilot review)', async (t) => {
218+
// The end-to-end shape of the bug Copilot flagged: validateSettings
219+
// delegates to readSettingsFile, which deduped by schemaName when the
220+
// same schema appeared in multiple stages. With dedupe, the Staging
221+
// (valid) value won and the Production (invalid) value was silently
222+
// skipped. validateSettings now passes preserveAllStages: true so
223+
// both entries are inspected.
224+
const { validateSettings } = require('../lib/validate-deployment-settings');
225+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-env-'));
226+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
227+
const file = path.join(dir, 'deployment-settings.json');
228+
fs.writeFileSync(
229+
file,
230+
JSON.stringify({
231+
stages: {
232+
Staging: {
233+
EnvironmentVariables: [
234+
// Valid canonical Key Vault URI
235+
{ SchemaName: 'c311_api_secret', Value: 'https://lakeshore-kv.vault.azure.net/secrets/api-secret' },
236+
],
237+
},
238+
Production: {
239+
EnvironmentVariables: [
240+
// Same schema name; the broken @KeyVault(...) placeholder format
241+
{ SchemaName: 'c311_api_secret', Value: '@KeyVault(vaultName=prod-kv;secretName=api-secret)' },
242+
],
243+
},
244+
},
245+
})
246+
);
247+
const result = await validateSettings({ settingsFile: file });
248+
assert.equal(result.summary.total, 2, 'validator must inspect both stages, not just the first');
249+
const prodFinding = result.findings.find(
250+
(f) => f.stageLabel === 'Production' && f.schemaName === 'c311_api_secret'
251+
);
252+
assert.ok(prodFinding, 'expected Production-stage finding to be present');
253+
assert.equal(prodFinding.valueFormat, 'kv-placeholder');
254+
assert.equal(prodFinding.status, 'invalid');
255+
});
256+
156257
test('readSettingsFile reads keyed-object Stages shape (Microsoft schema 2024)', async (t) => {
157258
// The Microsoft-standard `deployment-settings/2024` schema and the file
158259
// configure-env-variables emits use a KEYED OBJECT for stages, not an

0 commit comments

Comments
 (0)