diff --git a/backend/tests/unit/composeEnvironment.test.ts b/backend/tests/unit/composeEnvironment.test.ts index fe0fb06..e8d3cd3 100644 --- a/backend/tests/unit/composeEnvironment.test.ts +++ b/backend/tests/unit/composeEnvironment.test.ts @@ -48,6 +48,10 @@ const DEPLOYMENTS = [ { file: 'docker-compose.qa.yml', demoMode: 'true', + // Written into the file rather than supplied by the stack, unlike + // production: QA exists to run without PayPal credentials, so there is no + // circumstance in which it should be anything else. + stackVariables: {}, // The entire safety property of QA: delivery is restricted to named // recipients, so a run against a database full of test fixtures cannot // email a real customer. Asserted because the compose file claims it must @@ -59,10 +63,20 @@ const DEPLOYMENTS = [ // TEMPORARY, and #190 restores it to 'false'. Production is in demo mode // since 2026-08-25 — the stack was brought up during the cutover to this // file before the live PayPal credentials were available, and demo mode is - // the sanctioned interim for that. This assertion is what keeps the state - // honest: it fails the moment the compose file and this expectation - // disagree, so neither can be changed quietly, in either direction. + // the sanctioned interim for that. demoMode: 'true', + // Production reads DEMO_MODE from the stack, so the file holds `${DEMO_MODE}` + // rather than a value and this is what the stack has to be set to. + // + // Be clear about what this can and cannot prove. It cannot see Portainer, + // so it does not verify the stack actually holds `true` — nothing in this + // repository can. What it does is keep the intent written down beside the + // file, and make the pair inseparable: change the compose line to a literal + // and this disagrees, change this and the boot behaviour it claims no + // longer matches. The runtime half is `checkDemoMode`, which refuses to + // start on anything that is not exactly `true` or `false`, so a stack that + // is unset or mistyped fails loudly rather than falling back to either. + stackVariables: { DEMO_MODE: 'true' }, // Deliberately unrestricted. Production has to be able to reach real // customers, and it is the one environment where that is correct. requiresMailAllowlist: false @@ -84,9 +98,29 @@ function resolveDefault(value: string): string { return withDefault?.[1] ?? value; } +/** + * What the container receives for one entry, given what the stack is declared + * to set. + * + * A bare `${VAR}` the deployment names in `stackVariables` resolves to that + * value — which is how a file that reads DEMO_MODE from the stack can still be + * handed to `validateEnv` and checked as the container will see it. Every other + * bare `${VAR}` stays opaque, as before: those are secrets, and what is being + * checked of them is that the line exists. + */ +function resolveEntry(value: string, stackVariables: Readonly>): string { + const bare = /^\$\{([A-Z_0-9]+)\}$/.exec(value); + const name = bare?.[1]; + const named = name === undefined ? undefined : stackVariables[name]; + return named ?? resolveDefault(value); +} + // Only real environment entries — `- NAME=value` at an indented list position. // A mention inside a comment cannot match, because a comment line starts with #. -function environmentEntries(source: string): Map { +function environmentEntries( + source: string, + stackVariables: Readonly> +): Map { const entries = new Map(); // The split pattern tolerates carriage returns. A checkout with CRLF line // endings, which is every fresh clone on Windows, otherwise leaves a stray @@ -100,7 +134,7 @@ function environmentEntries(source: string): Map { // but RegExpExecArray cannot say so, and #101 made the compiler insist. const [, name, value] = match ?? []; if (name !== undefined && value !== undefined) { - entries.set(name, resolveDefault(value.trim())); + entries.set(name, resolveEntry(value.trim(), stackVariables)); } } return entries; @@ -108,7 +142,7 @@ function environmentEntries(source: string): Map { describe.each(DEPLOYMENTS)('$file provides everything the app requires to boot', (deployment) => { const compose = readFileSync(path.join(REPO_ROOT, deployment.file), 'utf8'); - const entries = environmentEntries(compose); + const entries = environmentEntries(compose, deployment.stackVariables); it('parsed some environment entries at all', () => { // Guards the guard: a regex that matched nothing would make every diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7db6f41..27892a2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -58,6 +58,12 @@ # Required stack environment variables — all secrets, all must be set in # Portainer for this stack: # +# DEMO_MODE `true` or `false`, exactly. Whether real payments are +# taken. Not a secret — it is here rather than written +# into the file below because it is the one value that +# gets flipped without a code change. There is no +# default: unset or mistyped refuses to boot rather than +# choosing for you. # DB_PASSWORD Postgres password for the `redefined` database. # SMTP_USER Brevo SMTP login. # SMTP_PASSWORD @@ -112,24 +118,28 @@ services: # on 2026-08-25 to bring the stack up during the cutover to this file # before the live PayPal credentials were to hand. # - # Restoring it is #190. Set this back to `false`, fill the three stack - # variables below, and redeploy. Until then every order placed is a - # pretend one. + # Restoring it is #190: fill the three PayPal stack variables and set the + # DEMO_MODE stack variable to `false`. Both are Portainer values now, so + # restoring real payments needs no commit — which is the point, and also + # why this banner and the guard test exist to keep the state visible. + # Until then every order placed is a pretend one. # =================================================================== # # Real payments. This is the difference between production and QA, and it # is why the three PayPal secrets are required rather than optional — the # app refuses to start without them when this is false. # - # HARDCODED, AND NOT INTERPOLATED, ON PURPOSE. Portainer substitutes stack - # variables into this file; it does not hand them to the container. A - # `DEMO_MODE` stack variable therefore does NOTHING — there is no `${...}` - # here for it to substitute into, and the value below wins silently. That - # is deliberate: the one value that decides whether the shop takes money - # should not be flippable from a web UI without a commit anybody can read. - # It is also exactly what wasted an hour during the cutover, so it is - # written down rather than left to be rediscovered. - - DEMO_MODE=true + # Supplied by the stack, and with NO DEFAULT, deliberately. + # `${DEMO_MODE:-false}` is the obvious thing to write and the wrong thing: + # a default decides whether the shop takes money on the operator's behalf, + # silently, whichever way it points. + # + # Having no default is safe rather than fragile, because `checkDemoMode` + # is strict. An unset stack variable substitutes to an empty string, and + # anything that is not exactly `true` or `false` refuses to boot naming + # DEMO_MODE. So a missing or mistyped value fails loudly at startup + # instead of guessing — which is what makes interpolating this one safe. + - DEMO_MODE=${DEMO_MODE} - PAYPAL_ENV=live - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}