Files
redefined-designs/backend/tests/unit/composeEnvironment.test.ts
T
bermudalamb 2de61df8f7 docs(ops): point the demo-mode notes at the right issue (#190)
The tracking issue was filed as #190; the compose banner and the guard test both said #191, guessing the number before it existed. A note that points at the wrong issue is worse than no note when the thing it tracks is production not taking money.
2026-08-26 08:38:59 -05:00

186 lines
8.6 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',
// 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. 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.
demoMode: '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;
}
// 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<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, resolveDefault(value.trim()));
}
}
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);
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());
});
});