Pin the recipe's secret declarations, and declare every app secret file
Soul S2: no test read deployment/idunn/recipe.toml, so a plaintext secret name could be declared again (mutant ME) or a published _FILE name dropped (mutant MF) with the suite green. tests/recipe-secrets.test.ts reads the service's declared names and asserts that no secret-shaped name is declared except as _FILE, that none of the secrets Heimdall reads is declared under its plaintext name, and that every provider client-secret file the witness publishes, every published app profile's shared-secret file, and the database URL, token key and patron secret files are declared. The app-profile rule found two gaps: the witness publishes the repixelizer and streampixels profiles and Heimdall reads their shared-secret files, but the recipe declared neither, so an Idunn binding could not configure them. Both are declared as optional, as GitHub and Spotify were for Soul F5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d73a26dfd1
commit
2800d88d10
2 changed files with 80 additions and 0 deletions
|
|
@ -142,6 +142,8 @@ optional_environment = [
|
|||
"GC_ACCESS_APP_GHOSTLIGHT_BACKEND_CALLBACK_URLS",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_RUNTIME_IDS",
|
||||
"GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APP_REPIXELIZER_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APP_STREAMPIXELS_SHARED_SECRET_FILE",
|
||||
"GC_ACCESS_APPLY_SCHEMA_ON_STARTUP",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE",
|
||||
|
|
|
|||
78
tests/recipe-secrets.test.ts
Normal file
78
tests/recipe-secrets.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appProfiles } from "../src/app-profiles.js";
|
||||
import { providers } from "../src/contracts.js";
|
||||
import { providerCatalog } from "../src/providers.js";
|
||||
|
||||
// The Idunn recipe decides which environment names a binding may set, and
|
||||
// Idunn refuses any other. So the recipe is where "a secret is only ever a
|
||||
// file" is enforced for a deployment: a plaintext secret name declared here
|
||||
// can carry the secret into the binding, the unit and every plan.
|
||||
|
||||
const recipe = readFileSync(new URL("../deployment/idunn/recipe.toml", import.meta.url), "utf8");
|
||||
|
||||
/** The names in one `KEY = [ "A", "B" ]` array of the given TOML text. */
|
||||
function stringArray(text: string, key: string): string[] {
|
||||
const match = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(text);
|
||||
return match ? [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!) : [];
|
||||
}
|
||||
|
||||
const serviceSection = /^\[service\]\n([\s\S]*?)(?=^\[)/m.exec(recipe.replace(/\r\n/g, "\n"))?.[1] ?? "";
|
||||
const serviceNames = [
|
||||
...stringArray(serviceSection, "optional_environment"),
|
||||
...stringArray(serviceSection, "required_environment"),
|
||||
];
|
||||
const everyDeclaredName = [...recipe.replace(/\r\n/g, "\n").matchAll(/^\w+_environment\s*=\s*\[([^\]]*)\]/gm)].flatMap(
|
||||
(match) => [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!)
|
||||
);
|
||||
|
||||
const serviceSecretNames = [
|
||||
"GC_ACCESS_DATABASE_URL",
|
||||
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64",
|
||||
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET",
|
||||
];
|
||||
|
||||
/** Secrets Heimdall reads through readSecretInput, by their plaintext name. */
|
||||
const secretNames = [
|
||||
...serviceSecretNames,
|
||||
...providers.map((provider) => `GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`),
|
||||
...Object.values(appProfiles).map((profile) => `GC_ACCESS_APP_${profile.slug.toUpperCase()}_SHARED_SECRET`),
|
||||
];
|
||||
|
||||
const secretShaped = /SECRET|PASSWORD|DATABASE_URL|ENCRYPTION_KEY|_PEM$/;
|
||||
|
||||
describe("Idunn recipe secret declarations", () => {
|
||||
it("reads the service's declared environment", () => {
|
||||
expect(serviceNames).toContain("GC_ACCESS_DATABASE_URL_FILE");
|
||||
expect(serviceNames).toContain("GC_ACCESS_BASE_URL");
|
||||
});
|
||||
|
||||
it("declares no secret-shaped name except as a _FILE credential path", () => {
|
||||
const plaintext = everyDeclaredName.filter((name) => secretShaped.test(name) && !name.endsWith("_FILE"));
|
||||
expect(plaintext).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares none of the secrets Heimdall reads under its plaintext name", () => {
|
||||
for (const name of secretNames) {
|
||||
expect(everyDeclaredName).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares every provider client-secret file the witness publishes", () => {
|
||||
for (const provider of providers) {
|
||||
expect(serviceNames).toContain(providerCatalog[provider].clientSecretEnv);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares the shared-secret file of every app profile the witness publishes", () => {
|
||||
for (const profile of Object.values(appProfiles)) {
|
||||
expect(serviceNames).toContain(`GC_ACCESS_APP_${profile.slug.toUpperCase()}_SHARED_SECRET_FILE`);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares the database URL, token key and patron secret files", () => {
|
||||
for (const name of serviceSecretNames) {
|
||||
expect(serviceNames).toContain(`${name}_FILE`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue