Files
redefined-designs/docker-compose.qa.yml
T
bermudalambandClaude Opus 5 0c90e18205
SonarQube Analysis / sonarqube (pull_request) Failing after 13m54s
Tests / lint (pull_request) Successful in 4m27s
Tests / backend-unit (pull_request) Successful in 1m23s
Tests / frontend-e2e (pull_request) Failing after 24m46s
feat: let QA send real email, guarded by a recipient allowlist (#87)
QA has never been able to send mail. The compose file set no SMTP variables and the mailer skips sending when it finds none, which was deliberate — a QA run must not be able to email a real customer if a fixture ever holds a real address. The cost is that four customer-facing flows have never been exercised anywhere but production: verification, password reset, favorite-sold alerts, and the cart-reminder cron that already has a known silent failure mode.

MAIL_ALLOWLIST replaces the blanket mute. Unset means unrestricted, which is production and must stay so. Set means only matching recipients are delivered to; anything else is skipped with a [mail-blocked] warning naming the address and subject. An entry is either a full address, which also covers its plus-suffixed variants, or @domain for every mailbox there — plus-addressing is how these tests get written, and nobody should have to edit an allowlist to invent a new suffix mid-run.

The guard sits in the mailer, not at the four call sites, so every sender is covered by construction and a fifth added later cannot bypass it by forgetting. It skips rather than throws: three callers already swallow send failures into a log, so throwing would mostly be caught anyway while risking a 500 on the signup path. The flow under test finishes and the log says why no mail arrived, which is exactly what was missing when QA was simply muted.

Two details are load-bearing enough to state. Comparison is exact equality on both halves of the address rather than a suffix test, so a lookalike domain ending in an allowed one cannot get through — there is a test for that specifically. And a present-but-empty value refuses everyone rather than allowing everyone: writing MAIL_ALLOWLIST= expresses an intent to restrict, and reading it as "no restriction" would turn a typo into an outbound mail incident.

This inverts the failure mode, so the allowlist is hardcoded in docker-compose.qa.yml rather than read from a stack variable. The safety property must not depend on remembering to set something in Portainer, where an omission would mean unrestricted sending from an environment full of fixtures. The comment says removing the line disables the restriction rather than the mail.

QA points at Brevo, reusing the existing account rather than a separate QA sender — a deliberate choice that puts QA volume behind production's sending reputation and quota, acceptable for now. Host, port and secure are pinned in the compose because the mailer's fallbacks are Gmail's and Brevo needs 587 with STARTTLS; that mismatch fails at send time rather than at boot, which is #64's territory.

Verified: 12 new unit tests on the matching function, which is where a mistake would actually be dangerous — 98 unit and 144 integration passing, lint 0 errors and 8 warnings unchanged, and the compose renders the expected values under docker compose config.

Refs #87
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:15:30 -05:00

145 lines
6.6 KiB
YAML

# QA stack — a disposable copy of the app for reviewing merged-but-undeployed
# changes online. See issue #25.
#
# Deployed as its own Portainer stack, separate from production. Every value
# that could collide with production has been changed: container names, host
# port, volume paths, database name, and image tag. Do not copy a path or port
# back from the production stack — a shared Postgres data directory would mean
# QA writing into production's database files.
#
# Name the Portainer stack `redefined-designs-qa`, NOT `redefined-designs`.
# The stack name becomes the compose project name. Reusing production's name
# would make compose treat this as the same project and reconcile the two
# against each other — it would happily remove the production containers
# because they are not declared in this file.
#
# DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor.
#
# Repository: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
# Reference: refs/heads/main
# Compose path: docker-compose.qa.yml
#
# The repository is public, so no credentials are needed. Portainer then does
# the whole cycle from one button: it pulls the repo, builds the image from the
# Dockerfile below, and recreates the containers. Nothing is built by hand on
# the NAS, and no image has to be pushed anywhere first.
#
# `pull_policy: build` matters. Without it the stack reuses whatever is already
# tagged redefined-designs:qa, 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.
#
# Required stack environment variables:
# QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the
# production stack's variables here does nothing silently.
# PUBLIC_URL — the QA hostname, e.g.
# https://qa-redefined-designs.bermudalamb.synology.me
# QA_SMTP_USER — Brevo SMTP login. Named QA_ for the same reason as the
# QA_SMTP_PASSWORD database password: pasting production's variables in here
# QA_SMTP_FROM must not silently work.
services:
redefined-designs-qa:
# Built from this repository by Portainer rather than pulled. The context is
# the repo root, which is where the Dockerfile lives — the same Dockerfile
# production uses, so QA and production images differ only in configuration.
build:
context: .
dockerfile: Dockerfile
image: redefined-designs:qa
# Always build; never reuse the existing tag.
pull_policy: build
container_name: redefined-designs-qa-syn
environment:
- TZ=America/Chicago
- PORT=3000
- PGHOST=redefined-designs-qa-db-syn
- PGPORT=5432
- PGUSER=redefined_qa
- PGPASSWORD=${QA_DB_PASSWORD}
- PGDATABASE=redefined_qa
# No PayPal credentials at all. DEMO_MODE lets the full cart and checkout
# flow run without them, so QA can exercise the whole purchase path with
# no way to reach live PayPal. Never set PAYPAL_ENV=live here. To test a
# real PayPal integration change, add sandbox credentials and set
# PAYPAL_ENV=sandbox — never the live ones.
- DEMO_MODE=true
# SMTP *is* configured here, unlike PayPal above, because the four mail
# flows — verification, password reset, favorite-sold alerts and the
# cart-reminder cron — cannot be regression tested without it. See #87.
#
# Host, port and secure are not secrets and are pinned here 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=${QA_SMTP_USER}
- SMTP_PASSWORD=${QA_SMTP_PASSWORD}
- SMTP_FROM=${QA_SMTP_FROM}
# What keeps a QA run from emailing a real customer now that it *can*
# send. Only these recipients are ever delivered to; anything else is
# skipped with a [mail-blocked] warning naming the address.
#
# Hardcoded rather than read from a stack variable, deliberately. This is
# the entire safety property, and it must not depend on somebody
# remembering to set something in Portainer — an unset variable would
# mean unrestricted sending from an environment full of test fixtures.
#
# An entry covers its plus-suffixed variants, so `+whatever` addresses
# work without editing this. Removing the line does NOT disable mail; it
# disables the restriction. Production is a separate stack that does not
# read this file, which is why it is unrestricted and correct to be.
- MAIL_ALLOWLIST=thomlamb@gmail.com
- SITE_CURRENCY=USD
- RESERVATION_MINUTES=15
- PUBLIC_URL=${PUBLIC_URL}
volumes:
# Separate uploads directory. Sharing production's would let a QA run
# write into, and a QA teardown delete, real product images.
- /volume1/configs/redefined-designs-qa/uploads:/app/uploads
ports:
# 32751, not production's 32750.
- 32751:3000
depends_on:
redefined-designs-qa-db-syn:
condition: service_healthy
# Not `unless-stopped`: QA is meant to be up only while a review is
# happening. `unless-stopped` would silently bring it back after every NAS
# reboot and leave it running indefinitely.
restart: "no"
# 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 — see the error-boundary design doc's backend section. This does not
# cover production, which is a separate Portainer stack outside this repo;
# the same logging options need to be added there directly.
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redefined-designs-qa-db-syn:
image: postgres:16
container_name: redefined-designs-qa-db-syn
environment:
- POSTGRES_USER=redefined_qa
- POSTGRES_PASSWORD=${QA_DB_PASSWORD}
- POSTGRES_DB=redefined_qa
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
# Distinct data directory from production's
# /volume1/configs/redefined-designs/postgres. This is the single most
# important difference in this file.
- /volume1/configs/redefined-designs-qa/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U redefined_qa -d redefined_qa"]
interval: 10s
timeout: 5s
retries: 10
restart: "no"