Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/__tests__/api/products/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ const stripeSyncSpy = jest.fn(async (supabase: any, opts: any) => {
// Real helper short-circuits if tenant has no Stripe connection — we honor
// the same gate here by checking the test-controlled flag.
if (!(globalThis as any).__stripeConnected) return
// p_mode is required since migration 147 — the real helper writes once per synced mode. A mock
// that omits it would keep passing while the real call regressed to the mode-blind 3-arg shape.
await supabase.rpc("tenant_products_set_stripe_ids", {
p_id: opts.productId,
p_stripe_product_id: "prod_synced",
p_stripe_price_id_by_currency: { USD: "price_synced_USD" },
p_mode: "live",
})
await supabase.rpc("tenant_providers_set_payment_links", {
p_tenant_id: opts.tenantId,
Expand Down
13 changes: 11 additions & 2 deletions dashboard/__tests__/lib/drift-no-test-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,17 @@ describe("detectNoTestCredential", () => {
expect(out).toHaveLength(1)
expect(out[0].kind).toBe("no-test-credential")
expect(out[0].subject).toBe("provider:stripe")
// QUOTES the offending value — a finding you cannot confirm is noise.
expect(out[0].detail).toContain("sk_live_abc")
// IDENTIFIES the offending value — a finding you cannot confirm is noise. It used to quote the
// key in full; it is now masked to the last 6 characters, which keeps the finding confirmable
// while keeping a credential out of an API response.
//
// The narrowing is deliberate, and this fixture is the reason: `key_id` is not always
// publishable. `sk_live_abc` here is SECRET tier, and this `detail` is returned by
// /api/sync/drift — it reaches logs, CI output and agent transcripts. One such detail leaked a
// live publishable key into a transcript on 2026-10-06; the same code path would have leaked a
// secret one for a provider whose key_id looks like this fixture.
expect(out[0].detail).toContain("ve_abc")
expect(out[0].detail).not.toContain("sk_live_abc")
// And names a concrete next action, not a description of the problem.
expect(out[0].action_hint).toMatch(/TEST-mode key/i)
expect(out[0].subject_id).toBe("stripe")
Expand Down
113 changes: 113 additions & 0 deletions dashboard/__tests__/lib/drift-stripe-error-surfaced.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* A "not readable at Stripe" finding must carry the REASON Stripe gave.
*
* WHY THIS TEST EXISTS
* The readback was `try { products.retrieve(id) } catch { push("not readable at Stripe") }`. A bare
* `catch {}` collapses three different root causes with three different remedies into one string:
*
* resource_missing → the id is absent from the CONNECTED account (deleted, or created under
* a different account than the key now stored) — re-sync creates it
* authentication_error → the key is revoked/rotated — re-syncing cannot help, fix the credential
* a network fault → nothing is wrong with the data at all
*
* Measured 2026-10-06: all 7 products across tenants cappy and reels-downloader reported
* "not readable at Stripe" and the output contained NOTHING to act on. Two hours of diagnosis went
* into questions the discarded exception would have answered in one line.
*
* Also asserted: the `no-test-credential` detail must not emit a key VALUE. That `detail` is
* returned by /api/sync/drift and lands in logs, CI output and agent transcripts — one did leak a
* live publishable key into a transcript on 2026-10-06. Publishable keys are low-tier, but the same
* string shape is reused for providers whose `key_id` is not publishable.
*/

import { detectNoTestCredential, detectProductMissingAtProvider } from "@/lib/drift-detectors"

const TENANT = "ba973ad0-8788-4c0f-89c8-1ff9533fa79f"

const retrieve = jest.fn()
jest.mock("@/lib/stripe-client", () => ({
getConnectedStripeClient: jest.fn(async () => ({ products: { retrieve: (id: string) => retrieve(id) } })),
}))

/** `.select().eq().eq()` chainable AND awaitable — only what the detectors use. */
function fakeSupabase(rows: unknown[]) {
const thenable: Record<string, unknown> = {
select: () => thenable,
eq: () => thenable,
then: (res: (v: { data: unknown[] | null; error: unknown }) => unknown) =>
res({ data: rows, error: null }),
}
return { from: () => thenable } as never
}

const product = {
id: "47ddeaf3-eecb-4903-a998-96c630c80b17",
sku: "cappy_plus_monthly",
stripe_product_id: "prod_VJMNS10q3sZyzF",
package_id: null,
}

beforeEach(() => retrieve.mockReset())

describe("detectProductMissingAtProvider — the Stripe reason survives", () => {
it("names resource_missing AND points at the account mismatch, not just 'not readable'", async () => {
const err: any = new Error("No such product: 'prod_VJMNS10q3sZyzF'")
err.code = "resource_missing"
retrieve.mockRejectedValue(err)

const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT)
expect(f.detail).toContain("not readable at Stripe")
expect(f.detail).toContain("[resource_missing]")
expect(f.detail).toContain("No such product")
// The remedy differs per cause, so the hint must too.
expect(f.action_hint).toMatch(/different account|deleted/i)
})

it("surfaces an authentication failure as itself — re-syncing would not fix it", async () => {
const err: any = new Error("Invalid API Key provided: sk_live_***")
err.type = "authentication_error"
retrieve.mockRejectedValue(err)

const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT)
expect(f.detail).toContain("[authentication_error]")
expect(f.detail).toContain("Invalid API Key")
})

it("still reports a bare failure with no code, rather than throwing", async () => {
retrieve.mockRejectedValue(new Error("socket hang up"))
const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT)
expect(f.detail).toContain("socket hang up")
expect(f.kind).toBe("product-missing-at-provider")
})

it("reports nothing when the product IS readable and active", async () => {
retrieve.mockResolvedValue({ id: product.stripe_product_id, active: true })
expect(await detectProductMissingAtProvider(fakeSupabase([product]), TENANT)).toHaveLength(0)
})
})

describe("detectNoTestCredential — never emits a key value", () => {
it("masks the live key to its last 6 characters", async () => {
const rows = [
{
provider: "stripe",
is_active: true,
// Deliberately SHORT + obviously synthetic: a >=20-char body matches the SV32
// staged-secret guard (pk_(live|test)_[A-Za-z0-9]{20,}) and a real key's prefix has no
// business in a committed fixture.
live_key_id: "pk_live_FIXTUREaVcn",
test_key_id: null,
test_payment_links: {},
provider_accounts: { config: {} },
},
]
const [f] = await detectNoTestCredential(fakeSupabase(rows), TENANT)
expect(f).toBeDefined()
// The whole key must not appear anywhere in the finding.
const blob = JSON.stringify(f)
expect(blob).not.toContain("pk_live_FIXTUREaVcn")
expect(blob).not.toContain("FIXTURE")
// Identity is still legible so an operator can tell WHICH key it means.
expect(f.detail).toContain("REaVcn") // slice(-6) of the fixture
})
})
141 changes: 141 additions & 0 deletions dashboard/__tests__/lib/stripe-id-mode-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import fs from "fs"
import path from "path"

/**
* Stripe product + price ids are MODE-SCOPED (migration 147). This is the sibling of
* `razorpay-plan-mode-scope.test.ts`; same defect, same shape, one provider later.
*
* Before it, `stripe_product_id` / `stripe_price_id_by_currency` were single columns written by
* whichever sync mode ran last. `stripeSyncProduct` builds `modes` live-first and recorded
* `lastResult` — the LAST iteration, i.e. TEST — so a routine sync on a both-keys tenant ended with
* test product and price ids in the fields LIVE checkout reads.
*
* MEASURED 2026-10-06, tenants cappy + reels-downloader, 7 products, from Stripe itself:
*
* No such product: 'prod_VJMNS10q3sZyzF'; a similar object exists in test mode,
* but a live mode key was used to make this request
*
* Live Stripe checkout on two shipped apps was pointed at test-mode objects.
*
* Source-level assertions, for the reason the Razorpay sibling gives: the defect is about WHICH
* FIELD each path touches, and a mocked unit test of either path passes whether or not the other
* agrees.
*/

const ROOT = path.join(__dirname, "..", "..")
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), "utf8")

describe("the sync writes each mode to its own columns", () => {
const src = read("lib/stripe-route-helper.ts")

it("passes p_mode to the setter", () => {
expect(src).toMatch(/tenant_products_set_stripe_ids[\s\S]{0,260}p_mode:/)
})

it("does not record ids from `lastResult`, which is whichever mode ran last", () => {
expect(src).not.toMatch(/p_stripe_product_id:\s*lastResult\.stripeProductId/)
expect(src).not.toMatch(/p_stripe_price_id_by_currency:\s*lastResult\.pricesByCurrency/)
})

it("keeps per-mode results apart", () => {
expect(src).toMatch(/stripeResultsByMode/)
})

it("seeds a test sync from the TEST existing-ids map", () => {
// Handing a test sync the live product id asks Stripe to update a live object with a test key.
expect(src).toMatch(/mode === "test"[\s\S]{0,160}existingStripeProductIdTest/)
})
})

describe("every caller supplies both id sets", () => {
// Enumerated from disk: a caller passing only the live ids silently reverts the fix for whatever
// path it serves, and would fail nothing else here.
function walk(dir: string, out: string[] = []): string[] {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name === ".next" || e.name === ".open-next") continue
const p = path.join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (e.name.endsWith(".ts")) out.push(p)
}
return out
}

const callers = [...walk(path.join(ROOT, "lib")), ...walk(path.join(ROOT, "app"))]
.map((f) => ({ rel: path.relative(ROOT, f), src: fs.readFileSync(f, "utf8") }))
.filter((f) => f.src.includes("existingStripeProductId:"))

it("finds the call sites (an empty sweep would pass everything below)", () => {
expect(callers.length).toBeGreaterThanOrEqual(1)
})

it.each(callers.map((c) => [c.rel, c]))("%s also passes the test ids", (_r, c: any) => {
expect(c.src).toMatch(/existingStripeProductIdTest:/)
})
})

