-
Notifications
You must be signed in to change notification settings - Fork 0
fix: address PR #84 alignment follow-ups (manifest reference, env validation, Netlify context preservation) #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { InfisicalSDK } from "@infisical/sdk"; | ||
| import { pathToFileURL } from "node:url"; | ||
| import { ALIAS_GROUPS, P0_KEYS, P1_KEYS } from "./parity-manifest.js"; | ||
|
|
||
| const INFISICAL_PROJECT_ID = "6c7646e9-04dd-484a-a5d1-612b9582da15"; | ||
|
|
@@ -15,11 +16,18 @@ const CANONICAL_VALUES: Record<string, string> = { | |
| NOTION_DRIFT_REPORT_DB_ID: "398bc9d7494c819494cfdb5b41de8f6d", | ||
| }; | ||
|
|
||
| interface NetlifyEnvVarValue { | ||
| context?: string; | ||
| /** Branch name when `context` is "branch"/"branch-deploy" overrides for a specific branch. */ | ||
| context_parameter?: string; | ||
| value?: string; | ||
| } | ||
|
|
||
| interface NetlifyEnvVar { | ||
| id?: string; | ||
| key?: string; | ||
| scopes?: string[]; | ||
| values?: { context?: string; value?: string }[]; | ||
| values?: NetlifyEnvVarValue[]; | ||
| } | ||
|
|
||
| function resolveAlias(key: string, keys: Set<string>): string | null { | ||
|
|
@@ -108,7 +116,34 @@ async function upsertInfisicalSecret( | |
| console.log(` + Infisical created ${key}`); | ||
| } | ||
|
|
||
| async function upsertNetlifySecret( | ||
| /** | ||
| * Builds the full `values` array to send to Netlify for a secret update. | ||
| * | ||
| * Netlify's env-var PATCH/POST bodies replace the entire `values` array, so | ||
| * naively sending a single `{ context, value }` pair — as this used to do by | ||
| * reading only `existing.values[0]` — silently deletes any other deployment | ||
| * context overrides (Preview, Branch deploys) the variable already had. | ||
| * Instead, preserve every existing context (and its `context_parameter`, | ||
| * used for a specific branch override) and apply the new value to each one. | ||
| */ | ||
| export function buildNetlifyValuesPayload( | ||
| value: string, | ||
| existingValues: NetlifyEnvVarValue[] | undefined | ||
| ): NetlifyEnvVarValue[] { | ||
| if (!existingValues || existingValues.length === 0) { | ||
| return [{ context: "all", value }]; | ||
| } | ||
|
|
||
| return existingValues.map((entry) => ({ | ||
| context: entry.context ?? "all", | ||
| ...(entry.context_parameter !== undefined | ||
| ? { context_parameter: entry.context_parameter } | ||
| : {}), | ||
| value, | ||
| })); | ||
| } | ||
|
|
||
| export async function upsertNetlifySecret( | ||
| authToken: string, | ||
| accountId: string, | ||
| siteId: string, | ||
|
|
@@ -117,15 +152,15 @@ async function upsertNetlifySecret( | |
| existing: NetlifyEnvVar | undefined | ||
| ): Promise<void> { | ||
| const scopes = existing?.scopes ?? ["builds", "functions", "runtime"]; | ||
| const context = existing?.values?.[0]?.context ?? "all"; | ||
| const values = buildNetlifyValuesPayload(value, existing?.values); | ||
| const siteScopedEnvUrl = | ||
| `https://api.netlify.com/api/v1/accounts/${accountId}/env/${encodeURIComponent(key)}?site_id=${siteId}`; | ||
| const headers = { | ||
| Authorization: `Bearer ${authToken}`, | ||
| Accept: "application/json", | ||
| "Content-Type": "application/json", | ||
| }; | ||
| const valuePayload = { values: [{ context, value }] }; | ||
| const valuePayload = { values }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Existing Netlify variables cannot update When a key exists, Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| const patchExisting = async (): Promise<Response> => | ||
| fetch(siteScopedEnvUrl, { | ||
|
|
@@ -139,11 +174,12 @@ async function upsertNetlifySecret( | |
| if (!res.ok) { | ||
| throw new Error(`Netlify update ${key} failed: ${res.status} ${await res.text()}`); | ||
| } | ||
| console.log(` ↻ Netlify updated ${key}`); | ||
| const contexts = values.map((entry) => entry.context ?? "all").join(", "); | ||
| console.log(` ↻ Netlify updated ${key} (preserved contexts: ${contexts})`); | ||
| return; | ||
| } | ||
|
|
||
| const envVar = { key, scopes, values: [{ context, value }] }; | ||
| const envVar = { key, scopes, values }; | ||
| const createRes = await fetch( | ||
| `https://api.netlify.com/api/v1/accounts/${accountId}/env?site_id=${siteId}`, | ||
| { | ||
|
|
@@ -253,7 +289,13 @@ async function main(): Promise<void> { | |
| console.log("[sync] Done. Re-run npm run drift-check to verify."); | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| console.error(error); | ||
| process.exit(1); | ||
| }); | ||
| const isDirectExecution = | ||
| Boolean(process.argv[1]) && | ||
| import.meta.url === pathToFileURL(process.argv[1]!).href; | ||
|
|
||
| if (isDirectExecution) { | ||
| main().catch((error) => { | ||
| console.error(error); | ||
| process.exit(1); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** Unit tests for drift-check helpers (no live Netlify/Infisical calls). */ | ||
| import assert from "node:assert/strict"; | ||
| import { requireEnv } from "./drift-check.js"; | ||
|
|
||
| function test(name: string, fn: () => void): void { | ||
| try { | ||
| fn(); | ||
| console.log(`✅ ${name}`); | ||
| } catch (error) { | ||
| console.error(`❌ ${name}`); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| function withEnv(name: string, value: string | undefined, fn: () => void): void { | ||
| const previous = process.env[name]; | ||
| if (value === undefined) delete process.env[name]; | ||
| else process.env[name] = value; | ||
| try { | ||
| fn(); | ||
| } finally { | ||
| if (previous === undefined) delete process.env[name]; | ||
| else process.env[name] = previous; | ||
| } | ||
| } | ||
|
|
||
| test("requireEnv returns the trimmed value when set", () => { | ||
| withEnv("TEST_DRIFT_CHECK_VAR", " a-real-token ", () => { | ||
| assert.equal(requireEnv("TEST_DRIFT_CHECK_VAR"), "a-real-token"); | ||
| }); | ||
| }); | ||
|
|
||
| test("requireEnv rejects a missing variable", () => { | ||
| withEnv("TEST_DRIFT_CHECK_MISSING", undefined, () => { | ||
| assert.throws( | ||
| () => requireEnv("TEST_DRIFT_CHECK_MISSING"), | ||
| /TEST_DRIFT_CHECK_MISSING is not set/ | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| test("requireEnv rejects an empty string", () => { | ||
| withEnv("TEST_DRIFT_CHECK_EMPTY", "", () => { | ||
| assert.throws( | ||
| () => requireEnv("TEST_DRIFT_CHECK_EMPTY"), | ||
| /TEST_DRIFT_CHECK_EMPTY is not set/ | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| test("requireEnv rejects a whitespace-only string", () => { | ||
| withEnv("TEST_DRIFT_CHECK_WHITESPACE", " \t\n ", () => { | ||
| assert.throws( | ||
| () => requireEnv("TEST_DRIFT_CHECK_WHITESPACE"), | ||
| /TEST_DRIFT_CHECK_WHITESPACE is not set/ | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| console.log("All drift-check helper tests passed."); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /** Unit tests for sync-drift-keys helpers (no live Netlify/Infisical calls). */ | ||
| import assert from "node:assert/strict"; | ||
| import { buildNetlifyValuesPayload, upsertNetlifySecret } from "./sync-drift-keys.js"; | ||
|
|
||
| async function test(name: string, fn: () => Promise<void> | void): Promise<void> { | ||
| try { | ||
| await fn(); | ||
| console.log(`✅ ${name}`); | ||
| } catch (error) { | ||
| console.error(`❌ ${name}`); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| interface RecordedCall { | ||
| url: string; | ||
| method?: string; | ||
| body?: unknown; | ||
| } | ||
|
|
||
| async function withMockFetch<T>( | ||
| handler: (url: string, init?: RequestInit) => Response | Promise<Response>, | ||
| fn: () => Promise<T> | ||
| ): Promise<T> { | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => | ||
| handler(String(input), init)) as typeof fetch; | ||
| try { | ||
| return await fn(); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| await test("buildNetlifyValuesPayload defaults to a single 'all' context when none exists", () => { | ||
| assert.deepEqual(buildNetlifyValuesPayload("v1", undefined), [ | ||
| { context: "all", value: "v1" }, | ||
| ]); | ||
| assert.deepEqual(buildNetlifyValuesPayload("v1", []), [ | ||
| { context: "all", value: "v1" }, | ||
| ]); | ||
| }); | ||
|
|
||
| await test("buildNetlifyValuesPayload preserves every existing context, including branch overrides", () => { | ||
| const result = buildNetlifyValuesPayload("new-value", [ | ||
| { context: "production", value: "old-prod" }, | ||
| { context: "deploy-preview", value: "old-preview" }, | ||
| { context: "branch-deploy", context_parameter: "staging", value: "old-branch" }, | ||
| ]); | ||
|
|
||
| assert.deepEqual(result, [ | ||
| { context: "production", value: "new-value" }, | ||
| { context: "deploy-preview", value: "new-value" }, | ||
| { context: "branch-deploy", context_parameter: "staging", value: "new-value" }, | ||
| ]); | ||
| }); | ||
|
|
||
| await test("upsertNetlifySecret PATCHes all existing contexts instead of clobbering them", async () => { | ||
| const calls: RecordedCall[] = []; | ||
|
|
||
| await withMockFetch( | ||
| (url, init) => { | ||
| calls.push({ | ||
| url, | ||
| method: init?.method, | ||
| body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, | ||
| }); | ||
| return new Response(JSON.stringify({}), { status: 200 }); | ||
| }, | ||
| () => | ||
| upsertNetlifySecret("token", "account-1", "site-1", "SUPABASE_URL", "new-value", { | ||
| key: "SUPABASE_URL", | ||
| scopes: ["builds", "functions", "runtime"], | ||
| values: [ | ||
| { context: "production", value: "old-prod" }, | ||
| { context: "deploy-preview", value: "old-preview" }, | ||
| { context: "branch-deploy", context_parameter: "staging", value: "old-branch" }, | ||
| ], | ||
| }) | ||
| ); | ||
|
Comment on lines
+76
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The regression test injects production, preview, and branch values directly into Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: scripts/test-sync-drift-keys.ts
Line: 76-81
Comment:
**Test Bypasses Context Lookup**
The regression test injects production, preview, and branch values directly into `upsertNetlifySecret`, while the real repair path first fetches variables with `context_name=production`. It therefore remains green without validating that the operational lookup supplies every context the update is intended to preserve.
**Knowledge Base Used:**
- [Data, security, and platform operations](https://app.greptile.com/palmtree-studios/-/custom-context/knowledge-base/palmtr3man/thispagedoesnotexist12345/-/docs/data-security-platform.md)
- [Deployment security operations](https://app.greptile.com/palmtree-studios/-/custom-context/knowledge-base/palmtr3man/thispagedoesnotexist12345/-/docs/deployment-security-operations.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
||
| assert.equal(calls.length, 1, "expected exactly one PATCH request for an existing var"); | ||
| assert.equal(calls[0].method, "PATCH"); | ||
| const sentValues = (calls[0].body as { values: Array<Record<string, string>> }).values; | ||
| assert.equal(sentValues.length, 3, "all three contexts must be preserved, not just the first"); | ||
|
|
||
| const byContext = new Map(sentValues.map((entry) => [entry.context, entry])); | ||
| assert.equal(byContext.get("production")?.value, "new-value"); | ||
| assert.equal(byContext.get("deploy-preview")?.value, "new-value"); | ||
| assert.equal(byContext.get("branch-deploy")?.value, "new-value"); | ||
| assert.equal(byContext.get("branch-deploy")?.context_parameter, "staging"); | ||
| }); | ||
|
|
||
| await test("upsertNetlifySecret creates a single 'all' context value when the key is new", async () => { | ||
| const calls: RecordedCall[] = []; | ||
|
|
||
| await withMockFetch( | ||
| (url, init) => { | ||
| calls.push({ | ||
| url, | ||
| method: init?.method, | ||
| body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, | ||
| }); | ||
| return new Response(JSON.stringify({}), { status: 200 }); | ||
| }, | ||
| () => upsertNetlifySecret("token", "account-1", "site-1", "NEW_KEY", "v1", undefined) | ||
| ); | ||
|
|
||
| assert.equal(calls.length, 1); | ||
| assert.equal(calls[0].method, "POST"); | ||
| const body = calls[0].body as Array<{ values: Array<Record<string, string>> }>; | ||
| assert.deepEqual(body[0].values, [{ context: "all", value: "v1" }]); | ||
| }); | ||
|
|
||
| console.log("All sync-drift-keys helper tests passed."); | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| console.error(error); | ||
| process.exitCode = 1; | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Automation bypasses credential validation
The
requireEnvchecks guard an unused entry point; the configured drift command executes another script. Automation still accepts whitespace credentials and fails later.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.