diff --git a/backend/tests/unit/composeEnvironment.test.ts b/backend/tests/unit/composeEnvironment.test.ts index b90926c..5af872a 100644 --- a/backend/tests/unit/composeEnvironment.test.ts +++ b/backend/tests/unit/composeEnvironment.test.ts @@ -1,28 +1,67 @@ -import { readFileSync } from 'fs'; +import { readFileSync, readdirSync } from 'fs'; import path from 'path'; -import { ALWAYS_REQUIRED } from '../../src/envValidation'; +import { ALWAYS_REQUIRED, validateEnv } from '../../src/envValidation'; /** - * The guard for #107. + * The guard for #107 and #118. * - * That failure 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 + * #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. + * 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. * - * What this cannot cover: production runs from a Portainer stack outside this - * repository, so nothing here can check it. Adding a required variable still - * means updating that stack by hand, and this test is not evidence that it was - * done. + * #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 COMPOSE_PATH = path.resolve(__dirname, '..', '..', '..', 'docker-compose.qa.yml'); -const compose = readFileSync(COMPOSE_PATH, 'utf8'); +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', + demoMode: 'false', + // 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; // Only real environment entries — `- NAME=value` at an indented list position. // A mention inside a comment cannot match, because a comment line starts with #. @@ -43,9 +82,10 @@ function environmentEntries(source: string): Map { return entries; } -const entries = environmentEntries(compose); +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); -describe('the QA compose file provides everything the app requires to boot', () => { it('parsed some environment entries at all', () => { // Guards the guard: a regex that matched nothing would make every // assertion below vacuously true. @@ -56,17 +96,32 @@ describe('the QA compose file provides everything the app requires to boot', () expect(entries.has(name)).toBe(true); }); - // DEMO_MODE is required too, but validated separately from ALWAYS_REQUIRED - // because its rule is stricter than presence — it must be exactly 'true' or - // 'false'. Named explicitly here so it is not missed by reading only the list. - it('sets DEMO_MODE, to one of the two values that are allowed', () => { - expect(entries.has('DEMO_MODE')).toBe(true); - expect(['true', 'false']).toContain(entries.get('DEMO_MODE')); + /** + * 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. + // 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(); @@ -79,4 +134,28 @@ describe('the QA compose file provides everything the app requires to boot', () 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()); + }); }); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4b9723c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,196 @@ +# Production stack — the live storefront. See issue #118 for why this file is +# in the repository at all. +# +# It used to live only in Portainer's web editor, which meant no test could read +# it. `backend/tests/unit/composeEnvironment.test.ts` asserts that the deploying +# environment sets every name in ALWAYS_REQUIRED, and it could only ever check +# QA. Production was unguarded, and on 2026-08-23 it refused to boot because +# UPLOADS_DIR had no line here — while being set in Portainer's stack variables, +# where it does nothing. Committing the file is what lets the guard cover it. +# +# Name the Portainer stack `redefined-designs`, NOT `redefined-designs-qa`. +# The stack name becomes the compose project name, and reusing QA's would make +# compose reconcile the two against each other. +# +# DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor — otherwise the +# file here and the file that actually runs drift apart again, which is the +# whole problem this is solving. +# +# Repository: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs +# Reference: refs/heads/main +# Compose path: docker-compose.prod.yml +# +# `pull_policy: build` matters. Without it the stack reuses whatever is already +# tagged redefined-designs:latest, which is how a redeploy can appear to succeed +# while still running old code. Leave any Portainer option that re-pulls images +# turned OFF — there is no registry to pull this image from. +# +# WHY MOST VALUES ARE HARDCODED HERE RATHER THAN INTERPOLATED +# +# Portainer's stack variables are substituted into this file; they are not +# handed to the container. A variable set in Portainer with no line here never +# reaches the app, and the failure reads as "I set it and it says it is not +# set". Only secrets are interpolated below, because only secrets have a reason +# not to be in the repository. Everything else is written out, so there is one +# place to look and one thing that can be wrong. +# +# Required stack environment variables — all secrets, all must be set in +# Portainer for this stack: +# +# DB_PASSWORD Postgres password for the `redefined` database. +# SMTP_USER Brevo SMTP login. +# SMTP_PASSWORD +# SMTP_FROM The From address customers see. +# ADMIN_GATE_SECRET The shared secret Nginx Proxy Manager injects as the +# X-Admin-Gate header on the gated location. Both sides +# must hold the same value or the admin API returns 403. +# See #63. Without it, /api/admin is protected only by +# the proxy — anything reaching the container directly +# can administer the store. +# PAYPAL_CLIENT_ID Live PayPal credentials. Required because DEMO_MODE is +# PAYPAL_CLIENT_SECRET false below; the app refuses to start without them. +# PAYPAL_WEBHOOK_ID +# USPS_CLIENT_ID Optional. Leave unset to run without address +# USPS_CLIENT_SECRET validation; the app degrades gracefully rather than +# failing, so an empty value is a working configuration. +# +# If the existing stack in Portainer uses different names for any of these, +# rename them there to match — the names above are what this file reads. + +services: + redefined-designs: + # Built from this repository by Portainer rather than pulled. The same + # Dockerfile QA uses, so the two images differ only in configuration. + build: + context: . + dockerfile: Dockerfile + image: redefined-designs:latest + # Always build; never reuse the existing tag. + pull_policy: build + container_name: redefined-designs-syn + environment: + - TZ=America/Chicago + - PORT=3000 + + # NODE_ENV is deliberately absent. The Dockerfile sets it to `production`, + # and that value gates the `secure` flag on the session cookie. Setting it + # to anything else here would silently serve session cookies over plain + # HTTP. Do not add a line for it. + + - PGHOST=redefined-designs-db-syn + - PGPORT=5432 + - PGUSER=redefined + - PGPASSWORD=${DB_PASSWORD} + - PGDATABASE=redefined + + # 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. + # + # To bring the stack up before PayPal is configured, set this to `true` + # and the three PAYPAL_ lines can be removed. The full cart and checkout + # flow then works end to end and NOBODY IS EVER CHARGED. That is a + # deliberate interim state and a quiet disaster if it is left on. + - DEMO_MODE=false + - PAYPAL_ENV=live + - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} + - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET} + - PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID} + + # Host, port and secure are not secrets and are pinned rather than + # inherited: the mailer's fallbacks are Gmail's (smtp.gmail.com, 465, TLS) + # and Brevo needs 587 with STARTTLS, which is why SMTP_SECURE is false. + # Getting these wrong fails at send time, not at boot. + - SMTP_HOST=smtp-relay.brevo.com + - SMTP_PORT=587 + - SMTP_SECURE=false + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_FROM=${SMTP_FROM} + + # MAIL_ALLOWLIST is deliberately absent, and this is the one environment + # where that is correct. It restricts delivery to named recipients, which + # is what keeps QA from emailing real customers. Production has to be able + # to reach real customers, so it is unrestricted here on purpose. The + # boot-time warning about it is expected and should not be silenced. + + # Not a secret, and hardcoded rather than interpolated so it cannot go + # missing: every link in a verification, password-reset, favorite-alert + # and cart-reminder email is built from it, and an unset value renders + # them all as "undefined". + - PUBLIC_URL=https://redefined-designs.bermudalamb.synology.me + + - SITE_CURRENCY=USD + + # Must match the right-hand side of the volume mapping below. Hardcoded + # for that reason — splitting it across two places is how they drift, and + # its absence is what stopped this stack booting on 2026-08-23. + - UPLOADS_DIR=/app/uploads + + # Optional. Address validation is skipped when these are empty, rather + # than failing, so an unset pair is a working configuration. + - USPS_ENV=production + - USPS_CLIENT_ID=${USPS_CLIENT_ID} + - USPS_CLIENT_SECRET=${USPS_CLIENT_SECRET} + + - ADMIN_GATE_SECRET=${ADMIN_GATE_SECRET} + volumes: + # Production's own uploads directory. QA writes to + # /volume1/configs/redefined-designs-qa/uploads; sharing this one would + # let a QA teardown delete real product images. + - /volume1/configs/redefined-designs/uploads:/app/uploads + ports: + # 32750, not QA's 32751. + # + # An unqualified host port binds to 0.0.0.0, so this answers directly on + # http://:32750 from anywhere on the LAN, bypassing Nginx Proxy + # Manager and its TLS. The admin gate still fails closed — a direct + # request arrives without the X-Admin-Gate header — but the storefront, + # the customer API, login and registration are all reachable in the clear. + # + # That is #117, and the fix is not a loopback binding: NPM runs as its own + # container, so 127.0.0.1 would stop the proxy reaching this at all. The + # fix is a shared external network with this block removed entirely, which + # also requires repointing the proxy host entry at the container name and + # port 3000. Left as-is here so this file matches what is deployed today; + # changing it is #117's job, not this file's. + - 32750:3000 + depends_on: + redefined-designs-db-syn: + condition: service_healthy + # Unlike QA's `no`: production is meant to come back after a NAS reboot. + restart: unless-stopped + # Docker's default json-file driver has no size cap. POST /api/client-errors + # is unauthenticated, so an unrotated log is a disk-filling vector on its + # own. QA has carried these options for a while; production could not, + # because this file did not exist. + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + + redefined-designs-db-syn: + image: postgres:16 + container_name: redefined-designs-db-syn + environment: + - POSTGRES_USER=redefined + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=redefined + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + # THE REAL DATA. Distinct from QA's + # /volume1/configs/redefined-designs-qa/postgres. Never point a QA stack + # at this path. + - /volume1/configs/redefined-designs/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U redefined -d redefined"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + logging: + driver: json-file + options: + max-size: 10m + max-file: "3"