From 9eb9b4232b89acc21079fd741543d7207dd15d41 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 20:05:01 +0100 Subject: [PATCH] test: add billing edge case tests (characterization) --- src/__tests__/billing.test.ts | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 07790dc..30701bc 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -137,6 +137,36 @@ describe("GET /v1/billing/success", () => { const res = await request(app).get("/v1/billing/success?session_id=cs_err"); expect(res.status).toBe(500); }); + + it("returns 400 when session has no customer", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + customer: null, + customer_details: { email: "test@test.com" }, + }); + const res = await request(app).get("/v1/billing/success?session_id=cs_no_cust"); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/No customer found/); + }); + + it("escapes HTML in displayed key to prevent XSS", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_xss", + customer: "cus_xss", + customer_details: { email: "xss@test.com" }, + }); + const { createProKey } = await import("../services/keys.js"); + vi.mocked(createProKey).mockResolvedValue({ + key: '', + tier: "pro", + email: "xss@test.com", + createdAt: new Date().toISOString(), + } as any); + const res = await request(app).get("/v1/billing/success?session_id=cs_xss"); + expect(res.status).toBe(200); + expect(res.text).not.toContain(''); + expect(res.text).toContain("<script>"); + }); }); describe("POST /v1/billing/webhook", () => { @@ -275,6 +305,170 @@ describe("POST /v1/billing/webhook", () => { expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel"); }); + it("does not provision key when checkout.session.completed has missing customer", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_cust", + customer: null, + customer_details: { email: "nocust@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not provision key when checkout.session.completed has missing email", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_email", + customer: "cus_no_email", + customer_details: {}, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_email", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.updated with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_other", customer: "cus_other", status: "canceled", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("downgrades on customer.subscription.updated with past_due status", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_past", customer: "cus_past", status: "past_due", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_past"); + }); + + it("does not downgrade on customer.subscription.updated with active status and no cancel", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_ok", customer: "cus_ok", status: "active", cancel_at_period_end: false }, + }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.deleted with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_del_other", customer: "cus_del_other" }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("returns 200 for unknown event type", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "invoice.payment_failed", + data: { object: {} }, + }); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "invoice.payment_failed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it("returns 200 when session retrieve fails on checkout.session.completed", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_fail_retrieve", + customer: "cus_fail", + customer_details: { email: "fail@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe retrieve failed")); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + expect(createProKey).not.toHaveBeenCalled(); + }); + it("syncs email on customer.updated", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated",