fix(billing): block demo/sandbox accounts from Stripe checkout (#948)
* fix(billing): block demo/sandbox accounts from Stripe checkout An anonymous demo user on a sandbox company reached POST /api/billing/checkout and created a live Stripe customer. Neither the checkout nor the portal route checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users through (they are authenticated, just anonymously). Guard both routes on both conditions before any Stripe call: refuse anonymous users (identity truth, cheap in-memory check) and sandbox companies (matches the existing lib/sandbox/guard.ts "never charge a token" doctrine). Surface isDemo on GET /api/billing/status so the client hides the upgrade CTA instead of showing a button that 403s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(billing): redact tenant/customer IDs from incident note (CodeRabbit) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0626bb6326
commit
c9d9c5fe99
@@ -24,6 +24,14 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
// Keep sandboxBlockedResponse real; stub only the DB-backed guardSandbox so the
|
||||
// route's company_settings read doesn't need a live supabase mock.
|
||||
const guardSandboxMock = vi.fn()
|
||||
vi.mock('@/lib/sandbox/guard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/sandbox/guard')>()
|
||||
return { ...actual, guardSandbox: (...args: unknown[]) => guardSandboxMock(...args) }
|
||||
})
|
||||
|
||||
const customersCreate = vi.fn()
|
||||
const sessionsCreate = vi.fn()
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
@@ -41,8 +49,9 @@ const routeParams = { params: Promise.resolve({}) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
guardSandboxMock.mockResolvedValue(null)
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'u@example.com' },
|
||||
user: { id: 'user-1', email: 'u@example.com', is_anonymous: false },
|
||||
supabase: {},
|
||||
error: null,
|
||||
})
|
||||
@@ -61,6 +70,42 @@ describe('POST /api/billing/checkout', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks an anonymous (demo) user with 403 and never touches Stripe', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'anon-1', email: null, is_anonymous: true },
|
||||
supabase: {},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
|
||||
await POST(req, routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(customersCreate).not.toHaveBeenCalled()
|
||||
expect(sessionsCreate).not.toHaveBeenCalled()
|
||||
// The cheap identity check short-circuits before the DB-backed guard.
|
||||
expect(guardSandboxMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a sandbox company with 403 and never touches Stripe', async () => {
|
||||
const { sandboxBlockedResponse } = await import('@/lib/sandbox/guard')
|
||||
guardSandboxMock.mockResolvedValue(sandboxBlockedResponse())
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: { plan: 'monthly' } })
|
||||
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
|
||||
await POST(req, routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(guardSandboxMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
|
||||
expect(customersCreate).not.toHaveBeenCalled()
|
||||
expect(sessionsCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown plan with 400', async () => {
|
||||
const req = createMockRequest('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -21,6 +21,13 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
// Keep sandboxBlockedResponse real; stub only the DB-backed guardSandbox.
|
||||
const guardSandboxMock = vi.fn()
|
||||
vi.mock('@/lib/sandbox/guard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/sandbox/guard')>()
|
||||
return { ...actual, guardSandbox: (...args: unknown[]) => guardSandboxMock(...args) }
|
||||
})
|
||||
|
||||
const portalCreate = vi.fn()
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
getStripe: () => ({
|
||||
@@ -35,7 +42,8 @@ const routeParams = { params: Promise.resolve({}) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
guardSandboxMock.mockResolvedValue(null)
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1', is_anonymous: false }, supabase: {}, error: null })
|
||||
})
|
||||
|
||||
describe('POST /api/billing/portal', () => {
|
||||
@@ -51,6 +59,39 @@ describe('POST /api/billing/portal', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('blocks an anonymous (demo) user with 403 and never touches Stripe', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'anon-1', is_anonymous: true },
|
||||
supabase: {},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
|
||||
await POST(req, routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(portalCreate).not.toHaveBeenCalled()
|
||||
expect(guardSandboxMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a sandbox company with 403 and never touches Stripe', async () => {
|
||||
const { sandboxBlockedResponse } = await import('@/lib/sandbox/guard')
|
||||
guardSandboxMock.mockResolvedValue(sandboxBlockedResponse())
|
||||
|
||||
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
|
||||
await POST(req, routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(guardSandboxMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
|
||||
expect(portalCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 with NO_SUBSCRIPTION when the company has no Stripe customer', async () => {
|
||||
enqueue({ data: null })
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getStripe, priceIdForPlan } from '@/lib/stripe/client'
|
||||
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
||||
|
||||
const CheckoutSchema = z.object({
|
||||
plan: z.enum(['monthly', 'yearly']).default('monthly'),
|
||||
@@ -19,7 +20,16 @@ const CheckoutSchema = z.object({
|
||||
* still filters by the membership-validated companyId.
|
||||
*/
|
||||
export const POST = withRouteContext('billing.checkout', async (request, ctx) => {
|
||||
const { user, companyId, log } = ctx
|
||||
const { user, supabase, companyId, log } = ctx
|
||||
|
||||
// Demo accounts must never reach Stripe. An anonymous user has no real
|
||||
// identity to bill, and a sandbox company must never charge a token (same
|
||||
// doctrine as lib/sandbox/guard.ts: no real external side effects). Both
|
||||
// checks run before any Stripe call: this is the gap that let an anonymous
|
||||
// demo user create a live Stripe customer.
|
||||
if (user.is_anonymous) return sandboxBlockedResponse()
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const validation = await validateBody(request, CheckoutSchema, {
|
||||
log,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getStripe } from '@/lib/stripe/client'
|
||||
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
|
||||
|
||||
/**
|
||||
* Create a Stripe Billing Customer Portal session so the user can manage,
|
||||
@@ -13,7 +14,14 @@ import { getStripe } from '@/lib/stripe/client'
|
||||
* by the membership-validated companyId.
|
||||
*/
|
||||
export const POST = withRouteContext('billing.portal', async (_request, ctx) => {
|
||||
const { companyId } = ctx
|
||||
const { user, supabase, companyId } = ctx
|
||||
|
||||
// Demo accounts must never reach Stripe (see billing/checkout for the full
|
||||
// rationale). Defense in depth: a demo tenant should never own a portal
|
||||
// session even if a stray customer row exists.
|
||||
if (user.is_anonymous) return sandboxBlockedResponse()
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const service = createServiceClient()
|
||||
const { data: sub } = await service
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { isStripeConfigured } from '@/lib/stripe/client'
|
||||
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
||||
|
||||
/**
|
||||
* Billing status for the client-rendered billing section (which lives inside the
|
||||
@@ -20,6 +21,13 @@ export async function GET() {
|
||||
companyId = null
|
||||
}
|
||||
|
||||
// Demo accounts (anonymous user or sandbox company) can't check out, so the
|
||||
// client hides the upgrade CTA rather than showing a button that only errors.
|
||||
let isDemo = user.is_anonymous === true
|
||||
if (companyId && !isDemo) {
|
||||
isDemo = await isSandboxCompany(supabase, companyId)
|
||||
}
|
||||
|
||||
let isPaying = false
|
||||
let trialEndsAt: string | null = null
|
||||
if (companyId) {
|
||||
@@ -44,5 +52,5 @@ export async function GET() {
|
||||
trialEndsAt = (trial as { expires_at: string | null } | null)?.expires_at ?? null
|
||||
}
|
||||
|
||||
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt })
|
||||
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt, isDemo })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user