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",