Public: anyone can read this; the crew can change it.
Stripe's own path is proven: a fake Stripe in the tests walks Checkout, the return and the webhook #18
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/14-stripe-s-own-path-is-proven-a-fake-strip → main 8 commits fork 2 threads · 2 unresolved
README.md +24
| @@ -82,6 +82,7 @@ cart with all three against the order it becomes. | ||
| 82 | 82 | | `STRIPE_SECRET_KEY=sk_test_…` (or `rk_test_…`) | A Stripe **Checkout Session in test mode** is created from the order's own lines and the browser is sent to Stripe's hosted page. No card number is ever typed into this app. Test card `4242 4242 4242 4242`. | |
| 83 | 83 | | `STRIPE_SECRET_KEY=sk_live_…` | Refused. The store says so on the payment page and uses the mock instead. | |
| 84 | 84 | | unset | The mock gateway, clearly labelled. | |
| 85 | | `STRIPE_API_BASE` | Where Stripe is. It exists so the tests can point at a fake Stripe and is only honoured when it names this machine (`127.0.0.1`, `localhost`, `[::1]`); anything else is ignored and real Stripe is used, so a key cannot be sent somewhere an environment variable chose. | | |
| 85 | 86 | |
| 86 | 87 | With Stripe, an order settles either when the shopper comes back (the session |
| 87 | 88 | is read once) or when the webhook arrives at `POST /webhooks/stripe`, verified |
| @@ -90,6 +91,27 @@ an idempotency key, so it is recorded once. Refunds go to Stripe first and to | ||
| 90 | 91 | the ledger second, with an idempotency key built from the order and what has |
| 91 | 92 | already been refunded. |
| 92 | 93 | |
| 94 | What Stripe is asked for is the order's total less whatever a gift card | |
| 95 | already paid — `dueCents` in `src/money.ts`, which the payment page, the | |
| 96 | receipt, the mock and Stripe all read, so no two of them can drift. Usually | |
| 97 | that is the order's lines plus one for shipping and tax; when a discount or a | |
| 98 | gift card applies it is a single line for the balance, because Checkout has no | |
| 99 | negative line and the money must not come off twice. | |
| 100 | ||
| 101 | Pressing "Pay with card" is a form POST to this app that answers `303` to | |
| 102 | Stripe's hosted page, and the security floor says `form-action 'self'` — | |
| 103 | which Chromium applies to the redirect a form navigation follows. That one | |
| 104 | response, and only it, carries `form-action 'self' <the Stripe origin it is | |
| 105 | sending the browser to>`; every other response keeps the floor as it is. | |
| 106 | ||
| 107 | The whole of that path is tested. `src/stripe.test.ts` stands a **fake Stripe** | |
| 108 | on `127.0.0.1` — the three endpoints this app calls, nothing else — points | |
| 109 | `STRIPE_API_BASE` at it and boots the app in its own process against it, then | |
| 110 | asserts what went down the wire: the amounts, the currency, the idempotency | |
| 111 | keys, the shopper's return, the webhook arriving before and after it, a | |
| 112 | refund, Stripe answering 500, Stripe stopped, and a live key refused with no | |
| 113 | request made at all. No test in this repository talks to the network. | |
| 114 | ||
| 93 | 115 | ## Environment |
| 94 | 116 | |
| 95 | 117 | | Variable | Meaning | |
| @@ -98,6 +120,7 @@ already been refunded. | ||
| 98 | 120 | | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. | |
| 99 | 121 | | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. | |
| 100 | 122 | | `STRIPE_WEBHOOK_SECRET` | Verifies `POST /webhooks/stripe`. | |
| 123 | | `STRIPE_API_BASE` | Where Stripe is, for pointing the tests at a fake one on this machine. Ignored unless it is a loopback address; defaults to `https://api.stripe.com`. | | |
| 101 | 124 | |
| 102 | 125 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 103 | 126 | `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it |
| @@ -139,6 +162,7 @@ environment and keeps stock moves and refunds for an admin. | ||
| 139 | 162 | | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox | |
| 140 | 163 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 141 | 164 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 165 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe | | |
| 142 | 166 | |
| 143 | 167 | ## The terminal |
| 144 | 168 |
src/gateway.ts +61 −17
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 15 | import { dueCents } from "./money.ts"; | |
| 15 | 16 | import type { Order, OrderItem } from "./db.ts"; |
| 16 | 17 | |
| 17 | 18 | export type GatewayKind = "stripe" | "mock"; |
| @@ -22,7 +23,25 @@ export type GatewayInfo = { | ||
| 22 | 23 | note?: string; |
| 23 | 24 | }; |
| 24 | 25 | |
| 25 | const API = process.env.STRIPE_API_BASE ?? "https://api.stripe.com"; | |
| 26 | const STRIPE = "https://api.stripe.com"; | |
| 27 | /** http://127.0.0.1:1234, http://localhost, http://[::1] — this machine and nowhere else. */ | |
| 28 | const LOOPBACK = /^https?:\/\/(127(\.\d{1,3}){3}|localhost|\[::1\])(:\d{1,5})?$/i; | |
| 29 | ||
| 30 | /** | |
| 31 | * Where Stripe is. Read when a request is made rather than once at import, so | |
| 32 | * the tests can stand a fake Stripe in front of this and nothing in the suite | |
| 33 | * ever leaves the machine. | |
| 34 | * | |
| 35 | * STRIPE_API_BASE is a seam for those tests and is only honoured when it names | |
| 36 | * this machine: a secret key is never sent somewhere an environment variable | |
| 37 | * chose. Anything else is ignored and the real Stripe is used. | |
| 38 | */ | |
| 39 | export function apiBase(env: NodeJS.ProcessEnv = process.env): string { | |
| 40 | const override = env.STRIPE_API_BASE ?? ""; | |
| 41 | return LOOPBACK.test(override) ? override : STRIPE; | |
| 42 | } | |
| 43 | ||
| 44 | const api = (): string => apiBase(); | |
| 26 | 45 | |
| 27 | 46 | export function chosen(): GatewayInfo { |
| 28 | 47 | const key = process.env.STRIPE_SECRET_KEY ?? ""; |
| @@ -50,7 +69,7 @@ async function stripe<T>(path: string, form: Record<string, string>, idempotency | ||
| 50 | 69 | }; |
| 51 | 70 | if (idempotencyKey) headers["idempotency-key"] = idempotencyKey; |
| 52 | 71 | try { |
| 53 | const response = await fetch(`${API}${path}`, { | |
| 72 | const response = await fetch(`${api()}${path}`, { | |
| 54 | 73 | method: "POST", |
| 55 | 74 | headers, |
| 56 | 75 | body: new URLSearchParams(form).toString(), |
| @@ -68,7 +87,7 @@ async function stripeGet<T>(path: string): Promise<StripeResult<T>> { | ||
| 68 | 87 | const key = process.env.STRIPE_SECRET_KEY ?? ""; |
| 69 | 88 | if (!key) return { ok: false, error: "Stripe is not configured." }; |
| 70 | 89 | try { |
| 71 | const response = await fetch(`${API}${path}`, { | |
| 90 | const response = await fetch(`${api()}${path}`, { | |
| 72 | 91 | headers: { authorization: `Bearer ${key}` }, |
| 73 | 92 | signal: AbortSignal.timeout(15000), |
| 74 | 93 | }); |
| @@ -82,9 +101,42 @@ async function stripeGet<T>(path: string): Promise<StripeResult<T>> { | ||
| 82 | 101 | |
| 83 | 102 | export type Checkout = { id: string; url: string }; |
| 84 | 103 | |
| 104 | type Line = { name: string; unit_amount: number; quantity: number }; | |
| 105 | ||
| 85 | 106 | /** |
| 86 | * A Checkout Session for one order. The line items are built from the order's | |
| 87 | * own rows, so what Stripe charges is what the order says, to the cent. | |
| 107 | * The lines Stripe is asked to take, which must add up to what the order is | |
| 108 | * owed. What that is belongs to src/money.ts and is not restated here. | |
| 109 | * | |
| 110 | * Usually that is the order's own rows plus one line for shipping and tax. | |
| 111 | * A discount or a gift card takes money off the whole order rather than off a | |
| 112 | * line, and Checkout has no negative line to put it on, so when either applies | |
| 113 | * the order goes as a single line for the balance — the itemisation a shopper | |
| 114 | * needs is on the payment page and the receipt, which are ours. | |
| 115 | */ | |
| 116 | function checkoutLines(order: Order, items: OrderItem[]): Line[] { | |
| 117 | const due = dueCents(order); | |
| 118 | const reduced = order.discount_cents > 0 || order.gift_card_cents > 0; | |
| 119 | if (reduced) { | |
| 120 | const less = [ | |
| 121 | order.discount_cents > 0 ? `less ${order.discount_code || "a discount"}` : "", | |
| 122 | order.gift_card_cents > 0 ? "less a gift card" : "", | |
| 123 | ].filter((part) => part !== ""); | |
| 124 | const count = `${items.length} line${items.length === 1 ? "" : "s"}`; | |
| 125 | return [{ name: `Order ${order.number} — ${[count, ...less].join(", ")}`, unit_amount: due, quantity: 1 }]; | |
| 126 | } | |
| 127 | const lines: Line[] = items.map((item) => ({ | |
| 128 | name: `${item.product_title} — ${item.variant_name}`, | |
| 129 | unit_amount: item.unit_price_cents, | |
| 130 | quantity: item.quantity, | |
| 131 | })); | |
| 132 | const extras = order.shipping_cents + order.tax_cents; | |
| 133 | if (extras > 0) lines.push({ name: "Shipping and tax", unit_amount: extras, quantity: 1 }); | |
| 134 | return lines; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * A Checkout Session for one order, built from the order's own rows, so what | |
| 139 | * Stripe charges is what the order is owed, to the cent. | |
| 88 | 140 | */ |
| 89 | 141 | export async function startCheckout( |
| 90 | 142 | order: Order, |
| @@ -100,20 +152,12 @@ export async function startCheckout( | ||
| 100 | 152 | "payment_intent_data[metadata][order_number]": order.number, |
| 101 | 153 | "metadata[order_number]": order.number, |
| 102 | 154 | }; |
| 103 | items.forEach((item, i) => { | |
| 104 | form[`line_items[${i}][quantity]`] = String(item.quantity); | |
| 155 | checkoutLines(order, items).forEach((line, i) => { | |
| 156 | form[`line_items[${i}][quantity]`] = String(line.quantity); | |
| 105 | 157 | form[`line_items[${i}][price_data][currency]`] = order.currency.toLowerCase(); |
| 106 | form[`line_items[${i}][price_data][unit_amount]`] = String(item.unit_price_cents); | |
| 107 | form[`line_items[${i}][price_data][product_data][name]`] = `${item.product_title} — ${item.variant_name}`; | |
| 158 | form[`line_items[${i}][price_data][unit_amount]`] = String(line.unit_amount); | |
| 159 | form[`line_items[${i}][price_data][product_data][name]`] = line.name; | |
| 108 | 160 | }); |
| 109 | const extras = order.shipping_cents + order.tax_cents; | |
| 110 | if (extras > 0) { | |
| 111 | const i = items.length; | |
| 112 | form[`line_items[${i}][quantity]`] = "1"; | |
| 113 | form[`line_items[${i}][price_data][currency]`] = order.currency.toLowerCase(); | |
| 114 | form[`line_items[${i}][price_data][unit_amount]`] = String(extras); | |
| 115 | form[`line_items[${i}][price_data][product_data][name]`] = "Shipping and tax"; | |
| 116 | } | |
| 117 | 161 | return stripe<Checkout>("/v1/checkout/sessions", form, `checkout:${order.number}`); |
| 118 | 162 | } |
| 119 | 163 |
src/http.ts +25
| @@ -13,6 +13,31 @@ export const text = (body: string, status = 200): Response => | ||
| 13 | 13 | export const redirect = (location: string, status: 302 | 303 = 303): Response => |
| 14 | 14 | new Response(null, { status, headers: { location } }); |
| 15 | 15 | |
| 16 | /** | |
| 17 | * The header a handler uses to say "this response takes a form navigation to | |
| 18 | * another origin". The security floor sets `form-action 'self'`, and Chromium | |
| 19 | * applies that policy to the redirect a form POST follows — so a 303 from our | |
| 20 | * own origin to a hosted payment page is refused by the browser unless the | |
| 21 | * policy on that one response names where it goes. src/server.ts reads this | |
| 22 | * header, widens `form-action` for that response alone, and strips it; it | |
| 23 | * never reaches a browser. | |
| 24 | */ | |
| 25 | export const HAND_OFF = "x-hand-off-to"; | |
| 26 | ||
| 27 | /** An origin and nothing else — no path, no space, nothing that ends a CSP directive. */ | |
| 28 | const ORIGIN = /^https?:\/\/[a-z0-9.-]{1,253}(:\d{1,5})?$/i; | |
| 29 | ||
| 30 | /** Hand the browser to another origin, from a form, without the policy refusing it. */ | |
| 31 | export function handOff(location: string, status: 302 | 303 = 303): Response { | |
| 32 | const response = redirect(location, status); | |
| 33 | let origin = ""; | |
| 34 | try { | |
| 35 | origin = new URL(location).origin; | |
| 36 | } catch {} | |
| 37 | if (ORIGIN.test(origin)) response.headers.set(HAND_OFF, origin); | |
| 38 | return response; | |
| 39 | } | |
| 40 | ||
| 16 | 41 | /** |
| 17 | 42 | * A form field, cleaned at the boundary: control characters out, trimmed, |
| 18 | 43 | * length capped. Every handler in this app reads fields through this. |
src/mail.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { db, type Order, type OrderItem } from "./db.ts"; |
| 9 | import { formatCents } from "./money.ts"; | |
| 9 | import { dueCents, formatCents } from "./money.ts"; | |
| 10 | 10 | |
| 11 | 11 | export type Email = { |
| 12 | 12 | id: number; |
| @@ -28,7 +28,7 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str | ||
| 28 | 28 | item.unit_price_cents * item.quantity, |
| 29 | 29 | ).padStart(10)}`, |
| 30 | 30 | ); |
| 31 | const due = order.total_cents - order.gift_card_cents; | |
| 31 | const due = dueCents(order); | |
| 32 | 32 | return [ |
| 33 | 33 | `Thank you — your order ${order.number} is confirmed.`, |
| 34 | 34 | "", |
src/money.ts +9
| @@ -32,6 +32,15 @@ export function parseQuantity(input: unknown, max = 99): number { | ||
| 32 | 32 | return Math.min(n, max); |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | /** | |
| 36 | * What is still owed on an order, and so what a gateway is asked for: the | |
| 37 | * total, less whatever a gift card already paid. One subtraction, written | |
| 38 | * once — the payment page, the receipt, the mock and Stripe must all agree | |
| 39 | * on it to the cent, and they only do that by reading it from here. | |
| 40 | */ | |
| 41 | export const dueCents = (order: { total_cents: number; gift_card_cents: number }): number => | |
| 42 | order.total_cents - order.gift_card_cents; | |
| 43 | ||
| 35 | 44 | /** Totals are recomputed from lines, never carried. */ |
| 36 | 45 | export function sumLines(lines: { unit_price_cents: number; quantity: number }[]): number { |
| 37 | 46 | return lines.reduce((total, line) => total + line.unit_price_cents * line.quantity, 0); |
src/orders.ts +1 −4
| @@ -16,7 +16,7 @@ import * as giftcards from "./giftcards.ts"; | ||
| 16 | 16 | import { randomBytes } from "node:crypto"; |
| 17 | 17 | import { db, type Order, type OrderItem, type Payment } from "./db.ts"; |
| 18 | 18 | import { addStock, takeStock } from "./inventory.ts"; |
| 19 | import { sumLines } from "./money.ts"; | |
| 19 | import { dueCents, sumLines } from "./money.ts"; | |
| 20 | 20 | import { quote, type CartLine, type Quote } from "./cart.ts"; |
| 21 | 21 | import { writeReceipt } from "./mail.ts"; |
| 22 | 22 | import type { Applied } from "./codes.ts"; |
| @@ -80,9 +80,6 @@ export const rememberGatewayRef = (order: Order, ref: string): void => { | ||
| 80 | 80 | db.query('UPDATE "order" SET payment_ref = ?2 WHERE id = ?1 AND status = \'pending_payment\'').run(order.id, ref); |
| 81 | 81 | }; |
| 82 | 82 | |
| 83 | /** What the gateway was asked for: the total, less what a gift card paid. */ | |
| 84 | export const dueCents = (order: Order): number => order.total_cents - order.gift_card_cents; | |
| 85 | ||
| 86 | 83 | const sumPayments = (orderId: number, kind: "charge" | "refund"): number => |
| 87 | 84 | db |
| 88 | 85 | .query<{ total: number }, [number, string]>( |
src/pages/checkout.ts +3 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { csrfField, escape } from "../html.ts"; |
| 9 | import { formatCents } from "../money.ts"; | |
| 9 | import { dueCents, formatCents } from "../money.ts"; | |
| 10 | 10 | import { codeBox, totals } from "./cart.ts"; |
| 11 | 11 | import type { Quote } from "../cart.ts"; |
| 12 | 12 | import type { Order, OrderItem, Payment } from "../db.ts"; |
| @@ -121,7 +121,7 @@ export const orderTotals = (order: Order, refunded = 0): string => `<div class=" | ||
| 121 | 121 | formatCents(order.gift_card_cents), |
| 122 | 122 | )}</span></div> |
| 123 | 123 | <div class="row"><span>Charged</span><span class="price">${escape( |
| 124 | formatCents(order.total_cents - order.gift_card_cents), | |
| 124 | formatCents(dueCents(order)), | |
| 125 | 125 | )}</span></div>` |
| 126 | 126 | : "" |
| 127 | 127 | } |
| @@ -141,7 +141,7 @@ export function pay( | ||
| 141 | 141 | gateway: GatewayInfo, |
| 142 | 142 | error?: string, |
| 143 | 143 | ): string { |
| 144 | const due = order.total_cents - order.gift_card_cents; | |
| 144 | const due = dueCents(order); | |
| 145 | 145 | const box = |
| 146 | 146 | gateway.kind === "stripe" |
| 147 | 147 | ? `<form method="post" action="/pay/${escape(order.number)}/stripe"> |
src/server.test.ts +3
| @@ -191,6 +191,9 @@ test("the shop is served, on the security floor", async () => { | ||
| 191 | 191 | expect(body).toContain("Ethiopia Guji"); |
| 192 | 192 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 193 | 193 | expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); |
| 194 | // Forms submit here and nowhere else, everywhere but the payment hand-off. | |
| 195 | expect(response.headers.get("content-security-policy")).toContain("form-action 'self'"); | |
| 196 | expect(response.headers.get("content-security-policy")!.split("form-action 'self'")[1]).toBe(""); | |
| 194 | 197 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 195 | 198 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); |
| 196 | 199 | }); |
src/server.ts +14 −3
| @@ -28,7 +28,7 @@ import { notFound } from "./shop.ts"; | ||
| 28 | 28 | import { seed } from "./catalog.ts"; |
| 29 | 29 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 30 | 30 | import { seed as seedTax } from "./tax.ts"; |
| 31 | import { text } from "./http.ts"; | |
| 31 | import { HAND_OFF, text } from "./http.ts"; | |
| 32 | 32 | import { whoIs } from "./who.ts"; |
| 33 | 33 | import type { Who } from "./who.ts"; |
| 34 | 34 | import type { Ctx } from "./context.ts"; |
| @@ -44,9 +44,16 @@ const env = process.env.FORGE_APP_ENV ?? "local"; | ||
| 44 | 44 | // The security floor every response stands on. Scripts and styles come from |
| 45 | 45 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| 46 | 46 | // browsers never guess a content type, and referrers stay on this site. |
| 47 | // | |
| 48 | // Forms may only submit to this origin. The one exception is the response that | |
| 49 | // hands a shopper to a hosted payment page: it names that origin with HAND_OFF | |
| 50 | // and gets it added to `form-action` for itself alone, because Chromium checks | |
| 51 | // the redirect a form navigation follows against this policy. | |
| 52 | const csp = (formAction: string): string => | |
| 53 | `default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`; | |
| 54 | ||
| 47 | 55 | const SECURITY: Record<string, string> = { |
| 48 | "content-security-policy": | |
| 49 | "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", | |
| 56 | "content-security-policy": csp("'self'"), | |
| 50 | 57 | "x-content-type-options": "nosniff", |
| 51 | 58 | "referrer-policy": "strict-origin-when-cross-origin", |
| 52 | 59 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| @@ -67,7 +74,11 @@ const sameOrigin = (request: Request): boolean => { | ||
| 67 | 74 | /** Every response leaves through here, so none of them can miss the floor. */ |
| 68 | 75 | function finish(response: Response, cookies: string[]): Response { |
| 69 | 76 | const headers = new Headers(response.headers); |
| 77 | const handOffTo = headers.get(HAND_OFF); | |
| 70 | 78 | for (const [name, value] of Object.entries(SECURITY)) headers.set(name, value); |
| 79 | // Widened for this response only, and only to the origin it redirects to. | |
| 80 | if (handOffTo) headers.set("content-security-policy", csp(`'self' ${handOffTo}`)); | |
Round one is answered: the branch is on The suite is real, not decorative. Reverting the collapse in One finding is in the way, and it is on the money path. 1. The
|
||
| 81 | headers.delete(HAND_OFF); | |
| 71 | 82 | if (!headers.has("cache-control")) headers.set("cache-control", "no-store"); |
| 72 | 83 | for (const value of cookies) headers.append("set-cookie", value); |
| 73 | 84 | return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); |
src/shop.ts +9 −7
| @@ -17,13 +17,13 @@ import * as orders from "./orders.ts"; | ||
| 17 | 17 | import * as pages from "./pages/checkout.ts"; |
| 18 | 18 | import * as storefront from "./pages/storefront.ts"; |
| 19 | 19 | import { categories, categoryBySlug, productBySlug, products, search, variantById, variantsOf } from "./catalog.ts"; |
| 20 | import { field, intField, looksLikeEmail, redirect, text } from "./http.ts"; | |
| 20 | import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts"; | |
| 21 | 21 | import { render, type Ctx } from "./context.ts"; |
| 22 | 22 | import { page as cartPage } from "./pages/cart.ts"; |
| 23 | 23 | import { chosen } from "./gateway.ts"; |
| 24 | 24 | import { equals, sign, unsign } from "./sign.ts"; |
| 25 | 25 | import { forOrder } from "./mail.ts"; |
| 26 | import { parseQuantity } from "./money.ts"; | |
| 26 | import { dueCents, parseQuantity } from "./money.ts"; | |
| 27 | 27 | import { escape } from "./html.ts"; |
| 28 | 28 | import type { Order } from "./db.ts"; |
| 29 | 29 | |
| @@ -208,7 +208,7 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 208 | 208 | if (!result.ok) return checkoutForm(ctx, result.error, values); |
| 209 | 209 | saveCart(ctx, []); |
| 210 | 210 | saveCodes(ctx, codes.NONE); |
| 211 | const where = orders.dueCents(result.order) === 0 ? "orders" : "pay"; | |
| 211 | const where = dueCents(result.order) === 0 ? "orders" : "pay"; | |
| 212 | 212 | return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`); |
| 213 | 213 | } |
| 214 | 214 | |
| @@ -254,7 +254,7 @@ export function payMock(ctx: Ctx): Response { | ||
| 254 | 254 | const paid = orders.markPaid(order, { |
| 255 | 255 | gateway: "mock", |
| 256 | 256 | ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`, |
| 257 | amount_cents: orders.dueCents(order), | |
| 257 | amount_cents: dueCents(order), | |
| 258 | 258 | }); |
| 259 | 259 | return redirect(`/orders/${paid.number}?token=${encodeURIComponent(paid.access_token)}`); |
| 260 | 260 | } |
| @@ -282,7 +282,9 @@ export async function payStripe(ctx: Ctx): Promise<Response> { | ||
| 282 | 282 | if (!session.ok) return payPageFor(ctx, order, session.error); |
| 283 | 283 | orders.note(order.id, "checkout-started", session.value.id); |
| 284 | 284 | orders.rememberGatewayRef(order, session.value.id); |
| 285 | return redirect(session.value.url, 303); | |
| 285 | // A form POST answered with a redirect off-site: the response says where it | |
| 286 | // goes, so the policy on it allows that one hop and no other. | |
| 287 | return handOff(session.value.url); | |
| 286 | 288 | } |
| 287 | 289 | |
| 288 | 290 | // --- the order -------------------------------------------------------------- |
| @@ -320,7 +322,7 @@ async function settleStripe(order: Order): Promise<Order> { | ||
| 320 | 322 | return orders.markPaid(order, { |
| 321 | 323 | gateway: "stripe", |
| 322 | 324 | ref: session.value.payment_intent ?? order.payment_ref, |
| 323 | amount_cents: session.value.amount_total ?? orders.dueCents(order), | |
| 325 | amount_cents: session.value.amount_total ?? dueCents(order), | |
| 324 | 326 | }); |
| 325 | 327 | } |
| 326 | 328 | |
| @@ -347,7 +349,7 @@ export async function stripeWebhook(ctx: Ctx): Promise<Response> { | ||
| 347 | 349 | orders.markPaid(order, { |
| 348 | 350 | gateway: "stripe", |
| 349 | 351 | ref: typeof session.payment_intent === "string" ? session.payment_intent : String(session.id ?? ""), |
| 350 | amount_cents: typeof session.amount_total === "number" ? session.amount_total : orders.dueCents(order), | |
| 352 | amount_cents: typeof session.amount_total === "number" ? session.amount_total : dueCents(order), | |
| 351 | 353 | }); |
| 352 | 354 | } |
| 353 | 355 | return text("ok"); |
src/stripe.test.ts added +722
| @@ -0,0 +1,722 @@ | ||
| 1 | /** | |
| 2 | * The Stripe path, end to end, against a Stripe that lives in this process. | |
| 3 | * | |
| 4 | * A fake Stripe answers the only three endpoints this app calls and records | |
| 5 | * every request it is sent, so what is asserted here is what would go down the | |
| 6 | * wire: the amounts, the currency, the idempotency keys. Nothing here makes an | |
| 7 | * outbound request — STRIPE_API_BASE points at 127.0.0.1 and the fake is | |
| 8 | * stopped in afterAll. Only a real key separates this from production. | |
| 9 | * | |
| 10 | * The app under test is the app: `bun run src/server.ts` in its own process, | |
| 11 | * with its own FORGE_APP_DATA and its own Stripe environment. That keeps this | |
| 12 | * file's orders and this file's keys out of every other test file, which share | |
| 13 | * one process and so would otherwise share one database. | |
| 14 | */ | |
| 15 | ||
| 16 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 17 | import { Database } from "bun:sqlite"; | |
| 18 | import { createHmac } from "node:crypto"; | |
| 19 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 20 | import { tmpdir } from "node:os"; | |
| 21 | import { join } from "node:path"; | |
| 22 | ||
| 23 | import { stamp } from "./who.ts"; | |
| 24 | ||
| 25 | const WEBHOOK_SECRET = "whsec_a_fake_stripe_signs_with_this"; | |
| 26 | const TEST_KEY = "sk_test_a_key_that_is_not_a_key"; | |
| 27 | /** The key the forge signs identities with; the app under test verifies against it. */ | |
| 28 | const IDENTITY_KEY = "the-key-the-forge-signs-with-in-the-stripe-test"; | |
| 29 | const dataDir = mkdtempSync(join(tmpdir(), "bazaar-stripe-")); | |
| 30 | ||
| 31 | // --- the fake Stripe --------------------------------------------------------- | |
| 32 | ||
| 33 | type Seen = { method: string; path: string; headers: Record<string, string>; form: URLSearchParams }; | |
| 34 | ||
| 35 | /** Every request the app made, oldest first. */ | |
| 36 | const seen: Seen[] = []; | |
| 37 | /** The sessions the fake has created, by id, as Stripe would remember them. */ | |
| 38 | const sessions = new Map<string, { payment_status: string; payment_intent: string; amount_total: number }>(); | |
| 39 | /** What each idempotency key already answered: a repeat is not a second one. */ | |
| 40 | const replies = new Map<string, unknown>(); | |
| 41 | /** While set, every call fails the way Stripe fails: a 5xx with a message. */ | |
| 42 | let failing = false; | |
| 43 | let counter = 0; | |
| 44 | ||
| 45 | const errorBody = (message: string, status: number): Response => | |
| 46 | Response.json({ error: { type: "api_error", message } }, { status }); | |
| 47 | ||
| 48 | /** Stripe, as far as this app is concerned: three endpoints and a memory. */ | |
| 49 | const startFake = (port: number) => | |
| 50 | Bun.serve({ | |
| 51 | port, | |
| 52 | hostname: "127.0.0.1", | |
| 53 | async fetch(request) { | |
| 54 | const url = new URL(request.url); | |
| 55 | const body = request.method === "POST" ? await request.text() : ""; | |
| 56 | seen.push({ | |
| 57 | method: request.method, | |
| 58 | path: url.pathname, | |
| 59 | headers: Object.fromEntries(request.headers), | |
| 60 | form: new URLSearchParams(body), | |
| 61 | }); | |
| 62 | if (failing) return errorBody("Stripe is having a moment.", 500); | |
| 63 | ||
| 64 | // Stripe answers a repeated idempotency key with the first answer. | |
| 65 | const key = request.headers.get("idempotency-key"); | |
| 66 | if (key !== null && replies.has(key)) return Response.json(replies.get(key)); | |
| 67 | const answer = (value: unknown): Response => { | |
| 68 | if (key !== null) replies.set(key, value); | |
| 69 | return Response.json(value); | |
| 70 | }; | |
| 71 | ||
| 72 | if (request.method === "POST" && url.pathname === "/v1/checkout/sessions") { | |
| 73 | const form = new URLSearchParams(body); | |
| 74 | let amount = 0; | |
| 75 | for (let i = 0; form.has(`line_items[${i}][quantity]`); i++) | |
| 76 | amount += | |
| 77 | Number(form.get(`line_items[${i}][price_data][unit_amount]`)) * Number(form.get(`line_items[${i}][quantity]`)); | |
| 78 | const id = `cs_test_${++counter}`; | |
| 79 | sessions.set(id, { payment_status: "unpaid", payment_intent: `pi_test_${counter}`, amount_total: amount }); | |
| 80 | return answer({ id, url: `${fakeBase()}/checkout/${id}`, amount_total: amount }); | |
| 81 | } | |
| 82 | if (request.method === "GET" && url.pathname.startsWith("/v1/checkout/sessions/")) { | |
| 83 | const id = url.pathname.slice("/v1/checkout/sessions/".length); | |
| 84 | const session = sessions.get(id); | |
| 85 | if (!session) return errorBody("No such checkout session.", 404); | |
| 86 | return Response.json({ id, ...session }); | |
| 87 | } | |
| 88 | if (request.method === "POST" && url.pathname === "/v1/refunds") | |
| 89 | return answer({ id: `re_test_${++counter}`, status: "succeeded" }); | |
| 90 | ||
| 91 | return errorBody("Unrecognized request URL.", 404); | |
| 92 | }, | |
| 93 | }); | |
| 94 | ||
| 95 | let fake = startFake(0); | |
| 96 | const FAKE_PORT = fake.port; | |
| 97 | const fakeBase = (): string => `http://127.0.0.1:${FAKE_PORT}`; | |
| 98 | ||
| 99 | /** The shopper finished Stripe's hosted page: the session is now paid. */ | |
| 100 | const payAtStripe = (id: string): void => { | |
| 101 | sessions.set(id, { ...sessions.get(id)!, payment_status: "paid" }); | |
| 102 | }; | |
| 103 | ||
| 104 | /** What the fake was sent, from `at` onwards. */ | |
| 105 | const since = (at: number): Seen[] => seen.slice(at); | |
| 106 | ||
| 107 | type WireLine = { name: string; currency: string; unit_amount: number; quantity: number }; | |
| 108 | ||
| 109 | /** The `line_items[…]` of a Checkout Session request, back as plain data. */ | |
| 110 | function wireLines(form: URLSearchParams): WireLine[] { | |
| 111 | const lines: WireLine[] = []; | |
| 112 | for (let i = 0; form.has(`line_items[${i}][quantity]`); i++) | |
| 113 | lines.push({ | |
| 114 | name: form.get(`line_items[${i}][price_data][product_data][name]`) ?? "", | |
| 115 | currency: form.get(`line_items[${i}][price_data][currency]`) ?? "", | |
| 116 | unit_amount: Number(form.get(`line_items[${i}][price_data][unit_amount]`)), | |
| 117 | quantity: Number(form.get(`line_items[${i}][quantity]`)), | |
| 118 | }); | |
| 119 | return lines; | |
| 120 | } | |
| 121 | ||
| 122 | const wireTotal = (form: URLSearchParams): number => | |
| 123 | wireLines(form).reduce((sum, line) => sum + line.unit_amount * line.quantity, 0); | |
| 124 | ||
| 125 | // --- the app, in its own process --------------------------------------------- | |
| 126 | ||
| 127 | type App = { base: string; stop: () => Promise<void> }; | |
| 128 | ||
| 129 | /** Boot `bun run src/server.ts` and wait for the line that says it is up. */ | |
| 130 | async function boot(env: Record<string, string> = {}): Promise<App> { | |
| 131 | const proc = Bun.spawn(["bun", "run", "src/server.ts"], { | |
| 132 | cwd: join(import.meta.dir, ".."), | |
| 133 | env: { | |
| 134 | ...process.env, | |
| 135 | PORT: "0", | |
| 136 | FORGE_APP_DATA: dataDir, | |
| 137 | FORGE_APP_IDENTITY_KEY: IDENTITY_KEY, | |
| 138 | STRIPE_API_BASE: fakeBase(), | |
| 139 | STRIPE_SECRET_KEY: TEST_KEY, | |
| 140 | STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, | |
| 141 | ...env, | |
| 142 | }, | |
| 143 | stdout: "pipe", | |
| 144 | stderr: "inherit", | |
| 145 | }); | |
| 146 | const reader = proc.stdout.getReader(); | |
| 147 | const decoder = new TextDecoder(); | |
| 148 | const listening = /listening on (http:\/\/\S+)/; | |
| 149 | const deadline = Date.now() + 15_000; | |
| 150 | let log = ""; | |
| 151 | while (!listening.test(log)) { | |
| 152 | if (Date.now() > deadline) throw new Error(`the app did not start in time:\n${log}`); | |
| 153 | const { value, done } = await reader.read(); | |
| 154 | if (done) throw new Error(`the app exited before it listened:\n${log}`); | |
| 155 | log += decoder.decode(value); | |
| 156 | } | |
| 157 | // Keep draining, or a full pipe would stop the app mid-test. | |
| 158 | void (async () => { | |
| 159 | try { | |
| 160 | while (!(await reader.read()).done); | |
| 161 | } catch {} | |
| 162 | })(); | |
| 163 | return { | |
| 164 | base: listening.exec(log)![1]!, | |
| 165 | stop: async () => { | |
| 166 | proc.kill(); | |
| 167 | await proc.exited; | |
| 168 | }, | |
| 169 | }; | |
| 170 | } | |
| 171 | ||
| 172 | let app: App; | |
| 173 | /** A second app, configured with a live key, for the one test that needs it. */ | |
| 174 | let liveApp: App; | |
| 175 | let db: Database; | |
| 176 | ||
| 177 | beforeAll(async () => { | |
| 178 | app = await boot(); | |
| 179 | liveApp = await boot({ STRIPE_SECRET_KEY: "sk_live_this_must_never_be_used" }); | |
| 180 | db = new Database(join(dataDir, "app.db")); | |
| 181 | }); | |
| 182 | ||
| 183 | afterAll(async () => { | |
| 184 | fake.stop(true); | |
| 185 | await app?.stop(); | |
| 186 | await liveApp?.stop(); | |
| 187 | db?.close(); | |
| 188 | rmSync(dataDir, { recursive: true, force: true }); | |
| 189 | }); | |
| 190 | ||
| 191 | type Person = { id: string; name: string; role: "user" | "admin" }; | |
| 192 | ||
| 193 | /** The headers the forge puts on a request it proxies, signed the way it signs them. */ | |
| 194 | function identityHeaders(person?: Person): Record<string, string> { | |
| 195 | if (!person) return {}; | |
| 196 | const identity = `${person.id}|${person.name}|${person.role}|${Date.now() + 600_000}`; | |
| 197 | return { "x-forge-identity": identity, "x-forge-signature": stamp(IDENTITY_KEY, identity) }; | |
| 198 | } | |
| 199 | ||
| 200 | /** A browser: its own cookies, its own address, and it posts like a form. */ | |
| 201 | class Shopper { | |
| 202 | jar = new Map<string, string>(); | |
| 203 | /** Rate limits are per address, and a test is not a crowd of one. */ | |
| 204 | ip = `203.0.113.${++Shopper.made}`; | |
| 205 | static made = 0; | |
| 206 | ||
| 207 | /** Who the forge says this browser is; undefined is a signed-out visitor. */ | |
| 208 | constructor(private person?: Person) {} | |
| 209 | ||
| 210 | private remember(response: Response): void { | |
| 211 | for (const line of response.headers.getSetCookie()) { | |
| 212 | const [pair] = line.split(";"); | |
| 213 | const eq = pair!.indexOf("="); | |
| 214 | this.jar.set(pair!.slice(0, eq), pair!.slice(eq + 1)); | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | private headers(extra: Record<string, string> = {}): Record<string, string> { | |
| 219 | return { | |
| 220 | cookie: [...this.jar].map(([name, value]) => `${name}=${value}`).join("; "), | |
| 221 | "sec-fetch-site": "same-origin", | |
| 222 | "x-forwarded-for": this.ip, | |
| 223 | ...identityHeaders(this.person), | |
| 224 | ...extra, | |
| 225 | }; | |
| 226 | } | |
| 227 | ||
| 228 | async get(path: string, at: App = app): Promise<Response> { | |
| 229 | const response = await fetch(at.base + path, { headers: this.headers(), redirect: "manual" }); | |
| 230 | this.remember(response); | |
| 231 | return response; | |
| 232 | } | |
| 233 | ||
| 234 | async page(path: string, at: App = app): Promise<string> { | |
| 235 | return (await this.get(path, at)).text(); | |
| 236 | } | |
| 237 | ||
| 238 | async post(path: string, fields: Record<string, string>, csrf?: string, at: App = app): Promise<Response> { | |
| 239 | const body = new URLSearchParams(fields); | |
| 240 | if (csrf !== undefined) body.set("csrf", csrf); | |
| 241 | const response = await fetch(at.base + path, { | |
| 242 | method: "POST", | |
| 243 | body, | |
| 244 | headers: this.headers({ "content-type": "application/x-www-form-urlencoded" }), | |
| 245 | redirect: "manual", | |
| 246 | }); | |
| 247 | this.remember(response); | |
| 248 | return response; | |
| 249 | } | |
| 250 | ||
| 251 | static tokenIn(html: string): string { | |
| 252 | return /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? ""; | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | // --- what the database says -------------------------------------------------- | |
| 257 | ||
| 258 | const variantId = (sku: string): number => | |
| 259 | db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id; | |
| 260 | const slugOf = (sku: string): string => | |
| 261 | db | |
| 262 | .query<{ slug: string }, [string]>( | |
| 263 | "SELECT p.slug FROM variant v JOIN product p ON p.id = v.product_id WHERE v.sku = ?1", | |
| 264 | ) | |
| 265 | .get(sku)!.slug; | |
| 266 | ||
| 267 | const orderNamed = (number: string): any => | |
| 268 | db.query<any, [string]>('SELECT * FROM "order" WHERE number = ?1').get(number)!; | |
| 269 | const paymentsOf = (number: string): any[] => | |
| 270 | db.query<any, [string]>("SELECT * FROM payment WHERE order_id = ?1 ORDER BY id").all(orderNamed(number).id); | |
| 271 | const chargesOf = (number: string): any[] => paymentsOf(number).filter((row) => row.kind === "charge"); | |
| 272 | const dueOf = (number: string): number => { | |
| 273 | const order = orderNamed(number); | |
| 274 | return order.total_cents - order.gift_card_cents; | |
| 275 | }; | |
| 276 | ||
| 277 | // --- shopping ---------------------------------------------------------------- | |
| 278 | ||
| 279 | type Placed = { number: string; token: string }; | |
| 280 | ||
| 281 | /** Buy something and stop at the payment step, with Stripe as the gateway. */ | |
| 282 | async function placeOrder( | |
| 283 | shopper: Shopper, | |
| 284 | what: { sku: string; quantity: number }[], | |
| 285 | codes: string[] = [], | |
| 286 | ): Promise<Placed> { | |
| 287 | for (const line of what) { | |
| 288 | const product = await shopper.page(`/p/${slugOf(line.sku)}`); | |
| 289 | const added = await shopper.post( | |
| 290 | "/cart/add", | |
| 291 | { variant: String(variantId(line.sku)), quantity: String(line.quantity) }, | |
| 292 | Shopper.tokenIn(product), | |
| 293 | ); | |
| 294 | expect(added.status).toBe(303); | |
| 295 | } | |
| 296 | for (const code of codes) { | |
| 297 | const cart = await shopper.page("/cart"); | |
| 298 | const applied = await shopper.post("/cart/code", { code, back: "cart" }, Shopper.tokenIn(cart)); | |
| 299 | expect(applied.headers.get("location")).toContain("m=applied"); | |
| 300 | } | |
| 301 | const checkout = await shopper.page("/checkout"); | |
| 302 | const placed = await shopper.post( | |
| 303 | "/checkout", | |
| 304 | { | |
| 305 | idempotency: /name="idempotency" value="([^"]+)"/.exec(checkout)![1]!, | |
| 306 | email: "[email protected]", | |
| 307 | name: "A Buyer", | |
| 308 | address1: "1 Test Street", | |
| 309 | address2: "", | |
| 310 | city: "Testville", | |
| 311 | region: "TS", | |
| 312 | postal: "12345", | |
| 313 | country: "United States", | |
| 314 | }, | |
| 315 | Shopper.tokenIn(checkout), | |
| 316 | ); | |
| 317 | expect(placed.status).toBe(303); | |
| 318 | const [, number, token] = /\/pay\/([^?]+)\?token=(.+)$/.exec(placed.headers.get("location")!)!; | |
| 319 | return { number: number!, token: decodeURIComponent(token!) }; | |
| 320 | } | |
| 321 | ||
| 322 | /** Press "Pay with card" on /pay. */ | |
| 323 | async function pressPayWithCard(shopper: Shopper, placed: Placed, at: App = app): Promise<Response> { | |
| 324 | const payPage = await shopper.page(`/pay/${placed.number}?token=${encodeURIComponent(placed.token)}`, at); | |
| 325 | return shopper.post(`/pay/${placed.number}/stripe`, { token: placed.token }, Shopper.tokenIn(payPage), at); | |
| 326 | } | |
| 327 | ||
| 328 | const sessionIdIn = (location: string): string => location.slice(location.lastIndexOf("/") + 1); | |
| 329 | ||
| 330 | /** The shopper coming back from Stripe to the success_url. */ | |
| 331 | const comeBack = (shopper: Shopper, placed: Placed): Promise<Response> => | |
| 332 | shopper.get(`/orders/${placed.number}?token=${encodeURIComponent(placed.token)}`); | |
| 333 | ||
| 334 | /** A delivery signed the way Stripe signs one. */ | |
| 335 | async function deliverWebhook(event: unknown, secret = WEBHOOK_SECRET): Promise<Response> { | |
| 336 | const payload = JSON.stringify(event); | |
| 337 | const timestamp = Math.floor(Date.now() / 1000); | |
| 338 | const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); | |
| 339 | return fetch(app.base + "/webhooks/stripe", { | |
| 340 | method: "POST", | |
| 341 | body: payload, | |
| 342 | headers: { "content-type": "application/json", "stripe-signature": `t=${timestamp},v1=${signature}` }, | |
| 343 | }); | |
| 344 | } | |
| 345 | ||
| 346 | const completedEvent = (number: string, sessionId: string) => ({ | |
| 347 | id: "evt_test_1", | |
| 348 | type: "checkout.session.completed", | |
| 349 | data: { | |
| 350 | object: { | |
| 351 | id: sessionId, | |
| 352 | object: "checkout_session", | |
| 353 | client_reference_id: number, | |
| 354 | payment_status: "paid", | |
| 355 | payment_intent: sessions.get(sessionId)!.payment_intent, | |
| 356 | amount_total: sessions.get(sessionId)!.amount_total, | |
| 357 | metadata: { order_number: number }, | |
| 358 | }, | |
| 359 | }, | |
| 360 | }); | |
| 361 | ||
| 362 | /** | |
| 363 | * A browser the forge signs in as an admin. There is no password to post: | |
| 364 | * identity arrives on every request as the forge's signed headers. | |
| 365 | */ | |
| 366 | async function admin(): Promise<Shopper> { | |
| 367 | const staff = new Shopper({ id: "u_1", name: "ada", role: "admin" }); | |
| 368 | expect((await staff.get("/admin")).status).toBe(200); | |
| 369 | return staff; | |
| 370 | } | |
| 371 | ||
| 372 | // --- the Checkout Session ---------------------------------------------------- | |
| 373 | ||
| 374 | test("a Checkout Session is built from the order's lines and asks for what is due", async () => { | |
| 375 | const shopper = new Shopper(); | |
| 376 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]); | |
| 377 | const order = orderNamed(placed.number); | |
| 378 | const at = seen.length; | |
| 379 | ||
| 380 | const response = await pressPayWithCard(shopper, placed); | |
| 381 | expect(response.status).toBe(303); | |
| 382 | const sessionId = sessionIdIn(response.headers.get("location")!); | |
| 383 | expect(sessionId).toMatch(/^cs_test_/); | |
| 384 | expect(response.headers.get("location")).toBe(`${fakeBase()}/checkout/${sessionId}`); | |
| 385 | ||
| 386 | // The browser follows that 303 out of a form POST, and the floor says | |
| 387 | // `form-action 'self'` — which Chromium applies to a form's redirect. This | |
| 388 | // one response names where it hands off, and the internal header that said | |
| 389 | // so does not leave. Nothing else in the floor moves. | |
| 390 | const policy = response.headers.get("content-security-policy")!; | |
| 391 | expect(policy).toContain(`form-action 'self' ${fakeBase()}`); | |
| 392 | expect(policy).toContain("script-src 'none'"); | |
| 393 | expect(policy).toContain("frame-ancestors 'none'"); | |
| 394 | expect(response.headers.get("x-hand-off-to")).toBeNull(); | |
| 395 | ||
| 396 | // And the widening is for that response alone. | |
| 397 | const payAgain = await shopper.get(`/pay/${placed.number}?token=${encodeURIComponent(placed.token)}`); | |
| 398 | expect(payAgain.headers.get("content-security-policy")).toContain("form-action 'self'"); | |
| 399 | expect(payAgain.headers.get("content-security-policy")).not.toContain(fakeBase()); | |
| 400 | ||
| 401 | // Exactly one request, and it is the one the app says it makes. | |
| 402 | const requests = since(at); | |
| 403 | expect(requests.length).toBe(1); | |
| 404 | const request = requests[0]!; | |
| 405 | expect(request.method).toBe("POST"); | |
| 406 | expect(request.path).toBe("/v1/checkout/sessions"); | |
| 407 | expect(request.headers["authorization"]).toBe(`Bearer ${TEST_KEY}`); | |
| 408 | expect(request.headers["idempotency-key"]).toBe(`checkout:${placed.number}`); | |
| 409 | expect(request.headers["content-type"]).toContain("application/x-www-form-urlencoded"); | |
| 410 | ||
| 411 | const form = request.form; | |
| 412 | expect(form.get("mode")).toBe("payment"); | |
| 413 | expect(form.get("client_reference_id")).toBe(placed.number); | |
| 414 | expect(form.get("customer_email")).toBe(order.email); | |
| 415 | expect(form.get("metadata[order_number]")).toBe(placed.number); | |
| 416 | expect(form.get("payment_intent_data[metadata][order_number]")).toBe(placed.number); | |
| 417 | expect(form.get("success_url")).toContain(`/orders/${placed.number}?token=`); | |
| 418 | expect(form.get("cancel_url")).toContain(`/pay/${placed.number}?token=`); | |
| 419 | ||
| 420 | // The lines are the order's own, in the order's currency, and they add up. | |
| 421 | const lines = wireLines(form); | |
| 422 | expect(lines.length).toBe(2); // the one item, then shipping and tax | |
| 423 | expect(lines[0]).toEqual({ | |
| 424 | name: "Ethiopia Guji — 250 g", | |
| 425 | currency: order.currency.toLowerCase(), | |
| 426 | unit_amount: 1900, | |
| 427 | quantity: 2, | |
| 428 | }); | |
| 429 | expect(lines[1]).toEqual({ | |
| 430 | name: "Shipping and tax", | |
| 431 | currency: order.currency.toLowerCase(), | |
| 432 | unit_amount: order.shipping_cents + order.tax_cents, | |
| 433 | quantity: 1, | |
| 434 | }); | |
| 435 | expect(wireTotal(form)).toBe(order.total_cents - order.gift_card_cents); | |
| 436 | expect(wireTotal(form)).toBe(dueOf(placed.number)); | |
| 437 | ||
| 438 | // Nothing has settled: the order waits, and no money is written down. | |
| 439 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 440 | expect(orderNamed(placed.number).payment_ref).toBe(sessionId); | |
| 441 | expect(paymentsOf(placed.number).length).toBe(0); | |
| 442 | }); | |
| 443 | ||
| 444 | /** Issue a gift card the way an admin does, and read its code off the page. */ | |
| 445 | async function issueGiftCard(amount: string, note: string): Promise<string> { | |
| 446 | const staff = await admin(); | |
| 447 | const cards = await staff.page("/admin/giftcards"); | |
| 448 | const issued = await staff.post("/admin/giftcards", { amount, note }, Shopper.tokenIn(cards)); | |
| 449 | expect(issued.status).toBe(200); | |
| 450 | return /<strong>([A-Z0-9-]+)<\/strong> — write it down now/.exec(await issued.text())![1]!; | |
| 451 | } | |
| 452 | ||
| 453 | test("a discount and a gift card come off what Stripe is asked for", async () => { | |
| 454 | const shopper = new Shopper(); | |
| 455 | const code = await issueGiftCard("10.00", "for the Stripe path"); | |
| 456 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }], ["welcome10", code]); | |
| 457 | const order = orderNamed(placed.number); | |
| 458 | expect(order.discount_cents).toBe(660); // 10% of the $66.00 bag | |
| 459 | expect(order.gift_card_cents).toBe(1000); | |
| 460 | ||
| 461 | const at = seen.length; | |
| 462 | expect((await pressPayWithCard(shopper, placed)).status).toBe(303); | |
| 463 | const form = since(at)[0]!.form; | |
| 464 | ||
| 465 | // Stripe is asked for the balance, not the sticker price: a session built | |
| 466 | // from the lines alone would take the discount and the card a second time. | |
| 467 | expect(wireTotal(form)).toBe(order.total_cents - order.gift_card_cents); | |
| 468 | expect(wireTotal(form)).toBe(dueOf(placed.number)); | |
| 469 | expect(wireTotal(form)).toBeLessThan(order.subtotal_cents + order.shipping_cents + order.tax_cents); | |
| 470 | ||
| 471 | // Checkout has no negative line, so the balance goes as one line that says | |
| 472 | // what it is. What the shopper is buying is on our own page and receipt. | |
| 473 | const lines = wireLines(form); | |
| 474 | expect(lines.length).toBe(1); | |
| 475 | expect(lines[0]!.name).toBe(`Order ${placed.number} — 1 line, less WELCOME10, less a gift card`); | |
| 476 | for (const line of lines) { | |
| 477 | expect(line.currency).toBe(order.currency.toLowerCase()); | |
| 478 | expect(Number.isInteger(line.unit_amount)).toBe(true); | |
| 479 | expect(line.unit_amount).toBeGreaterThan(0); | |
| 480 | expect(line.quantity).toBeGreaterThan(0); | |
| 481 | } | |
| 482 | ||
| 483 | // Paying it settles for what was actually asked for, and no more. | |
| 484 | const sessionId = orderNamed(placed.number).payment_ref; | |
| 485 | payAtStripe(sessionId); | |
| 486 | await comeBack(shopper, placed); | |
| 487 | const charges = chargesOf(placed.number); | |
| 488 | expect(charges.length).toBe(1); | |
| 489 | expect(charges[0].amount_cents).toBe(order.total_cents - order.gift_card_cents); | |
| 490 | }); | |
| 491 | ||
| 492 | // --- coming back ------------------------------------------------------------- | |
| 493 | ||
| 494 | test("the shopper coming back settles the order once", async () => { | |
| 495 | const shopper = new Shopper(); | |
| 496 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); | |
| 497 | const started = await pressPayWithCard(shopper, placed); | |
| 498 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 499 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 500 | ||
| 501 | // Back from Stripe before the card has cleared: nothing is written yet. | |
| 502 | const early = await comeBack(shopper, placed); | |
| 503 | expect(early.status).toBe(200); | |
| 504 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 505 | expect(chargesOf(placed.number).length).toBe(0); | |
| 506 | ||
| 507 | payAtStripe(sessionId); | |
| 508 | const at = seen.length; | |
| 509 | const back = await comeBack(shopper, placed); | |
| 510 | expect(back.status).toBe(200); | |
| 511 | expect(await back.text()).toContain("Thank you"); | |
| 512 | expect(since(at).map((one) => `${one.method} ${one.path}`)).toEqual([`GET /v1/checkout/sessions/${sessionId}`]); | |
| 513 | ||
| 514 | const order = orderNamed(placed.number); | |
| 515 | expect(order.status).toBe("paid"); | |
| 516 | expect(order.gateway).toBe("stripe"); | |
| 517 | expect(order.payment_ref).toBe(sessions.get(sessionId)!.payment_intent); | |
| 518 | const charges = chargesOf(placed.number); | |
| 519 | expect(charges.length).toBe(1); | |
| 520 | expect(charges[0].gateway).toBe("stripe"); | |
| 521 | expect(charges[0].gateway_ref).toBe(sessions.get(sessionId)!.payment_intent); | |
| 522 | expect(charges[0].amount_cents).toBe(dueOf(placed.number)); | |
| 523 | ||
| 524 | // A second return reads nothing and writes nothing. | |
| 525 | const again = seen.length; | |
| 526 | expect((await comeBack(shopper, placed)).status).toBe(200); | |
| 527 | expect(since(again).length).toBe(0); | |
| 528 | expect(chargesOf(placed.number).length).toBe(1); | |
| 529 | }); | |
| 530 | ||
| 531 | // --- the webhook ------------------------------------------------------------- | |
| 532 | ||
| 533 | test("a signed webhook pays an order that never came back", async () => { | |
| 534 | const shopper = new Shopper(); | |
| 535 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 2 }]); | |
| 536 | const started = await pressPayWithCard(shopper, placed); | |
| 537 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 538 | payAtStripe(sessionId); | |
| 539 | ||
| 540 | const at = seen.length; | |
| 541 | const delivered = await deliverWebhook(completedEvent(placed.number, sessionId)); | |
| 542 | expect(delivered.status).toBe(200); | |
| 543 | expect(since(at).length).toBe(0); // the event carries everything; nothing is asked | |
| 544 | ||
| 545 | const order = orderNamed(placed.number); | |
| 546 | expect(order.status).toBe("paid"); | |
| 547 | expect(order.payment_ref).toBe(sessions.get(sessionId)!.payment_intent); | |
| 548 | const charges = chargesOf(placed.number); | |
| 549 | expect(charges.length).toBe(1); | |
| 550 | expect(charges[0].amount_cents).toBe(dueOf(placed.number)); | |
| 551 | expect(charges[0].gateway_ref).toBe(sessions.get(sessionId)!.payment_intent); | |
| 552 | ||
| 553 | // And the shopper who finally comes back is shown the order, not a charge. | |
| 554 | expect((await comeBack(shopper, placed)).status).toBe(200); | |
| 555 | expect(chargesOf(placed.number).length).toBe(1); | |
| 556 | }); | |
| 557 | ||
| 558 | test("a webhook after a return leaves exactly one charge", async () => { | |
| 559 | const shopper = new Shopper(); | |
| 560 | const placed = await placeOrder(shopper, [{ sku: "KIT-MUG-OAT", quantity: 1 }]); | |
| 561 | const started = await pressPayWithCard(shopper, placed); | |
| 562 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 563 | payAtStripe(sessionId); | |
| 564 | ||
| 565 | expect((await comeBack(shopper, placed)).status).toBe(200); | |
| 566 | const before = chargesOf(placed.number); | |
| 567 | expect(before.length).toBe(1); | |
| 568 | ||
| 569 | expect((await deliverWebhook(completedEvent(placed.number, sessionId))).status).toBe(200); | |
| 570 | const after = chargesOf(placed.number); | |
| 571 | expect(after.length).toBe(1); | |
| 572 | expect(after[0].id).toBe(before[0].id); | |
| 573 | expect(after[0].amount_cents).toBe(before[0].amount_cents); | |
| 574 | expect(orderNamed(placed.number).status).toBe("paid"); | |
| 575 | ||
| 576 | // An unsigned copy of the same event is not an event. | |
| 577 | const forged = await fetch(app.base + "/webhooks/stripe", { | |
| 578 | method: "POST", | |
| 579 | body: JSON.stringify(completedEvent(placed.number, sessionId)), | |
| 580 | headers: { "content-type": "application/json" }, | |
| 581 | }); | |
| 582 | expect(forged.status).toBe(400); | |
| 583 | expect(chargesOf(placed.number).length).toBe(1); | |
| 584 | }); | |
| 585 | ||
| 586 | // --- refunds ----------------------------------------------------------------- | |
| 587 | ||
| 588 | test("an admin's refund goes to Stripe with the intent, the cents and a key", async () => { | |
| 589 | const shopper = new Shopper(); | |
| 590 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }]); | |
| 591 | const started = await pressPayWithCard(shopper, placed); | |
| 592 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 593 | payAtStripe(sessionId); | |
| 594 | await comeBack(shopper, placed); | |
| 595 | const intent = sessions.get(sessionId)!.payment_intent; | |
| 596 | expect(orderNamed(placed.number).payment_ref).toBe(intent); | |
| 597 | ||
| 598 | const staff = await admin(); | |
| 599 | const detail = await staff.page(`/admin/orders/${placed.number}`); | |
| 600 | const at = seen.length; | |
| 601 | const refunded = await staff.post( | |
| 602 | `/admin/orders/${placed.number}/refund`, | |
| 603 | { amount: "4.00", reason: "a torn bag", restock: "" }, | |
| 604 | Shopper.tokenIn(detail), | |
| 605 | ); | |
| 606 | expect(refunded.headers.get("location")).toContain("m=refunded"); | |
| 607 | ||
| 608 | const requests = since(at); | |
| 609 | expect(requests.length).toBe(1); | |
| 610 | expect(requests[0]!.method).toBe("POST"); | |
| 611 | expect(requests[0]!.path).toBe("/v1/refunds"); | |
| 612 | expect(requests[0]!.form.get("payment_intent")).toBe(intent); | |
| 613 | expect(requests[0]!.form.get("amount")).toBe("400"); | |
| 614 | expect(requests[0]!.headers["idempotency-key"]).toBe(`refund:${placed.number}:0:400`); | |
| 615 | ||
| 616 | const refund = paymentsOf(placed.number).find((row) => row.kind === "refund")!; | |
| 617 | expect(refund.amount_cents).toBe(400); | |
| 618 | expect(refund.gateway).toBe("stripe"); | |
| 619 | expect(refund.gateway_ref).toMatch(/^re_test_/); | |
| 620 | expect(orderNamed(placed.number).status).toBe("partially_refunded"); | |
| 621 | ||
| 622 | // The key says what has already gone back, so the second refund is its own. | |
| 623 | const next = seen.length; | |
| 624 | const second = await staff.post( | |
| 625 | `/admin/orders/${placed.number}/refund`, | |
| 626 | { amount: "1.00", reason: "the rest of the bag", restock: "" }, | |
| 627 | Shopper.tokenIn(detail), | |
| 628 | ); | |
| 629 | expect(second.headers.get("location")).toContain("m=refunded"); | |
| 630 | expect(since(next)[0]!.headers["idempotency-key"]).toBe(`refund:${placed.number}:400:100`); | |
| 631 | expect(paymentsOf(placed.number).filter((row) => row.kind === "refund").length).toBe(2); | |
| 632 | }); | |
| 633 | ||
| 634 | test("a refund Stripe refuses leaves the ledger exactly as it was", async () => { | |
| 635 | const shopper = new Shopper(); | |
| 636 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 1 }]); | |
| 637 | const started = await pressPayWithCard(shopper, placed); | |
| 638 | payAtStripe(sessionIdIn(started.headers.get("location")!)); | |
| 639 | await comeBack(shopper, placed); | |
| 640 | const before = paymentsOf(placed.number); | |
| 641 | expect(before.length).toBe(1); | |
| 642 | ||
| 643 | const staff = await admin(); | |
| 644 | const detail = await staff.page(`/admin/orders/${placed.number}`); | |
| 645 | failing = true; | |
| 646 | const refused = await staff.post( | |
| 647 | `/admin/orders/${placed.number}/refund`, | |
| 648 | { amount: "3.00", reason: "a change of heart", restock: "1" }, | |
| 649 | Shopper.tokenIn(detail), | |
| 650 | ); | |
| 651 | failing = false; | |
| 652 | ||
| 653 | expect(refused.headers.get("location")).toContain("m=refund-failed"); | |
| 654 | const page = await staff.page(`/admin/orders/${placed.number}?m=refund-failed`); | |
| 655 | expect(page).toContain("The gateway refused the refund. Nothing was recorded."); | |
| 656 | expect(page).toContain("Stripe is having a moment."); | |
| 657 | ||
| 658 | // Not a cent moved, and the order is still simply paid. | |
| 659 | expect(paymentsOf(placed.number).map((row) => row.id)).toEqual(before.map((row) => row.id)); | |
| 660 | expect(orderNamed(placed.number).status).toBe("paid"); | |
| 661 | }); | |
| 662 | ||
| 663 | // --- when Stripe is not there ------------------------------------------------ | |
| 664 | ||
| 665 | test("Stripe answering 500 leaves the order unpaid and says so in a sentence", async () => { | |
| 666 | const shopper = new Shopper(); | |
| 667 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); | |
| 668 | failing = true; | |
| 669 | const response = await pressPayWithCard(shopper, placed); | |
| 670 | failing = false; | |
| 671 | ||
| 672 | expect(response.status).toBe(402); | |
| 673 | const body = await response.text(); | |
| 674 | expect(body).toContain("Stripe is having a moment."); | |
| 675 | expect(body).toContain("<h1>Payment</h1>"); | |
| 676 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 677 | expect(paymentsOf(placed.number).length).toBe(0); | |
| 678 | }); | |
| 679 | ||
| 680 | test("Stripe unreachable leaves the order unpaid and says so in a sentence", async () => { | |
| 681 | const shopper = new Shopper(); | |
| 682 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); | |
| 683 | ||
| 684 | const at = seen.length; | |
| 685 | fake.stop(true); | |
| 686 | const response = await pressPayWithCard(shopper, placed); | |
| 687 | fake = startFake(FAKE_PORT); | |
| 688 | ||
| 689 | expect(response.status).toBe(402); | |
| 690 | expect(await response.text()).toContain("Stripe could not be reached."); | |
| 691 | expect(since(at).length).toBe(0); | |
| 692 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 693 | expect(paymentsOf(placed.number).length).toBe(0); | |
| 694 | ||
| 695 | // And the shopper can still pay it once Stripe is back. | |
| 696 | const again = await pressPayWithCard(shopper, placed); | |
| 697 | expect(again.status).toBe(303); | |
| 698 | expect(again.headers.get("location")).toContain(fakeBase()); | |
| 699 | }); | |
| 700 | ||
| 701 | test("a live key is refused and no request reaches Stripe", async () => { | |
| 702 | const shopper = new Shopper(); | |
| 703 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]); | |
| 704 | ||
| 705 | // Same order, same database, an app whose key is a live one. | |
| 706 | const payPage = await shopper.page(`/pay/${placed.number}?token=${encodeURIComponent(placed.token)}`, liveApp); | |
| 707 | expect(payPage).toContain("Mock gateway"); | |
| 708 | expect(payPage).toContain("refuses to talk to live Stripe"); | |
| 709 | ||
| 710 | const at = seen.length; | |
| 711 | const response = await shopper.post( | |
| 712 | `/pay/${placed.number}/stripe`, | |
| 713 | { token: placed.token }, | |
| 714 | Shopper.tokenIn(payPage), | |
| 715 | liveApp, | |
| 716 | ); | |
| 717 | expect(response.status).toBe(402); | |
| 718 | expect(await response.text()).toContain("Stripe is not configured; use the mock gateway."); | |
| 719 | expect(since(at).length).toBe(0); | |
| 720 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 721 | expect(paymentsOf(placed.number).length).toBe(0); | |
| 722 | }); |
src/units.test.ts +19 −2
| @@ -6,10 +6,10 @@ import { join } from "node:path"; | ||
| 6 | 6 | // A database of its own: these tests write rows, and never into a real shop. |
| 7 | 7 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-units-")); |
| 8 | 8 | |
| 9 | const { formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts"); | |
| 9 | const { dueCents, formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts"); | |
| 10 | 10 | const { encode, decode, put, add, quote } = await import("./cart.ts"); |
| 11 | 11 | const { sign, unsign, loadSecret } = await import("./sign.ts"); |
| 12 | const { verifyWebhook, chosen } = await import("./gateway.ts"); | |
| 12 | const { apiBase, verifyWebhook, chosen } = await import("./gateway.ts"); | |
| 13 | 13 | const limit = await import("./ratelimit.ts"); |
| 14 | 14 | const { escape } = await import("./html.ts"); |
| 15 | 15 | const catalog = await import("./catalog.ts"); |
| @@ -40,6 +40,23 @@ test("money is integer cents in and out", () => { | ||
| 40 | 40 | expect(sumLines([{ unit_price_cents: 199, quantity: 3 }, { unit_price_cents: 50, quantity: 2 }])).toBe(697); |
| 41 | 41 | }); |
| 42 | 42 | |
| 43 | test("what is due is the total less the gift card, and nothing else", () => { | |
| 44 | expect(dueCents({ total_cents: 6045, gift_card_cents: 1000 })).toBe(5045); | |
| 45 | expect(dueCents({ total_cents: 6045, gift_card_cents: 0 })).toBe(6045); | |
| 46 | // A card that covered the lot leaves nothing for a gateway to take. | |
| 47 | expect(dueCents({ total_cents: 1200, gift_card_cents: 1200 })).toBe(0); | |
| 48 | }); | |
| 49 | ||
| 50 | test("Stripe's address is Stripe's, unless the seam points at this machine", () => { | |
| 51 | expect(apiBase({})).toBe("https://api.stripe.com"); | |
| 52 | expect(apiBase({ STRIPE_API_BASE: "http://127.0.0.1:4242" })).toBe("http://127.0.0.1:4242"); | |
| 53 | expect(apiBase({ STRIPE_API_BASE: "http://localhost:4242" })).toBe("http://localhost:4242"); | |
| 54 | // A secret key does not go wherever an environment variable says. | |
| 55 | expect(apiBase({ STRIPE_API_BASE: "https://api.stripe.com.example.test" })).toBe("https://api.stripe.com"); | |
| 56 | expect(apiBase({ STRIPE_API_BASE: "http://127.0.0.1.example.test" })).toBe("https://api.stripe.com"); | |
| 57 | expect(apiBase({ STRIPE_API_BASE: "not a url" })).toBe("https://api.stripe.com"); | |
| 58 | }); | |
| 59 | ||
| 43 | 60 | test("a signature is required to believe a payload", () => { |
| 44 | 61 | const secret = "a-secret-that-is-long-enough"; |
| 45 | 62 | const signed = sign(secret, "hello"); |
The fake Stripe is the right idea and it is well built: the three endpoints this app calls and nothing else, Stripe's own idempotency-key replay, every request recorded, the real app driven over HTTP in its own process with its own
FORGE_APP_DATA, ten tests in about a second, stable across repeated runs, and nothing leaving the machine. ThecheckoutLineschange is more than a test, too: before it, an order with a discount or a gift card was sent to Stripe at its sticker price and the reduction came off twice. That fix alone is worth landing.It does not land as it is, though.
Fix first: this branch is cut from
5991215, one commit behindmain, andmaindeleted the door the new tests sign in through. #13 removed the app's own password sign-in; identity now comes from the forge's signed headers. Theadmin()helper insrc/stripe.test.tsstill postsADMIN_PASSWORDto/admin/signin. The forge calls this pull mergeable because the conflict is not textual, but I mergedorigin/maininto the head commit in a scratch clone and ran the suite: 47 pass, 3 fail —a discount and a gift card come off what Stripe is asked for,an admin's refund goes to Stripe with the intent, the cents and a key, anda refund Stripe refuses leaves the ledger exactly as it was, each failing whereexpect(signedIn.status).toBe(303)receives404. The green check on this pull is green against amainthat no longer exists. The preview shows the same staleness from outside:GET /adminthere answers401with a password form, wheremainanswers403with a link to/__forge/signin.The fix is a rebase on
mainplus anadmin()that stamps the forge's headers the waysrc/server.test.tsandsrc/who.test.tsalready do: setFORGE_APP_IDENTITY_KEYin the child'sboot()environment, and sendx-forge-identityas<id>|<name>|admin|<expiry>withx-forge-signatureasstamp(key, identity)on the staffShopper.ADMIN_PASSWORDthen leaves the file entirely, and nothing else in it should need to move.Two smaller things, both worth doing while you are in here:
orders.dueCentsistotal_cents - gift_card_cents, andcheckoutLinescomputes that same subtraction again insrc/gateway.ts. The new test asserts the two agree today, which is exactly the kind of agreement that stops being true quietly.orders.tsdoes not importgateway.ts, so importdueCentsthere (or move it intomoney.ts) and let one module own what Stripe is asked for.pressPayWithCardusesredirect: "manual"and asserts theLocationheader, but in a real browser this is a form POST to our own origin answering 303 tocheckout.stripe.com, and the security floor insrc/server.tssetsform-action 'self'. Chromium appliesform-actionto the redirect target of a form navigation, so the hand-off to Stripe's hosted page may be refused in exactly the configuration this pull calls proven — and the preview cannot show it, because it has noSTRIPE_SECRET_KEYand falls back to the mock. Try it once with a test key in a real Chrome; if it is refused, widenform-actionto'self' https://checkout.stripe.comon the payment response only, never across the site.One thing I noticed and am not asking you to fix here: when a gift card leaves a balance under Stripe's minimum charge — say thirty cents — real Stripe refuses the session with a 400 and the shopper has no way left to pay, while the fake takes any positive amount, so no test can catch it.
orders.tsalready settles the zero-balance case, so this is only the sliver between zero and the minimum, and it deserves its own issue rather than this pull.The rest holds up. The preview is healthy:
/in about 270 ms, CSP,nosniffandReferrer-Policyon every response, and a guest walked catalog to cart to checkout to mock approve to aPaidreceipt without a stumble. The README and the comments are accurate about what is real and what is simulated. Come back with the rebase and those three admin tests green againstmain, and I will approve.