describe("the loader selects the columns the fix depends on", () => {
const src = read("lib/stripe-route-helper.ts")
// Omit them and `loaded.product.stripe_product_id_test` is undefined forever — the fix would be
// syntactically present and behaviourally absent, which is the worst of the three states.
it.each(["stripe_product_id_test", "stripe_price_id_by_currency_test", "live_stripe_ids_verified"])(
"selects %s",
(col) => expect(src).toContain(col),
)
})

describe("/config serves the ids for the mode the caller is in", () => {
const src = fs.readFileSync(
path.join(ROOT, "..", "supabase", "functions", "config", "index.ts"),
"utf8",
)

it("threads the resolved mode into the binding resolver", () => {
expect(src).toMatch(/storeBindingForChain\([\s\S]{0,120}isTestMode\)/)
})

it("reads the TEST price map for a test caller", () => {
expect(src).toMatch(/isTest\s*\n?\s*\?\s*p\.stripe_price_id_by_currency_test/)
})

it("does not fall back to the other mode when its own is missing", () => {
// A cross-mode fallback would hand a test build live price ids — exactly the bug. /config
// returning null makes the client BLOCK, which is the correct outcome.
expect(src).not.toMatch(/stripe_price_id_by_currency_test\s*\?\?\s*p\.stripe_price_id_by_currency\b/)
expect(src).not.toMatch(/stripe_product_id_test\s*\?\?\s*p\.stripe_product_id\b/)
})
})

