| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { DatabaseSync, type SQLInputValue } from "node:sqlite"; |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import Stripe from "stripe"; |
| 5 | import { handleMerch } from "./server"; |
| 6 | import { operatorAction, supplierRequest } from "./operator"; |
| 7 | import { parseConfig, reviewedRoute, validateQuoteInput, priceQuote } from "./model"; |
| 8 | import { destinationRate, yoycolHeaders, yoycolRequest } from "./yoycol"; |
| 9 | import type { MerchConfig, MerchDatabase, MerchEnv, D1Statement, Quote } from "./types"; |
| 10 | |
| 11 | class Sqlite implements MerchDatabase { |
| 12 | db = new DatabaseSync(":memory:"); |
| 13 | constructor() { this.db.exec(readFileSync(new URL("../../migrations/merch/0001_merch.sql", import.meta.url), "utf8")); } |
| 14 | prepare(sql: string): D1Statement { |
| 15 | let values: SQLInputValue[] = []; |
| 16 | const result: D1Statement = { |
| 17 | bind: (...args) => { values = args as SQLInputValue[]; return result; }, |
| 18 | first: async <T>() => this.db.prepare(sql).get(...values) as T ?? null, |
| 19 | all: async <T>() => ({ results: this.db.prepare(sql).all(...values) as T[] }), |
| 20 | run: async () => ({ meta: { changes: Number(this.db.prepare(sql).run(...values).changes) } }), |
| 21 | }; |
| 22 | return result; |
| 23 | } |
| 24 | async batch(statements: D1Statement[]) { |
| 25 | this.db.exec("BEGIN"); |
| 26 | try { const result = []; for (const statement of statements) result.push(await statement.run()); this.db.exec("COMMIT"); return result; } |
| 27 | catch (error) { this.db.exec("ROLLBACK"); throw error; } |
| 28 | } |
| 29 | } |
| 30 | const input = { productId: "whale-bro", size: "XL", color: "White", quantity: 1, country: "US", address: { name: "Sample Tester", line1: "1 Test Street", line2: "", city: "Test City", region: "CA", postalCode: "94107", phone: "+15555550100" } }; |
| 31 | function configuration(): MerchConfig { |
| 32 | return { mode: "test", checkoutEnabled: true, supplierSubmissionEnabled: false, siteOrigin: "http://localhost:3187", countries: { US: { regionCode: "US", shippingLevelCode: "FIXTURE", currency: "usd", usdExchangeRate: 1, feeRate: .054, flatFeeUsd: .30, taxReviewed: true, taxNote: "Fixture: import charges excluded.", supplierPricesUsdReviewed: true, addressPolicyReviewed: true, allowedPostalPrefixes: ["941"], blockedPostalPrefixes: ["94199"] } }, products: { "whale-bro": { US: { approved: true, designCode: "fixture-design", skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } }, contributor: { US: { approved: true, designCode: "", contributorDesigns: { en: ["fixture-en-0", "fixture-en-1", "fixture-en-2"] }, skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } } } }; |
| 33 | } |
| 34 | let db: Sqlite, env: MerchEnv, config: MerchConfig; |
| 35 | let currentSession: Stripe.Checkout.Session; |
| 36 | let sent: { url: string; method: string; body: string; idempotency: string | null }[]; |
| 37 | let supplierFailure = false; |
| 38 | beforeEach(() => { |
| 39 | db = new Sqlite(); config = configuration(); sent = []; supplierFailure = false; |
| 40 | env = { MERCH_DB: db, MERCH_RATE_LIMITER: { limit: async () => ({ success: true }) }, MERCH_CONFIG_JSON: JSON.stringify(config), MERCH_STRIPE_KEY: "sk_test_fixture", MERCH_STRIPE_WEBHOOK_SECRET: "whsec_fixture", MERCH_PAYMENT_CONFIGURATION: "pmc_fixture", YOYCOL_ACCESS_KEY: "fixture-key", YOYCOL_SECRET_KEY: "fixture-secret" }; |
| 41 | currentSession = { object: "checkout.session", id: "cs_test_fixture", livemode: false, metadata: {}, client_reference_id: "", currency: "usd", amount_total: 1725, payment_status: "unpaid", status: "complete", url: "https://checkout.stripe.com/c/pay/fixture" } as Stripe.Checkout.Session; |
| 42 | vi.stubGlobal("fetch", vi.fn(async (url: URL | string, init?: RequestInit) => { |
| 43 | const u = new URL(String(url)), method = init?.method ?? "GET", body = String(init?.body ?? ""); |
| 44 | sent.push({ url: u.href, method, body, idempotency: new Headers(init?.headers).get("idempotency-key") }); |
| 45 | if (u.hostname === "www.yoycol.com") { |
| 46 | if (method === "POST") { |
| 47 | if (supplierFailure) throw new Error("ambiguous connection loss"); |
| 48 | const value = JSON.parse(body); |
| 49 | return Response.json({ code: "100000", data: { orderId: 42, storeOrderSn: value.storeOrderSn, currency: "USD", orderRealAmount: 7.37, canPay: true } }); |
| 50 | } |
| 51 | return Response.json({ code: "100000", data: [{ regionCode: "US", levels: [{ levelCode: "FIXTURE", levelName: "Fixture service", firstPrice: 2.25, additionalPrice: .75 }] }] }); |
| 52 | } |
| 53 | if (u.hostname === "api.stripe.com") { |
| 54 | if (method === "POST") { |
| 55 | const data = new URLSearchParams(body); |
| 56 | currentSession.client_reference_id = data.get("client_reference_id"); |
| 57 | currentSession.metadata = { merch_order_id: data.get("metadata[merch_order_id]")!, merch_product_id: data.get("metadata[merch_product_id]")! }; |
| 58 | currentSession.currency = data.get("line_items[0][price_data][currency]"); |
| 59 | currentSession.amount_total = Number(data.get("line_items[0][price_data][unit_amount]")) + Number(data.get("line_items[1][price_data][unit_amount]")); |
| 60 | } |
| 61 | return Response.json(currentSession); |
| 62 | } |
| 63 | throw new Error("No unmocked network is permitted in commerce tests"); |
| 64 | })); |
| 65 | }); |
| 66 | afterEach(() => { vi.unstubAllGlobals(); db.db.close(); }); |
| 67 | function request(action: string, data: unknown, origin = config.siteOrigin) { |
| 68 | return new Request("http://localhost:3187/api/merch/" + action, { method: "POST", headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify(data) }); |
| 69 | } |
| 70 | async function quoted() { |
| 71 | const response = await handleMerch(request("quote", input), "quote", env); |
| 72 | expect(response.status).toBe(200); |
| 73 | return response.json() as Promise<Quote>; |
| 74 | } |
| 75 | async function checkout() { |
| 76 | const quote = await quoted(); |
| 77 | const response = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env); |
| 78 | expect(response.status).toBe(200); return quote; |
| 79 | } |
| 80 | async function event(type = "checkout.session.completed", eventId = "evt_fixture", valid = true, livemode = config.mode === "live") { |
| 81 | const payload = JSON.stringify({ id: eventId, object: "event", livemode, type, created: Math.floor(Date.now()/1000), data: { object: { id: currentSession.id, metadata: currentSession.metadata } } }); |
| 82 | const signature = Stripe.webhooks.generateTestHeaderString({ payload, secret: "whsec_fixture" }); |
| 83 | return handleMerch(new Request("http://localhost/api/merch/webhook", { method: "POST", body: payload, headers: { "stripe-signature": valid ? signature : "invalid" } }), "webhook", env); |
| 84 | } |
| 85 | function state(id: string) { return db.db.prepare("SELECT payment_state,fulfillment_state FROM merch_orders WHERE id=?").get(id); } |
| 86 | |
| 87 | describe("commerce trust boundaries", () => { |
| 88 | it("fails closed without storage, keys or reviewed rates", async () => { |
| 89 | expect(await (await handleMerch(new Request("http://localhost/status"), "status", {})).json()).toEqual({ checkoutConfigured: false, readyProductIds: [] }); |
| 90 | expect((await handleMerch(request("quote", input), "quote", {})).status).toBe(503); |
| 91 | config.countries.US.addressPolicyReviewed = false; env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 92 | expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(409); |
| 93 | expect(sent).toHaveLength(0); |
| 94 | }); |
| 95 | it("accepts only a real catalog variant and bounded quantity", () => { |
| 96 | for (const value of [{ ...input, productId: "deskmat" }, { ...input, productId: "field" }, { ...input, quantity: 0 }, { ...input, quantity: 6 }, { ...input, size: "10XL" }, { ...input, color: "Black" }, { ...input, country: "ZZ" }]) expect(() => validateQuoteInput(value)).toThrow(); |
| 97 | expect(validateQuoteInput(input).size).toBe("XL"); |
| 98 | }); |
| 99 | it("rejects remote/unreviewed postcodes and unapproved contributor print languages", () => { |
| 100 | expect(() => reviewedRoute(config, validateQuoteInput({ ...input, address: { ...input.address, postalCode: "94199" } }))).toThrow(); |
| 101 | const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "", contribution: "https://github.com/codewhale-hq/CodeWhale/pull/123", language: "zh", phraseIndex: 1 } }); |
| 102 | expect(() => reviewedRoute(config, c)).toThrow(); |
| 103 | c.contributor!.language = "en"; |
| 104 | expect(reviewedRoute(config, c).designCode).toBe("fixture-en-1"); |
| 105 | }); |
| 106 | it("rejects non-boolean activation and insecure production origins", () => { |
| 107 | expect(parseConfig(JSON.stringify({ ...config, checkoutEnabled: "true" }))).toBeNull(); |
| 108 | expect(parseConfig(JSON.stringify({ ...config, mode: "live" }))).toBeNull(); |
| 109 | expect(parseConfig(JSON.stringify({ ...config, siteOrigin: "ftp://localhost" }))).toBeNull(); |
| 110 | }); |
| 111 | it("requires same origin and a shared rate limit before contacting a supplier", async () => { |
| 112 | expect((await handleMerch(request("quote", input, "https://other.example"), "quote", env)).status).toBe(403); |
| 113 | env.MERCH_RATE_LIMITER = { limit: async () => ({ success: false }) }; |
| 114 | expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(429); |
| 115 | expect(sent).toHaveLength(0); |
| 116 | }); |
| 117 | it("bounds streamed requests and ignores client prices", async () => { |
| 118 | expect((await handleMerch(request("quote", { ...input, extra: "x".repeat(9000) }), "quote", env)).status).toBe(413); |
| 119 | const response = await handleMerch(request("quote", { ...input, total: 1 }), "quote", env); |
| 120 | expect((await response.json()).total).toBe(1725); |
| 121 | }); |
| 122 | it("grosses contributor costs up without a profit reserve", () => { |
| 123 | const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "123456", contribution: "", language: "en", phraseIndex: 0 } }); |
| 124 | const priced = priceQuote(c, config.countries.US, 5.12, 2.25); |
| 125 | expect(priced).toEqual({ merchandise: 512, shipping: 225, processing: 74, total: 811 }); |
| 126 | expect(priceQuote(validateQuoteInput(input), config.countries.US, 5.12, 2.25).processing).toBe(0); |
| 127 | expect(() => priceQuote(validateQuoteInput(input), config.countries.US, 14, 2.25)).toThrow("price review"); |
| 128 | }); |
| 129 | it("selects the exact supplier region/service and additional-piece price", () => { |
| 130 | const rates = [{ regionCode: "US", levels: [{ levelCode: "EXP", firstPrice: 12, additionalPrice: 5 }, { levelCode: "STD", firstPrice: 3, additionalPrice: 1 }] }]; |
| 131 | expect(destinationRate(rates, "US", "STD", 3).shippingUsd).toBe(5); |
| 132 | expect(() => destinationRate(rates, "CN", "STD", 1)).toThrow(); |
| 133 | expect(() => destinationRate(rates, "US", "UNKNOWN", 1)).toThrow(); |
| 134 | }); |
| 135 | it("pins HTTPS and cannot POST to shipping or follow arbitrary endpoints", async () => { |
| 136 | await expect(yoycolRequest(env, "POST", "/api/2025/open/v4/shipping/levels")).rejects.toThrow(); |
| 137 | await expect(yoycolRequest(env, "GET", "https://other.example/")).rejects.toThrow(); |
| 138 | expect(sent).toHaveLength(0); |
| 139 | }); |
| 140 | it("signs canonical raw sorted query values, independent of URL encoding", () => { |
| 141 | const headers = yoycolHeaders("GET", "/api/2025/open/v4/shipping/sku_quotes", { z: "two words", a: "x/y" }, "key", "secret", "1700000000000", "0123456789abcdef0123456789abcdef"); |
| 142 | expect(headers["X-API-Signature"]).toBe("ojijq4/UJG5lcMwoYJXopBrxYJ8LTYICrzmKmiIE0jE="); |
| 143 | }); |
| 144 | }); |
| 145 | |
| 146 | describe("checkout and durable payment receipts", () => { |
| 147 | it("creates a 15-minute quote and a stable idempotent session with separate shipping", async () => { |
| 148 | const quote = await checkout(); |
| 149 | expect(quote.expiresAt-Date.now()).toBeGreaterThan(14*60*1000); |
| 150 | expect(quote.expiresAt-Date.now()).toBeLessThanOrEqual(15*60*1000); |
| 151 | const creation = sent.find(r => new URL(r.url).hostname === "api.stripe.com" && r.method === "POST")!; |
| 152 | expect(creation.idempotency).toBe("codewhale-merch:" + quote.quoteId); |
| 153 | const params = new URLSearchParams(creation.body); |
| 154 | expect(params.get("payment_method_configuration")).toBe("pmc_fixture"); |
| 155 | expect(params.get("line_items[1][price_data][unit_amount]")).toBe("225"); |
| 156 | expect(params.get("line_items[1][price_data][product_data][name]")).toBe("Delivery · Fixture service"); |
| 157 | expect(params.has("shipping_options[0][shipping_rate_data][fixed_amount][amount]")).toBe(false); |
| 158 | expect(params.get("payment_intent_data[shipping][address][postal_code]")).toBe("94107"); |
| 159 | expect(params.has("shipping_address_collection[allowed_countries][0]")).toBe(false); |
| 160 | expect(Number(params.get("expires_at"))*1000-quote.expiresAt).toBeGreaterThan(29*60*1000); |
| 161 | expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(200); |
| 162 | expect(sent.filter(r => new URL(r.url).hostname === "api.stripe.com" && r.method === "POST")).toHaveLength(1); |
| 163 | }); |
| 164 | it("rejects an expired quote and changed saved artwork", async () => { |
| 165 | const quote = await quoted(); |
| 166 | config.products["whale-bro"]!.US.designCode = "different-art"; env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 167 | expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409); |
| 168 | db.db.prepare("UPDATE merch_orders SET expires_at=0").run(); |
| 169 | expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409); |
| 170 | }); |
| 171 | it("revokes outstanding quotes when shipping, costs, fees or tax disclosure change", async () => { |
| 172 | const changes = [ |
| 173 | () => { config.countries.US.shippingLevelCode = "REPLACEMENT"; }, |
| 174 | () => { config.products["whale-bro"]!.US.productionCostUsd = 14; }, |
| 175 | () => { config.countries.US.feeRate = .08; }, |
| 176 | () => { config.countries.US.taxNote = "Updated delivery charge disclosure."; }, |
| 177 | ]; |
| 178 | for (const change of changes) { |
| 179 | config = configuration(); env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 180 | const quote = await quoted(); |
| 181 | change(); env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 182 | const result = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env); |
| 183 | expect(result.status).toBe(409); |
| 184 | expect((await result.json()).code).toBe("quote_expired"); |
| 185 | } |
| 186 | expect(sent.filter(r => new URL(r.url).hostname === "api.stripe.com")).toHaveLength(0); |
| 187 | }); |
| 188 | it("rejects bad signatures, test/live mismatch and unreconciled amounts", async () => { |
| 189 | const quote = await checkout(); |
| 190 | expect((await event(undefined, undefined, false)).status).toBe(400); |
| 191 | expect((await event(undefined, undefined, true, true)).status).toBe(400); |
| 192 | currentSession.amount_total = 1; currentSession.payment_status = "paid"; |
| 193 | expect((await event()).status).toBe(409); |
| 194 | expect(state(quote.quoteId)?.payment_state).toBe("quoted"); |
| 195 | }); |
| 196 | it("does not fulfill completed-but-unpaid events or a redirect", async () => { |
| 197 | const quote = await checkout(); |
| 198 | expect(state(quote.quoteId)?.payment_state).toBe("quoted"); |
| 199 | expect((await event()).status).toBe(200); |
| 200 | expect(state(quote.quoteId)?.fulfillment_state).toBe("not_ready"); |
| 201 | expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0); |
| 202 | }); |
| 203 | it("accepts delayed payment after failure once, and duplicate events do not regress it", async () => { |
| 204 | const quote = await checkout(); await event("checkout.session.async_payment_failed", "evt_fail"); |
| 205 | expect(state(quote.quoteId)?.payment_state).toBe("failed"); |
| 206 | currentSession.payment_status = "paid"; |
| 207 | await event("checkout.session.async_payment_succeeded", "evt_paid"); |
| 208 | await event("checkout.session.async_payment_succeeded", "evt_paid"); |
| 209 | currentSession.payment_status = "unpaid"; await event("checkout.session.expired", "evt_late"); |
| 210 | expect(state(quote.quoteId)).toEqual(expect.objectContaining({ payment_state: "paid", fulfillment_state: "awaiting_supplier_review" })); |
| 211 | expect(db.db.prepare("SELECT count(*) AS n FROM merch_webhook_events WHERE id='evt_paid'").get()?.n).toBe(1); |
| 212 | }); |
| 213 | it("keeps webhooks working when new sales are paused", async () => { |
| 214 | const quote = await checkout(); currentSession.payment_status = "paid"; |
| 215 | config.checkoutEnabled = false; env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 216 | env.YOYCOL_ACCESS_KEY = undefined; env.MERCH_RATE_LIMITER = undefined; |
| 217 | expect((await event()).status).toBe(200); |
| 218 | expect(state(quote.quoteId)?.payment_state).toBe("paid"); |
| 219 | }); |
| 220 | }); |
| 221 | |
| 222 | describe("manual supplier handoff", () => { |
| 223 | async function paid(live = false) { |
| 224 | if (live) { config.mode = "live"; config.siteOrigin = "https://codewhale.net"; env.MERCH_CONFIG_JSON = JSON.stringify(config); env.MERCH_STRIPE_KEY = "sk_live_fixture"; currentSession.livemode = true; } |
| 225 | const quote = await checkout(); currentSession.payment_status = "paid"; await event(); return quote; |
| 226 | } |
| 227 | it("refuses unpaid and test-mode supplier creation", async () => { |
| 228 | const quote = await checkout(); |
| 229 | await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId })).rejects.toThrow(); |
| 230 | currentSession.payment_status = "paid"; await event(); |
| 231 | await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow(); |
| 232 | config.mode = "live"; config.siteOrigin = "https://codewhale.net"; config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 233 | await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow(); |
| 234 | }); |
| 235 | it("previews frozen SKU/art/address without forwarding contributor identity", async () => { |
| 236 | const quote = await paid(); |
| 237 | const stored = JSON.parse(db.db.prepare("SELECT quote_json FROM merch_orders WHERE id=?").get(quote.quoteId)?.quote_json as string) as Quote; |
| 238 | stored.input.contributor = { github: "private-identity", contribution: "https://github.com/codewhale-hq/CodeWhale/pull/123", language: "en", phraseIndex: 1 }; |
| 239 | const body = JSON.stringify(supplierRequest(stored)); |
| 240 | expect(body).not.toContain("private-identity"); expect(body).not.toContain("github.com"); |
| 241 | expect(body).toContain("fixture-design"); expect(body).toContain("fixture-sku"); |
| 242 | await operatorAction(env, { action: "preview", orderId: quote.quoteId }); |
| 243 | expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0); |
| 244 | }); |
| 245 | it("atomically allows one submission and keeps supplier funding separate", async () => { |
| 246 | const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config); |
| 247 | const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" }; |
| 248 | const results = await Promise.allSettled([operatorAction(env, value), operatorAction(env, value)]); |
| 249 | expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1); |
| 250 | expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1); |
| 251 | expect(state(quote.quoteId)?.fulfillment_state).toBe("supplier_cost_review"); |
| 252 | }); |
| 253 | it("holds an ambiguous submission instead of automatically retrying", async () => { |
| 254 | const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config); supplierFailure = true; |
| 255 | const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" }; |
| 256 | await expect(operatorAction(env, value)).rejects.toThrow("Do not resubmit"); |
| 257 | supplierFailure = false; await expect(operatorAction(env, value)).rejects.toThrow(); |
| 258 | expect(state(quote.quoteId)?.fulfillment_state).toBe("reconciliation_hold"); |
| 259 | expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1); |
| 260 | }); |
| 261 | }); |
| 262 |