Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

stripe-subscription-scope.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

stripe-subscription-scope.test.tsBlame176 lines · 1 contributor
8298a0fccantynz-alt1/**
2 * Regression guard: a Stripe event must only touch the subscription the user
3 * actually has on record.
4 *
5 * `customer.subscription.deleted` updated userQuotas keyed on
6 * stripeCustomerId alone, ignoring the deleted subscription's own id. One
7 * Stripe customer can hold several subscriptions, and a plan change creates
8 * the new one and deletes the old — with no ordering guarantee between the
9 * two webhooks. So `created`(sub_NEW) routinely lands first, upgrading the
10 * row, and then `deleted`(sub_OLD) matched that same row and reset it to
11 * planSlug "free" with a null subscription id.
12 *
13 * The result is a customer who is being billed while the product treats
14 * them as free. It fails silently and in the direction least likely to be
15 * reported quickly, since the downgrade looks like an ordinary plan state.
16 *
17 * The same flaw sat in two more places, both keyed on customer alone:
18 * reconcileSubscription's non-active branch (a canceled or past_due event
19 * for a superseded subscription stamped its id and status over the live
20 * one) and invoice.payment_failed.
21 *
22 * These assert the shared scope condition rather than round-tripping a
23 * webhook, so no database is required and no module needs mocking.
24 */
25
26import { describe, it, expect } from "bun:test";
27import { readFileSync } from "node:fs";
28import { join } from "node:path";
29import { recordedSubscriptionScope } from "../routes/stripe-webhook";
30
31/**
32 * Flatten a drizzle SQL tree into labelled leaves.
33 *
34 * The shape was inspected rather than assumed: the condition is a nested
35 * SQL whose chunks are StringChunk (carrying `.value`), column references,
36 * and Param nodes. Guessing at this shape is what makes these tests wrong.
37 */
38type Leaf = { kind: "str" | "col" | "param"; value: string };
39
40function leaves(node: unknown, out: Leaf[] = []): Leaf[] {
41 if (!node) return out;
42 if (Array.isArray(node)) {
43 for (const n of node) leaves(n, out);
44 return out;
45 }
46 const anyNode = node as any;
47 const kind = anyNode?.constructor?.name;
48 if (kind === "SQL") return leaves(anyNode.queryChunks, out);
49 if (kind === "StringChunk") {
50 out.push({ kind: "str", value: String(anyNode.value ?? "") });
51 return out;
52 }
53 if (kind === "Param") {
54 out.push({ kind: "param", value: String(anyNode.value) });
55 return out;
56 }
57 if (anyNode?.name && anyNode?.table) {
58 out.push({ kind: "col", value: String(anyNode.name) });
59 }
60 return out;
61}
62
63const text = (ls: Leaf[]) =>
64 ls
65 .filter((l) => l.kind === "str")
66 .map((l) => l.value)
67 .join(" ");
68
69describe("recordedSubscriptionScope", () => {
70 it("returns no constraint when the event names no subscription", () => {
71 // A one-off invoice has no subscription; the customer match is all
72 // there is to go on, so the caller must not be over-constrained.
73 expect(recordedSubscriptionScope("")).toBeUndefined();
74 });
75
76 it("constrains on the subscription id the event carries", () => {
77 const ls = leaves(recordedSubscriptionScope("sub_ABC"));
78 expect(ls.some((l) => l.kind === "col" && l.value === "stripe_subscription_id")).toBe(true);
79 expect(ls.some((l) => l.kind === "param" && l.value === "sub_ABC")).toBe(true);
80 });
81
82 it("does not bind some other subscription's id", () => {
83 const ls = leaves(recordedSubscriptionScope("sub_NEW"));
84 expect(ls.some((l) => l.kind === "param" && l.value === "sub_OLD")).toBe(false);
85 });
86
87 it("also matches a row whose subscription id was never recorded", () => {
88 // Checkout populates the row; reconcile enriches it. A row caught
89 // between the two is NULL, and the event is the only subscription it
90 // could be about — so it must still be matched or a real cancellation
91 // would be dropped.
92 const sql = text(leaves(recordedSubscriptionScope("sub_ABC")));
93 expect(sql).toContain(" or ");
94 expect(sql.toLowerCase()).toContain("is null");
95 });
96
97 it("never references the customer column — that is the caller's job", () => {
98 const ls = leaves(recordedSubscriptionScope("sub_ABC"));
99 expect(ls.some((l) => l.kind === "col" && l.value === "stripe_customer_id")).toBe(false);
100 });
101});
102
103// --- every mutating site must apply the scope ------------------------------
104//
105// The bug was an absent constraint, so asserting the helper alone would not
106// have caught it. Strip ONLY line comments.
107
108describe("stripe-webhook applies the scope everywhere it mutates a plan", () => {
109 const src = (() => {
110 const raw = readFileSync(
111 join(import.meta.dir, "..", "routes", "stripe-webhook.ts"),
112 "utf8"
113 );
114 const stripped = raw
115 .split("\n")
116 .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
117 .join("\n");
118 expect(stripped.length).toBeGreaterThan(0);
119 return stripped;
120 })();
121
122 /** Slice by anchor, never by character count — handlers move. */
123 function slice(startAnchor: string, endAnchor: string): string {
124 const start = src.indexOf(startAnchor);
125 expect(start).toBeGreaterThan(-1);
126 const end = src.indexOf(endAnchor, start + startAnchor.length);
127 expect(end).toBeGreaterThan(start);
128 const body = src.slice(start, end);
129 expect(body.length).toBeGreaterThan(0);
130 return body;
131 }
132
133 it("subscription.deleted scopes the downgrade", () => {
134 const body = slice(
135 `case "customer.subscription.deleted"`,
136 `case "invoice.payment_failed"`
137 );
138 expect(body).toContain('planSlug: "free"');
139 expect(body).toContain("recordedSubscriptionScope");
140 expect(body.indexOf("recordedSubscriptionScope")).toBeGreaterThan(
141 body.indexOf("stripeCustomerId, customerId")
142 );
143 });
144
145 it("invoice.payment_failed scopes the past_due flag", () => {
146 const body = slice(`case "invoice.payment_failed"`, "default:");
147 expect(body).toContain('stripeSubscriptionStatus: "past_due"');
148 expect(body).toContain("recordedSubscriptionScope");
149 });
150
151 it("reconcileSubscription scopes its non-active write", () => {
152 const body = slice("async function reconcileSubscription", "if (!slug)");
153 expect(body).toContain("stripeSubscriptionId: sub.id");
154 expect(body).toContain("recordedSubscriptionScope");
155 });
156
157 it("leaves exactly one unscoped update — the active-upgrade path", () => {
158 // Scoping guards against a STALE NEGATIVE event clobbering a live plan.
159 // The upgrade path must stay unscoped: a newly-created subscription is
160 // by definition not yet the one on record, so scoping it would reject
161 // the upgrade and strand a paying customer on their old plan. A
162 // superseded subscription is never active, so stale events cannot reach
163 // it. This pins that exemption to one known site.
164 const unscoped =
165 src.match(/\.where\(\s*eq\(userQuotas\.stripeCustomerId/g) ?? [];
166 expect(unscoped.length).toBe(1);
167
168 const at = src.search(/\.where\(\s*eq\(userQuotas\.stripeCustomerId/);
169 const block = src.slice(Math.max(0, at - 400), at);
170 expect(block.length).toBeGreaterThan(0);
171 // It is the active upgrade — sets a resolved paid slug, not a downgrade.
172 expect(block).toContain("planSlug: slug");
173 expect(block).not.toContain('planSlug: "free"');
174 expect(block).not.toContain('"past_due"');
175 });
176});