Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
stripe-subscription-scope.test.ts7.1 KB · 176 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
/**
 * Regression guard: a Stripe event must only touch the subscription the user
 * actually has on record.
 *
 * `customer.subscription.deleted` updated userQuotas keyed on
 * stripeCustomerId alone, ignoring the deleted subscription's own id. One
 * Stripe customer can hold several subscriptions, and a plan change creates
 * the new one and deletes the old — with no ordering guarantee between the
 * two webhooks. So `created`(sub_NEW) routinely lands first, upgrading the
 * row, and then `deleted`(sub_OLD) matched that same row and reset it to
 * planSlug "free" with a null subscription id.
 *
 * The result is a customer who is being billed while the product treats
 * them as free. It fails silently and in the direction least likely to be
 * reported quickly, since the downgrade looks like an ordinary plan state.
 *
 * The same flaw sat in two more places, both keyed on customer alone:
 * reconcileSubscription's non-active branch (a canceled or past_due event
 * for a superseded subscription stamped its id and status over the live
 * one) and invoice.payment_failed.
 *
 * These assert the shared scope condition rather than round-tripping a
 * webhook, so no database is required and no module needs mocking.
 */

import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { recordedSubscriptionScope } from "../routes/stripe-webhook";

/**
 * Flatten a drizzle SQL tree into labelled leaves.
 *
 * The shape was inspected rather than assumed: the condition is a nested
 * SQL whose chunks are StringChunk (carrying `.value`), column references,
 * and Param nodes. Guessing at this shape is what makes these tests wrong.
 */
type Leaf = { kind: "str" | "col" | "param"; value: string };

function leaves(node: unknown, out: Leaf[] = []): Leaf[] {
  if (!node) return out;
  if (Array.isArray(node)) {
    for (const n of node) leaves(n, out);
    return out;
  }
  const anyNode = node as any;
  const kind = anyNode?.constructor?.name;
  if (kind === "SQL") return leaves(anyNode.queryChunks, out);
  if (kind === "StringChunk") {
    out.push({ kind: "str", value: String(anyNode.value ?? "") });
    return out;
  }
  if (kind === "Param") {
    out.push({ kind: "param", value: String(anyNode.value) });
    return out;
  }
  if (anyNode?.name && anyNode?.table) {
    out.push({ kind: "col", value: String(anyNode.name) });
  }
  return out;
}

const text = (ls: Leaf[]) =>
  ls
    .filter((l) => l.kind === "str")
    .map((l) => l.value)
    .join(" ");

describe("recordedSubscriptionScope", () => {
  it("returns no constraint when the event names no subscription", () => {
    // A one-off invoice has no subscription; the customer match is all
    // there is to go on, so the caller must not be over-constrained.
    expect(recordedSubscriptionScope("")).toBeUndefined();
  });

  it("constrains on the subscription id the event carries", () => {
    const ls = leaves(recordedSubscriptionScope("sub_ABC"));
    expect(ls.some((l) => l.kind === "col" && l.value === "stripe_subscription_id")).toBe(true);
    expect(ls.some((l) => l.kind === "param" && l.value === "sub_ABC")).toBe(true);
  });

  it("does not bind some other subscription's id", () => {
    const ls = leaves(recordedSubscriptionScope("sub_NEW"));
    expect(ls.some((l) => l.kind === "param" && l.value === "sub_OLD")).toBe(false);
  });

  it("also matches a row whose subscription id was never recorded", () => {
    // Checkout populates the row; reconcile enriches it. A row caught
    // between the two is NULL, and the event is the only subscription it
    // could be about — so it must still be matched or a real cancellation
    // would be dropped.
    const sql = text(leaves(recordedSubscriptionScope("sub_ABC")));
    expect(sql).toContain(" or ");
    expect(sql.toLowerCase()).toContain("is null");
  });

  it("never references the customer column — that is the caller's job", () => {
    const ls = leaves(recordedSubscriptionScope("sub_ABC"));
    expect(ls.some((l) => l.kind === "col" && l.value === "stripe_customer_id")).toBe(false);
  });
});

// --- every mutating site must apply the scope ------------------------------
//
// The bug was an absent constraint, so asserting the helper alone would not
// have caught it. Strip ONLY line comments.

describe("stripe-webhook applies the scope everywhere it mutates a plan", () => {
  const src = (() => {
    const raw = readFileSync(
      join(import.meta.dir, "..", "routes", "stripe-webhook.ts"),
      "utf8"
    );
    const stripped = raw
      .split("\n")
      .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
      .join("\n");
    expect(stripped.length).toBeGreaterThan(0);
    return stripped;
  })();

  /** Slice by anchor, never by character count — handlers move. */
  function slice(startAnchor: string, endAnchor: string): string {
    const start = src.indexOf(startAnchor);
    expect(start).toBeGreaterThan(-1);
    const end = src.indexOf(endAnchor, start + startAnchor.length);
    expect(end).toBeGreaterThan(start);
    const body = src.slice(start, end);
    expect(body.length).toBeGreaterThan(0);
    return body;
  }

  it("subscription.deleted scopes the downgrade", () => {
    const body = slice(
      `case "customer.subscription.deleted"`,
      `case "invoice.payment_failed"`
    );
    expect(body).toContain('planSlug: "free"');
    expect(body).toContain("recordedSubscriptionScope");
    expect(body.indexOf("recordedSubscriptionScope")).toBeGreaterThan(
      body.indexOf("stripeCustomerId, customerId")
    );
  });

  it("invoice.payment_failed scopes the past_due flag", () => {
    const body = slice(`case "invoice.payment_failed"`, "default:");
    expect(body).toContain('stripeSubscriptionStatus: "past_due"');
    expect(body).toContain("recordedSubscriptionScope");
  });

  it("reconcileSubscription scopes its non-active write", () => {
    const body = slice("async function reconcileSubscription", "if (!slug)");
    expect(body).toContain("stripeSubscriptionId: sub.id");
    expect(body).toContain("recordedSubscriptionScope");
  });

  it("leaves exactly one unscoped update — the active-upgrade path", () => {
    // Scoping guards against a STALE NEGATIVE event clobbering a live plan.
    // The upgrade path must stay unscoped: a newly-created subscription is
    // by definition not yet the one on record, so scoping it would reject
    // the upgrade and strand a paying customer on their old plan. A
    // superseded subscription is never active, so stale events cannot reach
    // it. This pins that exemption to one known site.
    const unscoped =
      src.match(/\.where\(\s*eq\(userQuotas\.stripeCustomerId/g) ?? [];
    expect(unscoped.length).toBe(1);

    const at = src.search(/\.where\(\s*eq\(userQuotas\.stripeCustomerId/);
    const block = src.slice(Math.max(0, at - 400), at);
    expect(block.length).toBeGreaterThan(0);
    // It is the active upgrade — sets a resolved paid slug, not a downgrade.
    expect(block).toContain("planSlug: slug");
    expect(block).not.toContain('planSlug: "free"');
    expect(block).not.toContain('"past_due"');
  });
});