describe("migration 147", () => {
const sql = fs.readFileSync(
path.join(ROOT, "..", "supabase", "migrations", "147_stripe_ids_mode_scoped.sql"),
"utf8",
)

it("drops the three-argument setter instead of leaving it as an overload", () => {
// Left in place, PostgREST would resolve an un-updated three-arg caller to it silently, and
// that caller would keep writing the live columns from a test sync.
expect(sql).toMatch(/DROP FUNCTION IF EXISTS public\.tenant_products_set_stripe_ids\(uuid, text, jsonb\)/)
})

it("refuses an unrecognised mode rather than defaulting to live", () => {
expect(sql).toMatch(/invalid_mode/)
expect(sql).not.toMatch(/p_mode\s+TEXT\s+DEFAULT/)
})

it("marks legacy live ids unverified rather than trusting them", () => {
expect(sql).toMatch(/live_stripe_ids_verified/)
})

it("adds both test columns and leaves the live ones in place", () => {
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS stripe_product_id_test/)
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS stripe_price_id_by_currency_test/)
// 141's reasoning: clearing the legacy values would break live checkout for every row that DOES
// hold a live id, trading a latent risk for a certain outage.
expect(sql).not.toMatch(/UPDATE tenant_products\s+SET\s+stripe_product_id\s*=\s*NULL/i)
})

it("is idempotent, per this repo's migration rule", () => {
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS/)
expect(sql).toMatch(/CREATE OR REPLACE FUNCTION/)
})
})
Loading
Loading