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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/14-stripe-s-own-path-is-proven-a-fake-strip → main 8 commits fork 2 threads · 2 unresolved

  • Merged. ledger/14-stripe-s-own-path-is-proven-a-fake-strip was merged into main as 96e79b6.

Review

Sign in to review this change.
12 files changed +892 −38
README.md +24
@@ -82,6 +82,7 @@ cart with all three against the order it becomes.
8282 | `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`. |
8383 | `STRIPE_SECRET_KEY=sk_live_…` | Refused. The store says so on the payment page and uses the mock instead. |
8484 | 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. |
8586
8687 With Stripe, an order settles either when the shopper comes back (the session
8788 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
9091 the ledger second, with an idempotency key built from the order and what has
9192 already been refunded.
9293
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
93115 ## Environment
94116
95117 | Variable | Meaning |
@@ -98,6 +120,7 @@ already been refunded.
98120 | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. |
99121 | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. |
100122 | `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`. |
101124
102125 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
103126 `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.
139162 | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox |
140163 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
141164 | `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 |
142166
143167 ## The terminal
144168
src/gateway.ts +61 −17
@@ -12,6 +12,7 @@
1212 */
1313
1414 import { createHmac, timingSafeEqual } from "node:crypto";
15 import { dueCents } from "./money.ts";
1516 import type { Order, OrderItem } from "./db.ts";
1617
1718 export type GatewayKind = "stripe" | "mock";
@@ -22,7 +23,25 @@ export type GatewayInfo = {
2223 note?: string;
2324 };
2425
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();
2645
2746 export function chosen(): GatewayInfo {
2847 const key = process.env.STRIPE_SECRET_KEY ?? "";
@@ -50,7 +69,7 @@ async function stripe<T>(path: string, form: Record<string, string>, idempotency
5069 };
5170 if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
5271 try {
53 const response = await fetch(`${API}${path}`, {
72 const response = await fetch(`${api()}${path}`, {
5473 method: "POST",
5574 headers,
5675 body: new URLSearchParams(form).toString(),
@@ -68,7 +87,7 @@ async function stripeGet<T>(path: string): Promise<StripeResult<T>> {
6887 const key = process.env.STRIPE_SECRET_KEY ?? "";
6988 if (!key) return { ok: false, error: "Stripe is not configured." };
7089 try {
71 const response = await fetch(`${API}${path}`, {
90 const response = await fetch(`${api()}${path}`, {
7291 headers: { authorization: `Bearer ${key}` },
7392 signal: AbortSignal.timeout(15000),
7493 });
@@ -82,9 +101,42 @@ async function stripeGet<T>(path: string): Promise<StripeResult<T>> {
82101
83102 export type Checkout = { id: string; url: string };
84103
104 type Line = { name: string; unit_amount: number; quantity: number };
outdated

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. The checkoutLines change 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 behind main, and main deleted 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. The admin() helper in src/stripe.test.ts still posts ADMIN_PASSWORD to /admin/signin. The forge calls this pull mergeable because the conflict is not textual, but I merged origin/main into 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, and a refund Stripe refuses leaves the ledger exactly as it was, each failing where expect(signedIn.status).toBe(303) receives 404. The green check on this pull is green against a main that no longer exists. The preview shows the same staleness from outside: GET /admin there answers 401 with a password form, where main answers 403 with a link to /__forge/signin.

The fix is a rebase on main plus an admin() that stamps the forge's headers the way src/server.test.ts and src/who.test.ts already do: set FORGE_APP_IDENTITY_KEY in the child's boot() environment, and send x-forge-identity as <id>|<name>|admin|<expiry> with x-forge-signature as stamp(key, identity) on the staff Shopper. ADMIN_PASSWORD then leaves the file entirely, and nothing else in it should need to move.

Two smaller things, both worth doing while you are in here:

  1. The money rule is now written twice. orders.dueCents is total_cents - gift_card_cents, and checkoutLines computes that same subtraction again in src/gateway.ts. The new test asserts the two agree today, which is exactly the kind of agreement that stops being true quietly. orders.ts does not import gateway.ts, so import dueCents there (or move it into money.ts) and let one module own what Stripe is asked for.
  1. The one hop the test cannot see is the browser's. pressPayWithCard uses redirect: "manual" and asserts the Location header, but in a real browser this is a form POST to our own origin answering 303 to checkout.stripe.com, and the security floor in src/server.ts sets form-action 'self'. Chromium applies form-action to 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 no STRIPE_SECRET_KEY and falls back to the mock. Try it once with a test key in a real Chrome; if it is refused, widen form-action to 'self' https://checkout.stripe.com on 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.ts already 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, nosniff and Referrer-Policy on every response, and a guest walked catalog to cart to checkout to mock approve to a Paid receipt 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 against main, and I will approve.

105
85106 /**
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.
88140 */
89141 export async function startCheckout(
90142 order: Order,
@@ -100,20 +152,12 @@ export async function startCheckout(
100152 "payment_intent_data[metadata][order_number]": order.number,
101153 "metadata[order_number]": order.number,
102154 };
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);
105157 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;
108160 });
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 }
117161 return stripe<Checkout>("/v1/checkout/sessions", form, `checkout:${order.number}`);
118162 }
119163
src/http.ts +25
@@ -13,6 +13,31 @@ export const text = (body: string, status = 200): Response =>
1313 export const redirect = (location: string, status: 302 | 303 = 303): Response =>
1414 new Response(null, { status, headers: { location } });
1515
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
1641 /**
1742 * A form field, cleaned at the boundary: control characters out, trimmed,
1843 * length capped. Every handler in this app reads fields through this.
src/mail.ts +2 −2
@@ -6,7 +6,7 @@
66 */
77
88 import { db, type Order, type OrderItem } from "./db.ts";
9 import { formatCents } from "./money.ts";
9 import { dueCents, formatCents } from "./money.ts";
1010
1111 export type Email = {
1212 id: number;
@@ -28,7 +28,7 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str
2828 item.unit_price_cents * item.quantity,
2929 ).padStart(10)}`,
3030 );
31 const due = order.total_cents - order.gift_card_cents;
31 const due = dueCents(order);
3232 return [
3333 `Thank you — your order ${order.number} is confirmed.`,
3434 "",
src/money.ts +9
@@ -32,6 +32,15 @@ export function parseQuantity(input: unknown, max = 99): number {
3232 return Math.min(n, max);
3333 }
3434
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
3544 /** Totals are recomputed from lines, never carried. */
3645 export function sumLines(lines: { unit_price_cents: number; quantity: number }[]): number {
3746 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";
1616 import { randomBytes } from "node:crypto";
1717 import { db, type Order, type OrderItem, type Payment } from "./db.ts";
1818 import { addStock, takeStock } from "./inventory.ts";
19 import { sumLines } from "./money.ts";
19 import { dueCents, sumLines } from "./money.ts";
2020 import { quote, type CartLine, type Quote } from "./cart.ts";
2121 import { writeReceipt } from "./mail.ts";
2222 import type { Applied } from "./codes.ts";
@@ -80,9 +80,6 @@ export const rememberGatewayRef = (order: Order, ref: string): void => {
8080 db.query('UPDATE "order" SET payment_ref = ?2 WHERE id = ?1 AND status = \'pending_payment\'').run(order.id, ref);
8181 };
8282
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
8683 const sumPayments = (orderId: number, kind: "charge" | "refund"): number =>
8784 db
8885 .query<{ total: number }, [number, string]>(
src/pages/checkout.ts +3 −3
@@ -6,7 +6,7 @@
66 */
77
88 import { csrfField, escape } from "../html.ts";
9 import { formatCents } from "../money.ts";
9 import { dueCents, formatCents } from "../money.ts";
1010 import { codeBox, totals } from "./cart.ts";
1111 import type { Quote } from "../cart.ts";
1212 import type { Order, OrderItem, Payment } from "../db.ts";
@@ -121,7 +121,7 @@ export const orderTotals = (order: Order, refunded = 0): string => `<div class="
121121 formatCents(order.gift_card_cents),
122122 )}</span></div>
123123 <div class="row"><span>Charged</span><span class="price">${escape(
124 formatCents(order.total_cents - order.gift_card_cents),
124 formatCents(dueCents(order)),
125125 )}</span></div>`
126126 : ""
127127 }
@@ -141,7 +141,7 @@ export function pay(
141141 gateway: GatewayInfo,
142142 error?: string,
143143 ): string {
144 const due = order.total_cents - order.gift_card_cents;
144 const due = dueCents(order);
145145 const box =
146146 gateway.kind === "stripe"
147147 ? `<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 () => {
191191 expect(body).toContain("Ethiopia Guji");
192192 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
193193 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("");
194197 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
195198 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
196199 });
src/server.ts +14 −3
@@ -28,7 +28,7 @@ import { notFound } from "./shop.ts";
2828 import { seed } from "./catalog.ts";
2929 import { seed as seedDiscounts } from "./discounts.ts";
3030 import { seed as seedTax } from "./tax.ts";
31 import { text } from "./http.ts";
31 import { HAND_OFF, text } from "./http.ts";
3232 import { whoIs } from "./who.ts";
3333 import type { Who } from "./who.ts";
3434 import type { Ctx } from "./context.ts";
@@ -44,9 +44,16 @@ const env = process.env.FORGE_APP_ENV ?? "local";
4444 // The security floor every response stands on. Scripts and styles come from
4545 // this origin (inline styles allowed, inline scripts not), nothing frames us,
4646 // 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
4755 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'"),
5057 "x-content-type-options": "nosniff",
5158 "referrer-policy": "strict-origin-when-cross-origin",
5259 "permissions-policy": "camera=(), microphone=(), geolocation=()",
@@ -67,7 +74,11 @@ const sameOrigin = (request: Request): boolean => {
6774 /** Every response leaves through here, so none of them can miss the floor. */
6875 function finish(response: Response, cookies: string[]): Response {
6976 const headers = new Headers(response.headers);
77 const handOffTo = headers.get(HAND_OFF);
7078 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 main, admin() in src/stripe.test.ts stamps the forge's signed identity instead of posting a password, and dueCents now lives in src/money.ts where the payment page, the receipt, the mock, Stripe and orders.ts all read it. I checked all three rather than took them: bun test at this head is 52 pass / 0 fail, five runs in a row, no flake; the preview's /admin answers 403 with a link to /__forge/signin?to=%2Fadmin, so it is current.

The suite is real, not decorative. Reverting the collapse in checkoutLines (const reduced = false) fails a discount and a gift card come off what Stripe is asked for with Expected: 6045 Received: 7705 — the overcharge it was written for — and deleting the widening in finish() fails the Checkout Session test. On the preview I bought a $258 grinder with welcome10, paid through the mock and got the receipt; the floor on / is intact. The dueCents fix is the most valuable thing in this pull and I want it landed.

One finding is in the way, and it is on the money path.

1. The form-action widening is on the wrong response (fix this first)

handOff() puts the Stripe origin in the CSP of the 303 itself. A redirect does not become a document and its Content-Security-Policy header is applied to nothing. When a browser follows a form navigation through a redirect, the policy it checks each redirect target against is the policy of the document that contained the form: HTML's navigate algorithm carries the source snapshot params' policy container through every hop, so form-action is always the initiator's, never the redirect's. The initiator here is /pay/<number>, and src/stripe.test.ts:402 pins that page at form-action 'self' and asserts the Stripe origin is not on it. So the header moves and Chromium still blocks the hop to checkout.stripe.com: with a test key configured, a shopper with a card cannot reach Checkout.

It is not a regression — a plain redirect() was blocked the same way — but the README, the comments in src/server.ts and src/http.ts, and a passing test now all say the hop is fixed, and the price of that claim is a magic response header that can widen the security floor from any handler. The preview cannot settle it either way: it has no STRIPE_SECRET_KEY, /pay offers the mock, and the hand-off is never taken.

Concrete fix: keep csp(formAction) — that factoring is good — and put the widening on the payment page. Render /pay/<number> with form-action 'self' https://checkout.stripe.com when chosen().kind === "stripe", and leave every other response on 'self'. Then handOff(), HAND_OFF and the branch in finish() all go away and the floor is extended in one named place instead of by a header riding on a response. Turn the assertion around too: /pay carries the Stripe origin, everything else does not. If you can put a real Chrome in front of a branch with a test key and show the 303 being followed as written today, I will take that over my reading of the spec and approve as is.

2. A gift card can now leave an amount Stripe will not take

Stripe refuses a charge under about $0.50. giftCardCents = min(balance, total) in src/cart.ts, so a $60.00 card against a $60.45 order leaves 45¢ due — and dueCents is now what Stripe is asked for, so the session comes back Amount must be at least…, the order sits pending_payment, and the card's balance was already spent inside place(). Before this pull that order was overcharged $60.00 instead, so this is strictly better, but it is a new dead end on the one path that matters. Either cap what a card may pay so the remainder is zero or at least the minimum, or send a sub-minimum balance down the payWithGiftCard path that already exists. A test in the shape of the discount one would pin it.

3. Smaller things, none of them blocking

  • src/cart.ts still computes due_cents: total - giftCardCents by hand. That was the point of moving dueCents into money.ts; quote() should call it.
  • STRIPE_API_BASE as a loopback-only seam is a fair trade, the README says why, and src/units.test.ts pins the refusals including 127.0.0.1.example.test. No objection from me.
  • src/stripe.test.ts runs two app processes plus its own Database handle against one SQLite file and restarts the fake on the port it just released. Stable here over five runs; if it ever goes red with SQLITE_BUSY or EADDRINUSE, that is where to look.

Fix 1 and this lands. The fake Stripe and the overcharge fix are worth having whichever way the hand-off goes.

81 headers.delete(HAND_OFF);
7182 if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
7283 for (const value of cookies) headers.append("set-cookie", value);
7384 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";
1717 import * as pages from "./pages/checkout.ts";
1818 import * as storefront from "./pages/storefront.ts";
1919 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";
2121 import { render, type Ctx } from "./context.ts";
2222 import { page as cartPage } from "./pages/cart.ts";
2323 import { chosen } from "./gateway.ts";
2424 import { equals, sign, unsign } from "./sign.ts";
2525 import { forOrder } from "./mail.ts";
26 import { parseQuantity } from "./money.ts";
26 import { dueCents, parseQuantity } from "./money.ts";
2727 import { escape } from "./html.ts";
2828 import type { Order } from "./db.ts";
2929
@@ -208,7 +208,7 @@ export function placeOrder(ctx: Ctx): Response {
208208 if (!result.ok) return checkoutForm(ctx, result.error, values);
209209 saveCart(ctx, []);
210210 saveCodes(ctx, codes.NONE);
211 const where = orders.dueCents(result.order) === 0 ? "orders" : "pay";
211 const where = dueCents(result.order) === 0 ? "orders" : "pay";
212212 return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`);
213213 }
214214
@@ -254,7 +254,7 @@ export function payMock(ctx: Ctx): Response {
254254 const paid = orders.markPaid(order, {
255255 gateway: "mock",
256256 ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
257 amount_cents: orders.dueCents(order),
257 amount_cents: dueCents(order),
258258 });
259259 return redirect(`/orders/${paid.number}?token=${encodeURIComponent(paid.access_token)}`);
260260 }
@@ -282,7 +282,9 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
282282 if (!session.ok) return payPageFor(ctx, order, session.error);
283283 orders.note(order.id, "checkout-started", session.value.id);
284284 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);
286288 }
287289
288290 // --- the order --------------------------------------------------------------
@@ -320,7 +322,7 @@ async function settleStripe(order: Order): Promise<Order> {
320322 return orders.markPaid(order, {
321323 gateway: "stripe",
322324 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),
324326 });
325327 }
326328
@@ -347,7 +349,7 @@ export async function stripeWebhook(ctx: Ctx): Promise<Response> {
347349 orders.markPaid(order, {
348350 gateway: "stripe",
349351 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),
351353 });
352354 }
353355 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";
66 // A database of its own: these tests write rows, and never into a real shop.
77 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-units-"));
88
9 const { formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts");
9 const { dueCents, formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts");
1010 const { encode, decode, put, add, quote } = await import("./cart.ts");
1111 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");
1313 const limit = await import("./ratelimit.ts");
1414 const { escape } = await import("./html.ts");
1515 const catalog = await import("./catalog.ts");
@@ -40,6 +40,23 @@ test("money is integer cents in and out", () => {
4040 expect(sumLines([{ unit_price_cents: 199, quantity: 3 }, { unit_price_cents: 50, quantity: 2 }])).toBe(697);
4141 });
4242
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
4360 test("a signature is required to believe a payload", () => {
4461 const secret = "a-secret-that-is-long-enough";
4562 const signed = sign(secret, "hello");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs