Reverses the position the previous commit took. Hardcoding it made a value that gets flipped without a code change require a commit and a merge to flip, which is backwards — and it is the operator's call, not the file's.
No default, deliberately. `${DEMO_MODE:-false}` is the obvious form and the wrong one: a default decides whether the shop takes money on the operator's behalf, silently, whichever way it points. Having none 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. That strictness is the whole reason interpolating this one is defensible, so the line says so.
The compose guard had to learn the difference. It hands each deploying file's entries to the real validator, and a literal `${DEMO_MODE}` is not a value `checkDemoMode` accepts, so both the DEMO_MODE assertion and the validateEnv check failed the moment the file stopped holding a literal. Each deployment now declares the stack variables it supplies, and a bare `${VAR}` named there resolves to the declared value before the file is validated. Every other bare `${VAR}` stays opaque exactly as before — those are secrets, and what is checked of them is that the line exists.
Be clear about what that guard can 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 beside the file and make the pair inseparable — hardcode the compose line and the registry disagrees, change the registry and it no longer describes the file. The runtime half is the boot check, which fails loudly rather than falling back. Verified by mutation: hardcoding `false` fails the DEMO_MODE assertion, and deleting the line fails that and `validateEnv`.
Restoring real payments is now two Portainer values and a redeploy, with no commit — which is what #190 asks for.
220 lines
10 KiB
TypeScript
220 lines
10 KiB
TypeScript
import { readFileSync, readdirSync } from 'fs';
|
|
import path from 'path';
|
|
import { ALWAYS_REQUIRED, validateEnv } from '../../src/envValidation';
|
|
|
|
/**
|
|
* The guard for #107 and #118.
|
|
*
|
|
* #107 was not the missing variable — it was that nothing connected two files.
|
|
* envValidation.ts gained a required variable and docker-compose.qa.yml did
|
|
* not set it, and nothing noticed until the container refused to boot on a
|
|
* deploy. CI passed throughout, because CI sets its own environment and never
|
|
* reads the compose file.
|
|
*
|
|
* So this reads the real list from the validator rather than a copy. A copy
|
|
* would pass forever while the next added variable went unguarded in precisely
|
|
* the same way. For the same reason the per-file check below runs `validateEnv`
|
|
* itself rather than restating its rules: the point is that each deploying file
|
|
* satisfies the validator, and any restatement here is one more thing to drift.
|
|
*
|
|
* #118 was the other half. This used to read QA's file alone, while production
|
|
* ran from a Portainer stack outside the repository that nothing could check.
|
|
* That was worse than an even gap, because a green test implied a coverage it
|
|
* did not have: UPLOADS_DIR is in ALWAYS_REQUIRED, QA set it, production did
|
|
* not, and on 2026-08-23 production refused to boot for exactly that reason —
|
|
* while the variable was set in Portainer, where stack variables are
|
|
* substituted into the compose file rather than handed to the container, so a
|
|
* variable with no line in the file never reaches the app at all.
|
|
*/
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
|
|
/**
|
|
* Every deployment this repository describes, and what is true of each.
|
|
*
|
|
* Registered rather than discovered blindly, because the environments differ on
|
|
* purpose — QA runs in demo mode with no PayPal credentials and a mail
|
|
* allowlist, production is the reverse of all three — so "the files agree with
|
|
* each other" would be the wrong assertion. What each file has to satisfy is
|
|
* the validator, plus the handful of things below that are properties of that
|
|
* environment rather than of the code.
|
|
*
|
|
* A compose file at the repository root that is not registered here fails the
|
|
* last test in this file. That is deliberate: adding an environment should
|
|
* force the decision about what is true of it, rather than silently inheriting
|
|
* whatever the loop happened to assert.
|
|
*/
|
|
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
|
|
// not depend on somebody remembering to set something.
|
|
requiresMailAllowlist: true
|
|
},
|
|
{
|
|
file: 'docker-compose.prod.yml',
|
|
// 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.
|
|
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
|
|
}
|
|
] as const;
|
|
|
|
/**
|
|
* What the container receives for a `${VAR:-default}` entry when the stack
|
|
* variable behind it is unset — which is the case worth modelling, since a
|
|
* variable that is always set needs no default.
|
|
*
|
|
* A bare `${VAR}` is deliberately left alone. It stays an opaque non-empty
|
|
* string so that a required variable referenced that way still counts as
|
|
* present, which is the whole contract described below: what is checked here is
|
|
* that the *line exists*, not that the stack behind it is filled in.
|
|
*/
|
|
function resolveDefault(value: string): string {
|
|
const withDefault = /^\$\{[A-Z_0-9]+:-(.*)\}$/.exec(value);
|
|
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<Record<string, string>>): 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,
|
|
stackVariables: Readonly<Record<string, string>>
|
|
): Map<string, string> {
|
|
const entries = new Map<string, string>();
|
|
// The split pattern tolerates carriage returns. A checkout with CRLF line
|
|
// endings, which is every fresh clone on Windows, otherwise leaves a stray
|
|
// carriage return that the end-of-line anchor below cannot match, and every
|
|
// assertion in this file then silently finds nothing at all. That is exactly
|
|
// the failure the "parsed some entries" case exists to catch, and it is how
|
|
// this guard was found to be broken.
|
|
for (const line of source.split(/\r?\n/)) {
|
|
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
|
|
// Both groups are non-optional in the pattern, so a match always has them —
|
|
// but RegExpExecArray cannot say so, and #101 made the compiler insist.
|
|
const [, name, value] = match ?? [];
|
|
if (name !== undefined && value !== undefined) {
|
|
entries.set(name, resolveEntry(value.trim(), stackVariables));
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
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, deployment.stackVariables);
|
|
|
|
it('parsed some environment entries at all', () => {
|
|
// Guards the guard: a regex that matched nothing would make every
|
|
// assertion below vacuously true.
|
|
expect(entries.size).toBeGreaterThan(5);
|
|
});
|
|
|
|
it.each([...ALWAYS_REQUIRED])('sets %s', (name) => {
|
|
expect(entries.has(name)).toBe(true);
|
|
});
|
|
|
|
/**
|
|
* The strongest form of this check, and the one that cannot drift: hand the
|
|
* file's own entries to the real validator and require it to be satisfied.
|
|
*
|
|
* An interpolated `${SECRET}` counts as present, which is correct — what is
|
|
* being checked is that the *line exists*, since that is what decides whether
|
|
* the value reaches the container. Whether the stack variable behind it is
|
|
* set is a different failure, and one the app already reports clearly at boot.
|
|
*
|
|
* Errors only. Production warns about MAIL_ALLOWLIST by design, and silencing
|
|
* that warning would remove the notice that this environment can email real
|
|
* customers.
|
|
*/
|
|
it('satisfies validateEnv, the same check the container runs at boot', () => {
|
|
const { errors } = validateEnv(Object.fromEntries(entries));
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
it(`sets DEMO_MODE to ${deployment.demoMode}`, () => {
|
|
expect(entries.get('DEMO_MODE')).toBe(deployment.demoMode);
|
|
});
|
|
|
|
// The reason UPLOADS_DIR is hardcoded rather than taken from a stack
|
|
// variable is that it has to agree with the volume mapping. That claim is
|
|
// only worth making if something checks it — and its absence from production
|
|
// is what stopped that stack booting.
|
|
it('points UPLOADS_DIR at the directory the uploads volume is mounted on', () => {
|
|
const uploadsDir = entries.get('UPLOADS_DIR');
|
|
expect(uploadsDir).toBeTruthy();
|
|
expect(compose).toContain(`:${uploadsDir}`);
|
|
});
|
|
|
|
// Interpolated rather than hardcoded, so the secret itself never enters the
|
|
// repository. Present as a reference is what matters; an unset stack variable
|
|
// resolves to empty, which the validator and the gate both read as "off".
|
|
it('references ADMIN_GATE_SECRET from the stack rather than holding a value', () => {
|
|
expect(entries.get('ADMIN_GATE_SECRET')).toBe('${ADMIN_GATE_SECRET}');
|
|
});
|
|
|
|
if (deployment.requiresMailAllowlist) {
|
|
it('restricts delivery with MAIL_ALLOWLIST', () => {
|
|
expect(entries.get('MAIL_ALLOWLIST')).toBeTruthy();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('every deployment in the repository is under this guard', () => {
|
|
// The failure #118 was about was a file nothing read. A new environment added
|
|
// as a compose file and not registered above would reproduce it exactly, so
|
|
// the omission has to fail rather than pass quietly.
|
|
//
|
|
// Scoped to the repository root: backend/docker-compose.test.yml is a bare
|
|
// Postgres for the integration suite, with no app service and nothing here to
|
|
// say about it.
|
|
it('registers every root-level compose file in DEPLOYMENTS', () => {
|
|
const found = readdirSync(REPO_ROOT).filter(
|
|
(name) => /^docker-compose\..+\.ya?ml$/.test(name)
|
|
);
|
|
const registered = DEPLOYMENTS.map((d) => d.file);
|
|
|
|
expect(found.slice().sort()).toEqual(registered.slice().sort());
|
|
});
|
|
});
|