From 2fe3aa055f058b716c4f7c34477a4cacfa32e9a6 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 27 Aug 2026 11:35:44 -0500 Subject: [PATCH 1/2] docs(ops): make the cutover runbook agree with the compose file about DEMO_MODE (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #190 turned `DEMO_MODE` from a hardcoded compose value into a Portainer stack variable with no default, and updated the compose header. The runbook that actually creates the stack was not updated with it, so the document and the file it deploys have been disagreeing since — in the three places most likely to be read under pressure. Step 2's list of stack variables to record did not include `DEMO_MODE`, and step 6 says to add the variables from steps 2 and 3. An operator following this literally creates a stack that cannot boot. It is now in the list, with a paragraph of its own: it is the only one of the nine that is not a secret, which is exactly why it is the easy one to skip past. The troubleshooting section said `DEMO_MODE` was hardcoded and therefore could not be missing, so its absence from the error list proved nothing. That was the most dangerous sentence in the file — an unset `DEMO_MODE` is now the *first* thing to check rather than something to rule out. The line now says it moved and why. The crash-loop example was the PayPal triple, which cannot occur while `DEMO_MODE` is `true`. The message an operator will actually see during the demo interim — `DEMO_MODE is required and must be exactly 'true' or 'false'` — appeared nowhere in the runbook. Both forms are shown now, in the order they are likely to be hit. Added what neither document said: Compose only *warns* about an unset variable and deploys anyway. In Portainer's stack UI that warning is easy to miss, and the container then crash-loops under `restart: unless-stopped` — loud in the log, invisible in a glance at the stack list. The container log is the signal, not the deploy output. The compose header carried two claims that #190 falsified and did not correct: that the PayPal secrets are required "because DEMO_MODE is false below", and that only secrets are interpolated. Both now describe the file as it is. This is the drift `composeEnvironment.test.ts` exists to prevent, surfacing in the one place no test can reach — the guard keeps the compose file honest about its own intent and cannot see the runbook beside it. Verified: 278 backend unit tests pass, including the compose guard that parses this file, and a sweep for the stale claims finds none left. Closes #196 Co-Authored-By: Claude Opus 5 (1M context) --- docker-compose.prod.yml | 20 ++++++++++++-------- docs/ops/production-stack-cutover.md | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 27892a2..8b4ec8a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -51,12 +51,14 @@ # 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. +# set". Secrets are interpolated below, because they have a reason not to be in +# the repository — and so is DEMO_MODE, which is not a secret but is the one +# value that gets flipped without a code change, which is a reason of its own +# (#190). 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: +# Required stack environment variables. All must be set in Portainer for this +# stack. All are secrets except DEMO_MODE: # # DEMO_MODE `true` or `false`, exactly. Whether real payments are # taken. Not a secret — it is here rather than written @@ -74,9 +76,11 @@ # 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 +# PAYPAL_CLIENT_ID Live PayPal credentials. Required when DEMO_MODE is +# PAYPAL_CLIENT_SECRET `false`; the app refuses to start without them rather +# PAYPAL_WEBHOOK_ID than pretending it can take payments. Unused while +# DEMO_MODE is `true`, so flipping it to `false` without +# them crash-loops the container. # BACKUP_PASSPHRASE Optional. Set it and the uploads archives are # encrypted at rest; leave it empty and they are not. # See docs/ops/backup-and-restore.md before setting it — diff --git a/docs/ops/production-stack-cutover.md b/docs/ops/production-stack-cutover.md index 75b0339..43e31a1 100644 --- a/docs/ops/production-stack-cutover.md +++ b/docs/ops/production-stack-cutover.md @@ -60,7 +60,9 @@ Everything here is lost when the stack is deleted, and the rollback in step 8 is **The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other. -**Every stack environment variable, name and value.** These are all secrets, they belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest: the compose file interpolates `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET` and `PAYPAL_WEBHOOK_ID`, and an unset one substitutes to an empty string rather than failing. A missing `DB_PASSWORD` cannot authenticate against its own data directory; a missing PayPal secret crash-loops the container, because `DEMO_MODE` is false here and the app refuses to pretend it can take payments. Neither is recoverable from anything in this repository. +**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest: the compose file interpolates `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET` and `PAYPAL_WEBHOOK_ID`, and an unset one substitutes to an empty string rather than failing. A missing `DB_PASSWORD` cannot authenticate against its own data directory, and none of this is recoverable from anything in this repository. + +`DEMO_MODE` is the odd one out and the easiest to miss, because it is the only one that is not a secret. It must be exactly `true` or `false`. There is no default in the compose file, deliberately (#190) — a default would decide whether the shop takes money on the operator's behalf, silently, whichever way it pointed — so an unset `DEMO_MODE` refuses to boot rather than guessing. Production is `true` for now, the interim from #191; setting it to `false` is what restores real payments, and doing that without all three PayPal secrets present crash-loops the container. Copy them somewhere before you delete anything. @@ -217,7 +219,16 @@ Migration output must appear *before* `listening on 3000`, and `listening on 300 - `MAIL_ALLOWLIST is not set` — production is the one environment that has to reach real customers. - `UPLOADS_BASE_URL is not set` — the uploads origin is not configured yet (#103). Expected until an NPM host exists for it. -**What a crash loop looks like, and it is the likeliest outcome of a missed step 2.** A `[config] refusing to start` block, then the whole boot sequence again, repeating: +**What a crash loop looks like, and it is the likeliest outcome of a missed step 2.** A `[config] refusing to start` block, then the whole boot sequence again, repeating. + +While production is in the demo interim, the likeliest one is `DEMO_MODE` itself: + +``` +[config] refusing to start — 1 problem(s) with the environment: +[config] - DEMO_MODE is required and must be exactly 'true' or 'false'. +``` + +The other form appears once `DEMO_MODE` is `false` and real payments are on: ``` [config] refusing to start — 3 problem(s) with the environment: @@ -226,9 +237,11 @@ Migration output must appear *before* `listening on 3000`, and `listening on 300 [config] - PAYPAL_WEBHOOK_ID is required when DEMO_MODE=false, because real payments are enabled. ``` -That is a secret that did not reach the container, and it is what happens when the stack variables recorded in step 2 were not carried across — they belong to the stack and were discarded with the old one. An unset stack variable substitutes to an empty string rather than failing, so the container receives `PAYPAL_CLIENT_ID=` and refuses it. +Either way it is a stack variable that did not reach the container, and it is what happens when the ones recorded in step 2 were not carried across — they belong to the stack and were discarded with the old one. An unset stack variable substitutes to an empty string rather than failing, so the container receives `PAYPAL_CLIENT_ID=` and refuses it. -Read the list against the compose file to tell a missing variable from a misnamed one. Anything hardcoded there — `PAYPAL_ENV`, `DEMO_MODE`, `UPLOADS_DIR` — cannot be missing, so its absence from the error list proves nothing. What is diagnostic is an *interpolated* variable that is NOT in the list: if `ADMIN_GATE_SECRET` is quiet while the PayPal three complain, substitution is working and those three specifically are unset, rather than something being wrong with the stack. +Compose itself only warns about this — `The "DEMO_MODE" variable is not set. Defaulting to a blank string.` — and deploys anyway. That warning is easy to miss in Portainer's stack UI, and the container then crash-loops under `restart: unless-stopped`: loud in the log, invisible in a glance at the stack list. The container log is the reliable signal, not the deploy output. + +Read the list against the compose file to tell a missing variable from a misnamed one. Anything hardcoded there — `PAYPAL_ENV`, `UPLOADS_DIR` — cannot be missing, so its absence from the error list proves nothing. `DEMO_MODE` was in that list and is not any more: since #190 it is interpolated like the secrets, which makes it the first thing to check rather than something to rule out. What is diagnostic is an *interpolated* variable that is NOT in the list: if `ADMIN_GATE_SECRET` is quiet while the PayPal three complain, substitution is working and those three specifically are unset, rather than something being wrong with the stack. Fix it in the stack's environment, under exactly the names the compose file reads, and redeploy. If the values are gone with the old stack, the client id and secret are in the PayPal developer dashboard under the live app, and the webhook id is on the webhook entry for `PUBLIC_URL/webhooks/paypal` — readable rather than only recreatable. From 8572e62514fdf19c6a4b8bcc6091b4ea0937c4e0 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 27 Aug 2026 11:33:11 -0500 Subject: [PATCH 2/2] fix(cart): say it is a demo where the customer can see it (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production runs demo mode with no PayPal credentials — that combination is the whole reason #191 turned it on — and in exactly that configuration the storefront rendered a full-width primary button reading plainly `Checkout`. The `(Demo)` suffix was gated on a PayPal client id being present, so the one configuration that needs the word was the only one that never got it. The button is not decorative. It posts to `/demo/purchase`, which marks the item `sold`, writes a `completed` row into `orders` at the real price, and emails everyone who favorited it through production's real SMTP. Nobody is charged, which is what the compose banner promises and is true — but a customer cannot tell they have placed a pretend order, the inventory says otherwise, other customers are told it sold, and nobody is expecting to ship anything. #190, #191 and #192 all reason carefully about not charging by accident; none of them consider accepting an order by accident. Three things now say so, because one of them was never going to be enough: The label is unconditional. `type` still follows the PayPal client id — secondary when real PayPal buttons sit above it, primary when it is the only way to check out — and that distinction is worth keeping, but it is about prominence rather than about what the order is. A notice sits above it for the whole of demo mode, before an address is picked and whether or not PayPal is configured. A parenthesis on the control someone has already decided to press is the weakest possible moment to tell them. The confirmation stopped saying `Order complete!`, which is exactly what a real order says. It now names the two things a customer would otherwise assume: nothing was charged, and nothing will be shipped. Three end-to-end tests, written first and failing first against a browser — the label assertion failed with `Expected "Checkout (Demo)", Received "Checkout"` on an `ant-btn-primary ant-btn-block` element, which is the defect exactly as reported. The suite already runs `DEMO_MODE=true` with no PayPal credentials, so it reproduces production's configuration without any new fixture. `CartPage.checkoutButton` matches the label by prefix rather than in full, deliberately: a locator naming the correct label would have gone looking for the right button and found nothing, which is how a test for this can quietly pass by being wrong in the same direction as the bug. Verified: 3 new tests pass, frontend build clean, lint 0 errors, 25 unit tests pass, and the cart-countdown and orders suites still pass. The favorites and favorites-filter suites fail here, and fail identically with this change stashed — 9 failures without it, 8 with — so they are pre-existing and not from this. Worth their own issue. Closes #195 Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/cart/Cart.tsx | 29 ++++++++- frontend/tests/e2e/demo-checkout.spec.ts | 80 ++++++++++++++++++++++++ frontend/tests/e2e/pages/CartPage.ts | 8 +++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/e2e/demo-checkout.spec.ts diff --git a/frontend/src/cart/Cart.tsx b/frontend/src/cart/Cart.tsx index 829eabc..ae85d12 100644 --- a/frontend/src/cart/Cart.tsx +++ b/frontend/src/cart/Cart.tsx @@ -5,6 +5,7 @@ import List from 'antd/es/list'; import Button from 'antd/es/button'; import Empty from 'antd/es/empty'; import Card from 'antd/es/card'; +import Alert from 'antd/es/alert'; import Radio from 'antd/es/radio'; import Form from 'antd/es/form'; import Input from 'antd/es/input'; @@ -128,7 +129,10 @@ export default function Cart() { setCheckingOut(true); try { await demoCartPurchase(selectedAddressId); - message.success('Order complete!'); + // The item really is marked sold and everyone who favorited it really is + // emailed, so the one moment the customer is told what happened is the one + // moment a demo order has to stop looking like a real one. #195. + message.success('Demo order complete — nothing was charged and nothing will be shipped.'); refreshCartContext(); loadAll(); } catch (err) { @@ -235,17 +239,38 @@ export default function Cart() { + {/* + Shown for the whole of demo mode: before an address is picked, + and whether or not PayPal is configured. The word on the button + is the smaller half of this — it asks the customer to notice a + parenthesis on the control they have already decided to press. + */} + {config?.demoMode && ( + + )} {!selectedAddressId && Select a shipping address to check out.} {config?.paypalClientId && selectedAddressId &&
} {config?.demoMode && selectedAddressId && ( )} diff --git a/frontend/tests/e2e/demo-checkout.spec.ts b/frontend/tests/e2e/demo-checkout.spec.ts new file mode 100644 index 0000000..333d398 --- /dev/null +++ b/frontend/tests/e2e/demo-checkout.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, createItem, uniqueSuffix } from './fixtures'; + +/** + * Demo mode as production actually runs it: `DEMO_MODE=true` with no PayPal + * credentials configured, which is the interim #191 put in place and the shape + * this suite already runs in. + * + * That combination used to render a full-width primary button reading plainly + * "Checkout". The "(Demo)" suffix was gated on a PayPal client id being present, + * so the one configuration that needs the label was the only one that did not + * get it — and the button is not decorative, it marks real inventory sold and + * emails everyone who favorited it. See #195. + * + * These assert what a customer can see, because that is the whole defect. The + * server was always right about what it was doing. + */ + +const ADDRESS = { + fullName: 'Dana Holt', + addressLine1: '118 Cedar Street', + city: 'Asheville', + state: 'NC', + postalCode: '28801', + isDefault: true +}; + +test.describe('Demo mode says so to the customer', () => { + test('labels the checkout button as a demo when no PayPal is configured', async ({ + page, + customer, + cart + }) => { + const name = `Demo c${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + + // The cart selects the default address on load, so the checkout controls + // render with nothing clicked. + await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 }); + await expect(cart.checkoutButton).toHaveText('Checkout (Demo)'); + }); + + test('says so before the customer commits, not only on the button', async ({ + page, + customer, + cart + }) => { + const name = `Demo n${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + + // A label on the button alone asks the customer to notice a parenthesis on + // the thing they are already clicking. The notice is the part that has to + // survive someone not reading carefully. + await expect(cart.demoNotice).toBeVisible({ timeout: 20000 }); + }); + + test('the confirmation says nothing was charged', async ({ page, customer, cart }) => { + const name = `Demo p${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 }); + + await cart.checkoutButton.click(); + + // "Order complete!" is true and is exactly what a real order would say. The + // item really is marked sold and favoriters really are emailed, so the one + // moment the customer is told what happened has to distinguish the two. + await expect(page.getByText(/nothing was charged/i)).toBeVisible({ timeout: 20000 }); + }); +}); diff --git a/frontend/tests/e2e/pages/CartPage.ts b/frontend/tests/e2e/pages/CartPage.ts index 2b1979b..d4ceaa2 100644 --- a/frontend/tests/e2e/pages/CartPage.ts +++ b/frontend/tests/e2e/pages/CartPage.ts @@ -10,10 +10,18 @@ import { Locator, Page } from '@playwright/test'; export class CartPage { readonly continueShoppingButton: Locator; readonly emptyNotice: Locator; + readonly checkoutButton: Locator; + readonly demoNotice: Locator; constructor(private readonly page: Page) { this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' }); this.emptyNotice = page.getByText('Your cart is empty'); + // Matched on a prefix rather than the whole label, so a test can assert what + // the label says instead of having to know it in order to find the button. + // That is the point of #195: the label was wrong, and a locator naming it in + // full would have gone looking for the right button and found nothing. + this.checkoutButton = page.getByRole('button', { name: /^Checkout/ }); + this.demoNotice = page.getByText('Demonstration only'); } async goto(): Promise {