The one-line compose fix in the previous commit unblocks QA. This is the part that stops it happening again, and it is the more useful half. The failure was not really a missing variable. It was that nothing connected two files: envValidation.ts gained a required variable, docker-compose.qa.yml did not set it, and nothing noticed until a container refused to boot on deploy. CI passed the whole time, because CI supplies its own environment and never reads the compose file — which is exactly why "CI is green" was the wrong evidence to have offered. So a unit test now reads the compose file and asserts it sets everything the validator demands. It imports ALWAYS_REQUIRED rather than restating it, which is the only version of this test worth having: a copied list would pass forever while the next variable added to the validator went unguarded in precisely the same way. Two further assertions earn their place. UPLOADS_DIR is hardcoded rather than taken from a stack variable on the grounds that it must agree with the volume mapping, so the test checks it against the mount rather than leaving that a claim in a comment. And ADMIN_GATE_SECRET must be present as an interpolation rather than a literal, since a secret in the repository would defeat the point of having one. There is also a test guarding the test: a regex that matched nothing would make every other assertion in the file vacuously true, so one case asserts that parsing found entries at all. Fired deliberately rather than assumed. Removing the UPLOADS_DIR line reproduces the original failure as two failing tests; restoring it returns to ten passing. A guard that has only ever been observed passing is not known to guard anything. What this cannot do is check production, which runs from a Portainer stack outside this repository. That gap is now written into the README beside the validation rules, along with the reason a variable set only in Portainer's stack UI never reaches the container: stack variables are interpolated into the compose file, not handed to the service. 172 unit tests pass, lint unchanged at 4 warnings. Refs #107 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
/**
|
|
* Boot-time configuration checks.
|
|
*
|
|
* The backend reads environment variables in a couple of dozen places, and a
|
|
* missing or misspelled one used to be `undefined` until the first line of code
|
|
* that happened to need it — which could be a very long time after the
|
|
* container reported healthy. Several of those failures are silent and
|
|
* customer-visible: mail containing `undefined` in a link, or a shop that
|
|
* quietly stops charging anyone.
|
|
*
|
|
* The container already refuses to start on a failed migration rather than
|
|
* serving against a schema it does not match. This is the same argument applied
|
|
* to configuration.
|
|
*
|
|
* Kept a pure function of the environment it is handed, rather than reading
|
|
* `process.env` itself, so it can be tested exhaustively without booting a
|
|
* server or mutating global state. `server.ts` calls it; `app.ts` deliberately
|
|
* does not, because the integration suite imports `app` directly and would
|
|
* otherwise become a configuration exercise.
|
|
*/
|
|
|
|
export interface EnvValidation {
|
|
/** Configuration that must be fixed. The process should not start. */
|
|
errors: string[];
|
|
/** Working, but worth saying out loud — usually a capability that is off. */
|
|
warnings: string[];
|
|
}
|
|
|
|
// Without these the process cannot do its job at all.
|
|
//
|
|
// Exported so tests/unit/composeEnvironment.test.ts can assert the deploying
|
|
// environment actually sets them. #107 happened because this list grew and
|
|
// docker-compose.qa.yml did not: the check has to read this list rather than a
|
|
// copy of it, or the next variable added here goes unguarded in exactly the
|
|
// same way.
|
|
export const ALWAYS_REQUIRED = [
|
|
'PGHOST',
|
|
'PGPORT',
|
|
'PGUSER',
|
|
'PGPASSWORD',
|
|
'PGDATABASE',
|
|
// No reprieve for this one despite having a fallback: '/app/uploads' is
|
|
// correct inside the container and wrong everywhere else, so inheriting it
|
|
// silently writes uploads somewhere nobody is looking.
|
|
'UPLOADS_DIR'
|
|
] as const;
|
|
|
|
// Only meaningful once real payments are switched on. QA runs with none of
|
|
// these on purpose, which is why the requirement is conditional rather than
|
|
// absolute.
|
|
const PAYPAL_REQUIRED = [
|
|
'PAYPAL_CLIENT_ID',
|
|
'PAYPAL_CLIENT_SECRET',
|
|
'PAYPAL_WEBHOOK_ID',
|
|
'PAYPAL_ENV'
|
|
] as const;
|
|
|
|
// A variable set to spaces is a configuration mistake, not a value.
|
|
function isPresent(env: NodeJS.ProcessEnv, name: string): boolean {
|
|
const value = env[name];
|
|
return typeof value === 'string' && value.trim() !== '';
|
|
}
|
|
|
|
// One function per rule, at module level rather than nested. Each is small
|
|
// enough to read on its own, and cognitive complexity counts everything
|
|
// declared inside a function — so keeping these out of validateEnv is what
|
|
// keeps the composition below flat.
|
|
|
|
function checkAlwaysRequired(env: NodeJS.ProcessEnv): string[] {
|
|
return ALWAYS_REQUIRED.filter((name) => !isPresent(env, name)).map(
|
|
(name) => `${name} is required and is not set.`
|
|
);
|
|
}
|
|
|
|
// Strict rather than truthy. This used to be read as "demo unless the value is
|
|
// exactly 'false'", so DEMO_MODE=False, 0, or any typo meant demo mode was on —
|
|
// a configuration slip that stopped the shop taking money and said nothing.
|
|
function checkDemoMode(env: NodeJS.ProcessEnv): string[] {
|
|
const demoMode = env.DEMO_MODE;
|
|
|
|
if (demoMode === undefined || demoMode.trim() === '') {
|
|
return [
|
|
"DEMO_MODE is required and must be exactly 'true' or 'false'. It decides whether real " +
|
|
'payments are taken, so it has to be stated rather than inherited.'
|
|
];
|
|
}
|
|
|
|
if (demoMode !== 'true' && demoMode !== 'false') {
|
|
return [
|
|
`DEMO_MODE must be exactly 'true' or 'false', but is '${demoMode}'. Anything else used to ` +
|
|
'be read as demo mode, which meant a typo here quietly stopped the shop charging anyone.'
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// Conditional rather than absolute: QA runs with no PayPal credentials on
|
|
// purpose, so requiring them unconditionally would be wrong.
|
|
function checkPayPal(env: NodeJS.ProcessEnv): string[] {
|
|
if (env.DEMO_MODE !== 'false') {
|
|
return [];
|
|
}
|
|
return PAYPAL_REQUIRED.filter((name) => !isPresent(env, name)).map(
|
|
(name) => `${name} is required when DEMO_MODE=false, because real payments are enabled.`
|
|
);
|
|
}
|
|
|
|
// SMTP is all or nothing, and two other variables hang off whether it is set.
|
|
function checkMail(env: NodeJS.ProcessEnv): EnvValidation {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
const hasUser = isPresent(env, 'SMTP_USER');
|
|
const hasPassword = isPresent(env, 'SMTP_PASSWORD');
|
|
|
|
// Half-configured is worse than absent: the mailer only skips when both are
|
|
// missing, so setting one produces a connection that fails at send time
|
|
// instead of a clean "mail is off".
|
|
if (hasUser && !hasPassword) {
|
|
errors.push('SMTP_PASSWORD is required when SMTP_USER is set — set both or neither.');
|
|
}
|
|
if (hasPassword && !hasUser) {
|
|
errors.push('SMTP_USER is required when SMTP_PASSWORD is set — set both or neither.');
|
|
}
|
|
|
|
if (!hasUser || !hasPassword) {
|
|
warnings.push(
|
|
'SMTP is not configured — no email will be sent. Verification, password reset, favorite ' +
|
|
'alerts and cart reminders will all be skipped with a warning.'
|
|
);
|
|
return { errors, warnings };
|
|
}
|
|
|
|
// Demanded only alongside SMTP. Its sole job is building links in email, so a
|
|
// local environment that cannot send mail does not need it, and requiring it
|
|
// there would break every existing local setup to prevent nothing.
|
|
if (!isPresent(env, 'PUBLIC_URL')) {
|
|
errors.push(
|
|
'PUBLIC_URL is required when SMTP is configured, or every link in a verification, ' +
|
|
'password-reset, favorite-alert or cart-reminder email reads "undefined".'
|
|
);
|
|
}
|
|
|
|
if (!isPresent(env, 'MAIL_ALLOWLIST')) {
|
|
warnings.push(
|
|
'MAIL_ALLOWLIST is not set while SMTP is configured — this environment can email real ' +
|
|
'customers. That is correct for production and a hazard anywhere else.'
|
|
);
|
|
}
|
|
|
|
return { errors, warnings };
|
|
}
|
|
|
|
function checkAdminGate(env: NodeJS.ProcessEnv): string[] {
|
|
if (isPresent(env, 'ADMIN_GATE_SECRET')) {
|
|
return [];
|
|
}
|
|
return [
|
|
'ADMIN_GATE_SECRET is not set — /api/admin is protected only by the reverse proxy. ' +
|
|
'Anything able to reach this container directly can administer the store.'
|
|
];
|
|
}
|
|
|
|
export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
|
const mail = checkMail(env);
|
|
|
|
return {
|
|
errors: [
|
|
...checkAlwaysRequired(env),
|
|
...checkDemoMode(env),
|
|
...checkPayPal(env),
|
|
...mail.errors
|
|
],
|
|
warnings: [...mail.warnings, ...checkAdminGate(env)]
|
|
};
|
|
}
|