Files
accounted/app/api/billing/__tests__/checkout.test.ts
T
MattssonandClaude Fable 5.1 d29a5bda14 fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:12:22 +02:00

316 lines
12 KiB
TypeScript

/**
* Tests for POST /api/billing/checkout.
*
* Exercises the route through the real withRouteContext wrapper, mocking
* auth/company, the Stripe client, and the service-role Supabase client.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
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', () => ({
getStripe: () => ({
customers: { create: customersCreate },
checkout: { sessions: { create: sessionsCreate } },
}),
priceIdForPlan: vi.fn().mockReturnValue('price_123'),
}))
import { POST } from '../checkout/route'
const routeParams = { params: Promise.resolve({}) }
// The trusted-origin resolver reads the brands table; pin one registered
// brand host so the return-URL tests exercise the real resolver logic.
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
}))
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
beforeEach(() => {
vi.clearAllMocks()
reset()
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
brand: host === 'portal.brand.test' ? { domain: host } : null,
lookupFailed: false,
}))
guardSandboxMock.mockResolvedValue(null)
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', email: 'u@example.com', is_anonymous: false },
supabase: {},
error: null,
})
})
afterEach(() => {
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
})
describe('POST /api/billing/checkout', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const res = await POST(req, routeParams)
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',
body: { plan: 'weekly' },
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(400)
expect(sessionsCreate).not.toHaveBeenCalled()
})
it('reuses an existing Stripe customer and returns the checkout URL', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null }) // no trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', {
method: 'POST',
body: { plan: 'yearly' },
})
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
expect(status).toBe(200)
expect(body.url).toBe('https://stripe.test/session')
expect(customersCreate).not.toHaveBeenCalled()
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
customer: 'cus_existing',
client_reference_id: 'company-1',
})
)
// No trial grant → billing starts immediately, no Stripe trial.
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('enables automatic VAT, tax-id collection and address capture on the session', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null }) // no trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: { plan: 'monthly' } })
await POST(req, routeParams)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
customer_update: { name: 'auto', address: 'auto' },
}),
)
})
it('creates a Stripe customer when none exists yet', async () => {
enqueue({ data: null }) // no existing subscription row
enqueue({ data: null }) // no trial grant
enqueue({ data: null }) // upsert result
customersCreate.mockResolvedValue({ id: 'cus_new' })
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
expect(status).toBe(200)
expect(body.url).toBe('https://stripe.test/session')
expect(customersCreate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { company_id: 'company-1' } })
)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({ customer: 'cus_new' })
)
})
it('defers the first charge to the trial end when the trial has >48h left', async () => {
const trialEnd = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // active trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
subscription_data: expect.objectContaining({
metadata: { company_id: 'company-1' },
trial_end: Math.floor(new Date(trialEnd).getTime() / 1000),
}),
})
)
})
it('bills immediately when the trial is inside the 48h Stripe floor', async () => {
const trialEnd = new Date(Date.now() + 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // trial ends tomorrow
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('bills immediately when the trial has already expired', async () => {
const trialEnd = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // lapsed trial
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('fails closed (500, no Stripe session) when the trial lookup errors', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null, error: { message: 'boom' } }) // trial lookup fails
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(req, routeParams),
)
expect(status).toBe(500)
expect(body.error.code).toBe('TRIAL_LOOKUP_FAILED')
expect(sessionsCreate).not.toHaveBeenCalled()
})
describe('return URLs', () => {
function checkoutFrom(url: string) {
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null }) // no trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
return POST(createMockRequest(url, { method: 'POST', body: {} }), routeParams)
}
it('returns to the canonical app when checkout starts there', async () => {
const { status } = await parseJsonResponse(
await checkoutFrom('https://app.accounted.test/api/billing/checkout'),
)
expect(status).toBe(200)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
success_url: 'https://app.accounted.test/settings/billing?success=1',
cancel_url: 'https://app.accounted.test/settings/billing?canceled=1',
}),
)
})
it('returns to a registered white-label host when checkout starts there', async () => {
const { status } = await parseJsonResponse(
await checkoutFrom('https://portal.brand.test/api/billing/checkout'),
)
expect(status).toBe(200)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
success_url: 'https://portal.brand.test/settings/billing?success=1',
cancel_url: 'https://portal.brand.test/settings/billing?canceled=1',
}),
)
})
it('falls back to the canonical app for an unregistered or spoofed host', async () => {
const { status } = await parseJsonResponse(
await checkoutFrom('https://portal.brand.test.attacker.test/api/billing/checkout'),
)
expect(status).toBe(200)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
success_url: 'https://app.accounted.test/settings/billing?success=1',
cancel_url: 'https://app.accounted.test/settings/billing?canceled=1',
}),
)
})
})
})