test: add billing edge case tests (characterization)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m8s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m8s
This commit is contained in:
parent
82946ffcf0
commit
9eb9b4232b
1 changed files with 194 additions and 0 deletions
|
|
@ -137,6 +137,36 @@ describe("GET /v1/billing/success", () => {
|
||||||
const res = await request(app).get("/v1/billing/success?session_id=cs_err");
|
const res = await request(app).get("/v1/billing/success?session_id=cs_err");
|
||||||
expect(res.status).toBe(500);
|
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: '<script>alert("xss")</script>',
|
||||||
|
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('<script>alert("xss")</script>');
|
||||||
|
expect(res.text).toContain("<script>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /v1/billing/webhook", () => {
|
describe("POST /v1/billing/webhook", () => {
|
||||||
|
|
@ -275,6 +305,170 @@ describe("POST /v1/billing/webhook", () => {
|
||||||
expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel");
|
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 () => {
|
it("syncs email on customer.updated", async () => {
|
||||||
mockStripe.webhooks.constructEvent.mockReturnValue({
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
||||||
type: "customer.updated",
|
type: "customer.updated",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue