From f3dbdbb37752a8d81deeb35e256ff5a4af5aaeaa Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 28 Oct 2026 14:17:17 -0300 Subject: [PATCH 1/2] fix(whatsapp): prevent live Stripe operations outside production --- .../src/app/api/billing/checkout/route.ts | 4 + .../src/app/api/billing/portal/route.ts | 4 + .../src/app/api/stripe/checkout/route.ts | 7 + .../src/app/api/stripe/portal/route.ts | 7 + .../webhook/__tests__/previewGuard.test.ts | 82 +++++++++ .../src/app/api/stripe/webhook/route.ts | 12 ++ .../infrastructure/stripeMeterClient.ts | 3 +- .../stripeClient.runtimePolicy.test.ts | 108 ++++++++++++ .../src/modules/stripe/stripeClient.ts | 46 ++++-- .../StripeAdapter.previewGuard.test.ts | 120 ++++++++++++++ .../src/__tests__/StripeAdapter.test.ts | 2 +- ...CustomerPortalAdapter.previewGuard.test.ts | 63 +++++++ .../src/__tests__/stripeRuntimePolicy.test.ts | 145 ++++++++++++++++ .../src/adapters/StripeAdapter.ts | 27 ++- .../adapters/StripeCustomerPortalAdapter.ts | 11 +- packages/billing-core/src/index.ts | 18 ++ .../billing-core/src/stripeRuntimePolicy.ts | 156 ++++++++++++++++++ 17 files changed, 786 insertions(+), 29 deletions(-) create mode 100644 apps/whatsapp-platform/src/app/api/stripe/webhook/__tests__/previewGuard.test.ts create mode 100644 apps/whatsapp-platform/src/modules/stripe/__tests__/stripeClient.runtimePolicy.test.ts create mode 100644 packages/billing-core/src/__tests__/StripeAdapter.previewGuard.test.ts create mode 100644 packages/billing-core/src/__tests__/StripeCustomerPortalAdapter.previewGuard.test.ts create mode 100644 packages/billing-core/src/__tests__/stripeRuntimePolicy.test.ts create mode 100644 packages/billing-core/src/stripeRuntimePolicy.ts diff --git a/apps/whatsapp-platform/src/app/api/billing/checkout/route.ts b/apps/whatsapp-platform/src/app/api/billing/checkout/route.ts index 1de35a12..ffd8a7bf 100644 --- a/apps/whatsapp-platform/src/app/api/billing/checkout/route.ts +++ b/apps/whatsapp-platform/src/app/api/billing/checkout/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { z } from "zod"; +import { isStripeEnvironmentDeniedError } from "@devflow/billing-core"; import { jsonError, jsonSuccess } from "@/lib/api-response"; import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import { getAuthFromRequest, requireRole, ROLES_MANAGER_PLUS } from "@/modules/auth"; @@ -62,6 +63,9 @@ export async function POST(request: NextRequest) { ); return jsonSuccess({ url: checkoutUrl }); } catch (e) { + if (isStripeEnvironmentDeniedError(e)) { + return jsonError("stripe_unavailable_in_environment", "Stripe is unavailable in this environment", 503); + } console.error("[billing/checkout]", e); const msg = e instanceof Error ? e.message : "Checkout indisponível"; return jsonError("CHECKOUT_UNAVAILABLE", msg, 502); diff --git a/apps/whatsapp-platform/src/app/api/billing/portal/route.ts b/apps/whatsapp-platform/src/app/api/billing/portal/route.ts index ac5f07e3..312dd4fa 100644 --- a/apps/whatsapp-platform/src/app/api/billing/portal/route.ts +++ b/apps/whatsapp-platform/src/app/api/billing/portal/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { isStripeEnvironmentDeniedError } from "@devflow/billing-core"; import { jsonError, jsonSuccess } from "@/lib/api-response"; import { getAuthFromRequest, requireRole, ROLES_MANAGER_PLUS } from "@/modules/auth"; import { createBillingPortalSession } from "@/modules/billing/billingService"; @@ -28,6 +29,9 @@ export async function POST(request: NextRequest) { const { portalUrl } = await createBillingPortalSession(auth.payload.tenantId, returnUrl); return jsonSuccess({ url: portalUrl }); } catch (e) { + if (isStripeEnvironmentDeniedError(e)) { + return jsonError("stripe_unavailable_in_environment", "Stripe is unavailable in this environment", 503); + } console.error("[billing/portal]", e); const msg = e instanceof Error ? e.message : "Portal indisponível"; return jsonError("BILLING_PORTAL_FAILED", msg, 400); diff --git a/apps/whatsapp-platform/src/app/api/stripe/checkout/route.ts b/apps/whatsapp-platform/src/app/api/stripe/checkout/route.ts index bcf513ec..78c3ad7f 100644 --- a/apps/whatsapp-platform/src/app/api/stripe/checkout/route.ts +++ b/apps/whatsapp-platform/src/app/api/stripe/checkout/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { isStripeEnvironmentDeniedError } from "@devflow/billing-core"; import { getAuthFromRequest, requireRole, ROLES_MANAGER_PLUS } from "@/modules/auth"; import { prisma } from "@/lib/prisma"; import { createCheckoutSession, isStripeConfigured, type StripePlanKey } from "@/modules/stripe"; @@ -87,6 +88,12 @@ export async function POST(request: NextRequest) { data: { url: checkoutUrl }, }); } catch (e) { + if (isStripeEnvironmentDeniedError(e)) { + return NextResponse.json( + { success: false, error: "Stripe is unavailable in this environment", code: "stripe_unavailable_in_environment" }, + { status: 503 } + ); + } console.error("[stripe/checkout]", e); const msg = e instanceof Error ? e.message : "Checkout indisponível"; return NextResponse.json({ success: false, error: msg }, { status: 502 }); diff --git a/apps/whatsapp-platform/src/app/api/stripe/portal/route.ts b/apps/whatsapp-platform/src/app/api/stripe/portal/route.ts index 978b623d..10e5f1ec 100644 --- a/apps/whatsapp-platform/src/app/api/stripe/portal/route.ts +++ b/apps/whatsapp-platform/src/app/api/stripe/portal/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { isStripeEnvironmentDeniedError } from "@devflow/billing-core"; import { getAuthFromRequest, requireRole, ROLES_MANAGER_PLUS } from "@/modules/auth"; import { prisma } from "@/lib/prisma"; import { createPortalSession } from "@/modules/stripe"; @@ -64,6 +65,12 @@ export async function POST(request: NextRequest) { data: { url: portalUrl }, }); } catch (e) { + if (isStripeEnvironmentDeniedError(e)) { + return NextResponse.json( + { success: false, error: "Stripe is unavailable in this environment", code: "stripe_unavailable_in_environment" }, + { status: 503 } + ); + } console.error("[stripe/portal]", e); const msg = e instanceof Error ? e.message : "Portal indisponível"; return NextResponse.json({ success: false, error: msg }, { status: 400 }); diff --git a/apps/whatsapp-platform/src/app/api/stripe/webhook/__tests__/previewGuard.test.ts b/apps/whatsapp-platform/src/app/api/stripe/webhook/__tests__/previewGuard.test.ts new file mode 100644 index 00000000..2eeecc3e --- /dev/null +++ b/apps/whatsapp-platform/src/app/api/stripe/webhook/__tests__/previewGuard.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockClaim = vi.fn(); +const mockValidateWebhook = vi.fn(); +const mockHandle = vi.fn(); + +vi.mock("@/modules/stripe/stripeWebhook", () => ({ + validateWebhook: (...a: unknown[]) => mockValidateWebhook(...a), + parseWebhookEvent: () => null, +})); + +vi.mock("@/modules/billing/infrastructure/billingRepository", () => ({ + claimStripeWebhookEvent: (...a: unknown[]) => mockClaim(...a), + markStripeWebhookFailed: vi.fn(), + markStripeWebhookProcessed: vi.fn(), +})); + +vi.mock("@/modules/billing/billingWebhookService", () => ({ + handleStripeWebhookEvent: (...a: unknown[]) => mockHandle(...a), + resolveTenantId: vi.fn(), +})); + +vi.mock("@/modules/billing/billingObserverService", () => ({ + logStripeEvent: vi.fn(), + logSystemError: vi.fn(), +})); + +vi.mock("@/modules/stripe/webhookLogger", () => ({ + logWebhookInvalidSignature: vi.fn(), +})); + +vi.mock("@/lib/platformAuditLog", () => ({ + recordPlatformAudit: vi.fn(), +})); + +describe("POST /api/stripe/webhook preview guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("T04 Preview returns 503 before claim / validate / handler", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + const { POST } = await import("../route"); + const req = new NextRequest("http://x/api/stripe/webhook", { + method: "POST", + headers: { "stripe-signature": "sig_synthetic" }, + body: "{}", + }); + const res = await POST(req); + expect(res.status).toBe(503); + expect(await res.text()).toContain("unavailable"); + expect(mockValidateWebhook).not.toHaveBeenCalled(); + expect(mockClaim).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); + }); + + it("T07 Production proceeds past environment gate to signature validation", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "production"); + mockValidateWebhook.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + const { POST } = await import("../route"); + const req = new NextRequest("http://x/api/stripe/webhook", { + method: "POST", + headers: { "stripe-signature": "sig_synthetic" }, + body: "{}", + }); + const res = await POST(req); + expect(res.status).toBe(400); + expect(mockValidateWebhook).toHaveBeenCalledOnce(); + expect(mockClaim).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/whatsapp-platform/src/app/api/stripe/webhook/route.ts b/apps/whatsapp-platform/src/app/api/stripe/webhook/route.ts index 41a843cf..ee1712c9 100644 --- a/apps/whatsapp-platform/src/app/api/stripe/webhook/route.ts +++ b/apps/whatsapp-platform/src/app/api/stripe/webhook/route.ts @@ -13,6 +13,10 @@ import type Stripe from "stripe"; import { NextRequest } from "next/server"; +import { + isStripeEnvironmentDeniedError, + isStripeWebhookProcessingAllowed, +} from "@devflow/billing-core"; import { validateWebhook } from "@/modules/stripe/stripeWebhook"; import { claimStripeWebhookEvent, @@ -31,6 +35,11 @@ import { recordPlatformAudit } from "@/lib/platformAuditLog"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest) { + // --- 0. Fail-closed: Preview / non-production hosts must not mutate billing --- + if (!isStripeWebhookProcessingAllowed()) { + return new Response("Stripe unavailable in this environment", { status: 503 }); + } + // --- 1. Segurança: validar assinatura --- const signature = request.headers.get("stripe-signature"); if (!signature) { @@ -48,6 +57,9 @@ export async function POST(request: NextRequest) { try { event = validateWebhook(signature, payload) as Stripe.Event; } catch (err) { + if (isStripeEnvironmentDeniedError(err)) { + return new Response("Stripe unavailable in this environment", { status: 503 }); + } logWebhookInvalidSignature(err); return new Response("Webhook Error", { status: 400 }); } diff --git a/apps/whatsapp-platform/src/modules/billing/infrastructure/stripeMeterClient.ts b/apps/whatsapp-platform/src/modules/billing/infrastructure/stripeMeterClient.ts index f6391643..38e9fc8b 100644 --- a/apps/whatsapp-platform/src/modules/billing/infrastructure/stripeMeterClient.ts +++ b/apps/whatsapp-platform/src/modules/billing/infrastructure/stripeMeterClient.ts @@ -40,7 +40,6 @@ export type CreateMeterEventParams = { export async function createMeterEvent( params: CreateMeterEventParams ): Promise<{ ok: true } | { ok: false; error: string }> { - const stripe = getStripe(); const ts = params.timestamp ?? Math.floor(Date.now() / 1000); const prefix = params.eventName === METER_EVENT_MESSAGES @@ -52,6 +51,8 @@ export async function createMeterEvent( params.identifier ?? `${prefix}_${params.stripeCustomerId}_${Date.now()}`; try { + // getStripe() enforces fail-closed runtime policy before any network call. + const stripe = getStripe(); await stripe.billing.meterEvents.create({ event_name: params.eventName, payload: { diff --git a/apps/whatsapp-platform/src/modules/stripe/__tests__/stripeClient.runtimePolicy.test.ts b/apps/whatsapp-platform/src/modules/stripe/__tests__/stripeClient.runtimePolicy.test.ts new file mode 100644 index 00000000..2d636c23 --- /dev/null +++ b/apps/whatsapp-platform/src/modules/stripe/__tests__/stripeClient.runtimePolicy.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mockMeterCreate = vi.fn(); + +vi.mock("stripe", () => ({ + default: vi.fn().mockImplementation(function (this: unknown) { + return { + billing: { + meterEvents: { + create: (...args: unknown[]) => mockMeterCreate(...args), + }, + }, + billingPortal: { + sessions: { + create: vi.fn(), + }, + }, + checkout: { + sessions: { + create: vi.fn(), + }, + }, + webhooks: { + constructEvent: vi.fn(), + }, + }; + }), +})); + +describe("whatsapp stripeClient runtime policy", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + delete process.env.VERCEL; + delete process.env.VERCEL_ENV; + delete process.env.WHATSAPP_STRIPE_SECRET_KEY; + delete process.env.WHATSAPP_STRIPE_TEST_SECRET_KEY; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_TEST_SECRET_KEY; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("Preview + synthetic live key: getStripe throws before SDK use", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("WHATSAPP_STRIPE_SECRET_KEY", "sk_live_synthetic_not_real"); + const { getStripe, isStripeConfigured } = await import("../stripeClient"); + expect(isStripeConfigured()).toBe(false); + expect(() => getStripe()).toThrow( + expect.objectContaining({ code: "stripe_unavailable_in_environment" }) + ); + }); + + it("T03 Preview blocks meterEvents via getStripe", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("WHATSAPP_STRIPE_SECRET_KEY", "sk_live_synthetic_not_real"); + vi.stubEnv("WHATSAPP_STRIPE_METERED_PRICE_MESSAGES", "price_msg_synthetic"); + const { createMeterEvent } = await import( + "@/modules/billing/infrastructure/stripeMeterClient" + ); + const result = await createMeterEvent({ + eventName: "whatsapp_messages", + stripeCustomerId: "cus_synthetic", + value: 1, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).not.toContain("sk_live_"); + } + expect(mockMeterCreate).not.toHaveBeenCalled(); + }); + + it("local test key remains usable", async () => { + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("WHATSAPP_STRIPE_TEST_SECRET_KEY", "sk_test_synthetic_not_real"); + const { getStripe, isStripeConfigured } = await import("../stripeClient"); + expect(isStripeConfigured()).toBe(true); + expect(() => getStripe()).not.toThrow(); + }); + + it("verified Production allows live key", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "production"); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("WHATSAPP_STRIPE_SECRET_KEY", "sk_live_synthetic_not_real"); + const { getStripe, isStripeConfigured } = await import("../stripeClient"); + expect(isStripeConfigured()).toBe(true); + expect(() => getStripe()).not.toThrow(); + }); + + it("getWebhookSecret denied on Preview before reading secret use", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("WHATSAPP_STRIPE_WEBHOOK_SECRET", "whsec_synthetic_should_not_leak"); + const { getWebhookSecret } = await import("../stripeClient"); + expect(() => getWebhookSecret()).toThrow( + expect.objectContaining({ code: "stripe_unavailable_in_environment" }) + ); + }); +}); diff --git a/apps/whatsapp-platform/src/modules/stripe/stripeClient.ts b/apps/whatsapp-platform/src/modules/stripe/stripeClient.ts index 2566a729..cf5a6cb9 100644 --- a/apps/whatsapp-platform/src/modules/stripe/stripeClient.ts +++ b/apps/whatsapp-platform/src/modules/stripe/stripeClient.ts @@ -1,14 +1,23 @@ /** * Cliente Stripe configurado com variáveis de ambiente. * Namespace WHATSAPP_* (preferido) com fallback para nomes legados. + * + * NODE_ENV only selects which env var names to read (LIVE vs TEST names). + * Authorization is fail-closed via @devflow/billing-core stripeRuntimePolicy: + * Live keys require verified Vercel Production; Preview/unknown deny all Stripe. */ import Stripe from "stripe"; +import { + assertStripeSecretKeyAllowed, + assertStripeWebhookProcessingAllowed, + isStripeSecretKeyAllowed, +} from "@devflow/billing-core"; -/** Em dev: usa apenas TEST. Em prod: usa apenas LIVE. Evita mistura de ambientes. */ +/** Prefer LIVE var names when NODE_ENV=production; otherwise prefer TEST var names. */ function getSecretKey(): string | undefined { - const isProd = process.env.NODE_ENV === "production"; - if (isProd) { + const preferLiveVarNames = process.env.NODE_ENV === "production"; + if (preferLiveVarNames) { return ( process.env.WHATSAPP_STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY ?? @@ -29,13 +38,15 @@ export function getStripe(): Stripe { "WHATSAPP_STRIPE_SECRET_KEY or WHATSAPP_STRIPE_TEST_SECRET_KEY (or STRIPE_* legacy) is required" ); } + assertStripeSecretKeyAllowed(secretKey); return new Stripe(secretKey); } -/** Em dev: usa webhook TEST. Em prod: usa webhook LIVE. */ +/** Prefer LIVE webhook secret names when NODE_ENV=production; otherwise TEST names. */ export function getWebhookSecret(): string { - const isProd = process.env.NODE_ENV === "production"; - const secret = isProd + assertStripeWebhookProcessingAllowed(); + const preferLiveVarNames = process.env.NODE_ENV === "production"; + const secret = preferLiveVarNames ? (process.env.WHATSAPP_STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET ?? "") : (process.env.WHATSAPP_STRIPE_TEST_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET ?? ""); if (!secret) { @@ -46,46 +57,49 @@ export function getWebhookSecret(): string { return secret; } -function readScalePriceId(isProd: boolean): string { - return isProd +function readScalePriceId(preferLiveVarNames: boolean): string { + return preferLiveVarNames ? (process.env.WHATSAPP_STRIPE_PRICE_SCALE ?? process.env.WHATSAPP_STRIPE_PRICE_TEAM ?? process.env.STRIPE_PRICE_SCALE ?? process.env.STRIPE_PRICE_TEAM ?? "") : (process.env.WHATSAPP_STRIPE_TEST_PRICE_SCALE ?? process.env.WHATSAPP_STRIPE_TEST_PRICE_TEAM ?? process.env.WHATSAPP_STRIPE_PRICE_SCALE ?? process.env.WHATSAPP_STRIPE_PRICE_TEAM ?? process.env.STRIPE_TEST_PRICE_SCALE ?? process.env.STRIPE_TEST_PRICE_TEAM ?? ""); } -/** Em dev: usa TEST price IDs. Em prod: usa LIVE price IDs. */ +/** Prefer LIVE price IDs when NODE_ENV=production; otherwise prefer TEST price IDs. */ export function getPriceId(plan: "OPERATIONAL_BASE" | "PRO" | "SCALE" | "STARTER"): string { - const isProd = process.env.NODE_ENV === "production"; + const preferLiveVarNames = process.env.NODE_ENV === "production"; if (plan === "OPERATIONAL_BASE") { - const priceId = isProd + const priceId = preferLiveVarNames ? (process.env.WHATSAPP_STRIPE_PRICE_OPERATIONAL_BASE ?? process.env.WHATSAPP_STRIPE_PRICE_SCALE ?? "") : (process.env.WHATSAPP_STRIPE_TEST_PRICE_OPERATIONAL_BASE ?? process.env.WHATSAPP_STRIPE_TEST_PRICE_SCALE ?? process.env.WHATSAPP_STRIPE_PRICE_OPERATIONAL_BASE ?? ""); if (priceId) return priceId; - const fallback = readScalePriceId(isProd); + const fallback = readScalePriceId(preferLiveVarNames); if (!fallback) throw new Error("WHATSAPP_STRIPE_PRICE_OPERATIONAL_BASE ou WHATSAPP_STRIPE_PRICE_SCALE (TEST em dev) is required"); return fallback; } if (plan === "STARTER") { - const priceId = isProd + const priceId = preferLiveVarNames ? (process.env.WHATSAPP_STRIPE_PRICE_STARTER ?? "") : (process.env.WHATSAPP_STRIPE_TEST_PRICE_STARTER ?? process.env.WHATSAPP_STRIPE_PRICE_STARTER ?? ""); if (!priceId) throw new Error("WHATSAPP_STRIPE_PRICE_STARTER (ou TEST em dev) is required"); return priceId; } if (plan === "PRO") { - const priceId = isProd + const priceId = preferLiveVarNames ? (process.env.WHATSAPP_STRIPE_PRICE_PRO ?? process.env.STRIPE_PRICE_PRO ?? "") : (process.env.WHATSAPP_STRIPE_TEST_PRICE_PRO ?? process.env.WHATSAPP_STRIPE_PRICE_PRO ?? process.env.STRIPE_TEST_PRICE_PRO ?? ""); if (!priceId) throw new Error("WHATSAPP_STRIPE_PRICE_PRO (ou TEST em dev) is required"); return priceId; } - const priceId = readScalePriceId(isProd); + const priceId = readScalePriceId(preferLiveVarNames); if (!priceId) throw new Error("WHATSAPP_STRIPE_PRICE_SCALE (ou TEST em dev) is required"); return priceId; } +/** True when a secret is present and the runtime policy allows using it. */ export function isStripeConfigured(): boolean { - return Boolean(getSecretKey()); + const secretKey = getSecretKey(); + if (!secretKey) return false; + return isStripeSecretKeyAllowed(secretKey); } diff --git a/packages/billing-core/src/__tests__/StripeAdapter.previewGuard.test.ts b/packages/billing-core/src/__tests__/StripeAdapter.previewGuard.test.ts new file mode 100644 index 00000000..b0c049a8 --- /dev/null +++ b/packages/billing-core/src/__tests__/StripeAdapter.previewGuard.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mockSessionsCreate = vi.fn(); +const mockConstructEvent = vi.fn(); + +vi.mock("stripe", () => ({ + default: vi.fn().mockImplementation(function (this: unknown) { + return { + checkout: { + sessions: { + create: mockSessionsCreate, + }, + }, + webhooks: { + constructEvent: mockConstructEvent, + }, + }; + }), +})); + +describe("StripeAdapter preview / live fail-closed", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + process.env.STRIPE_SECRET_KEY = "sk_live_synthetic_not_real"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_synthetic"; + process.env.STRIPE_PRICE_PRO = "price_live_synthetic"; + process.env.STRIPE_PRICE_TEAM = "price_team_synthetic"; + delete process.env.STRIPE_TEST_SECRET_KEY; + delete process.env.VERCEL; + delete process.env.VERCEL_ENV; + mockSessionsCreate.mockResolvedValue({ + url: "https://checkout.stripe.com/session/xxx", + id: "cs_123", + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("T01 Preview + live key: no checkout network call", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + const { createCheckoutSession } = await import("../adapters/StripeAdapter"); + await expect( + createCheckoutSession({ + userId: "user-1", + email: "u@example.com", + planId: "PRO", + successUrl: "https://app.com/success", + cancelUrl: "https://app.com/cancel", + }) + ).rejects.toMatchObject({ code: "stripe_unavailable_in_environment" }); + expect(mockSessionsCreate).not.toHaveBeenCalled(); + }); + + it("T05 legacy billing-core live key blocked on Preview", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + const { createCheckoutSession } = await import("../adapters/StripeAdapter"); + await expect( + createCheckoutSession({ + userId: "user-1", + email: "u@example.com", + planId: "PRO", + successUrl: "/s", + cancelUrl: "/c", + }) + ).rejects.toMatchObject({ code: "stripe_unavailable_in_environment" }); + expect(mockSessionsCreate).not.toHaveBeenCalled(); + }); + + it("T06 unknown environment blocks live writes", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "Production"); + vi.stubEnv("NODE_ENV", "production"); + const { createCheckoutSession } = await import("../adapters/StripeAdapter"); + await expect( + createCheckoutSession({ + userId: "user-1", + email: "u@example.com", + planId: "PRO", + successUrl: "/s", + cancelUrl: "/c", + }) + ).rejects.toMatchObject({ code: "stripe_unavailable_in_environment" }); + expect(mockSessionsCreate).not.toHaveBeenCalled(); + }); + + it("T07 Production mocked: checkout still reachable", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "production"); + vi.stubEnv("NODE_ENV", "production"); + const { createCheckoutSession } = await import("../adapters/StripeAdapter"); + const result = await createCheckoutSession({ + userId: "user-1", + email: "u@example.com", + planId: "PRO", + successUrl: "/s", + cancelUrl: "/c", + }); + expect(result.checkoutUrl).toContain("checkout.stripe.com"); + expect(mockSessionsCreate).toHaveBeenCalledOnce(); + }); + + it("T04 Preview blocks webhook validation before constructEvent", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + const { validateWebhook } = await import("../adapters/StripeAdapter"); + expect(() => validateWebhook("sig", "payload")).toThrow( + expect.objectContaining({ code: "stripe_unavailable_in_environment" }) + ); + expect(mockConstructEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/billing-core/src/__tests__/StripeAdapter.test.ts b/packages/billing-core/src/__tests__/StripeAdapter.test.ts index aeabbd2f..db9fc48c 100644 --- a/packages/billing-core/src/__tests__/StripeAdapter.test.ts +++ b/packages/billing-core/src/__tests__/StripeAdapter.test.ts @@ -171,7 +171,7 @@ describe("StripeAdapter", () => { mode: "subscription", client_reference_id: "user-1", customer_email: "u@example.com", - metadata: { userId: "user-1", planId: "PRO" }, + metadata: expect.objectContaining({ userId: "user-1", planId: "PRO" }), }) ); }); diff --git a/packages/billing-core/src/__tests__/StripeCustomerPortalAdapter.previewGuard.test.ts b/packages/billing-core/src/__tests__/StripeCustomerPortalAdapter.previewGuard.test.ts new file mode 100644 index 00000000..5095201e --- /dev/null +++ b/packages/billing-core/src/__tests__/StripeCustomerPortalAdapter.previewGuard.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mockPortalCreate = vi.fn(); + +vi.mock("stripe", () => ({ + default: vi.fn().mockImplementation(function (this: unknown) { + return { + billingPortal: { + sessions: { + create: (...args: unknown[]) => mockPortalCreate(...args), + }, + }, + }; + }), +})); + +describe("StripeCustomerPortalAdapter preview guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); + process.env.STRIPE_SECRET_KEY = "sk_live_synthetic_not_real"; + delete process.env.STRIPE_TEST_SECRET_KEY; + delete process.env.VERCEL; + delete process.env.VERCEL_ENV; + mockPortalCreate.mockResolvedValue({ url: "https://billing.stripe.com/session/xxx" }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("T02 Preview blocks billingPortal.sessions.create", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("NODE_ENV", "production"); + const { createCustomerPortalSession } = await import( + "../adapters/StripeCustomerPortalAdapter" + ); + await expect( + createCustomerPortalSession({ + stripeCustomerId: "cus_synthetic", + returnUrl: "https://app.example/billing", + }) + ).rejects.toMatchObject({ code: "stripe_unavailable_in_environment" }); + expect(mockPortalCreate).not.toHaveBeenCalled(); + }); + + it("T07 Production mocked portal still works", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "production"); + vi.stubEnv("NODE_ENV", "production"); + const { createCustomerPortalSession } = await import( + "../adapters/StripeCustomerPortalAdapter" + ); + const result = await createCustomerPortalSession({ + stripeCustomerId: "cus_synthetic", + returnUrl: "https://app.example/billing", + }); + expect(result.portalUrl).toContain("billing.stripe.com"); + expect(mockPortalCreate).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/billing-core/src/__tests__/stripeRuntimePolicy.test.ts b/packages/billing-core/src/__tests__/stripeRuntimePolicy.test.ts new file mode 100644 index 00000000..c7786862 --- /dev/null +++ b/packages/billing-core/src/__tests__/stripeRuntimePolicy.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { + STRIPE_UNAVAILABLE_IN_ENVIRONMENT, + StripeEnvironmentDeniedError, + assertStripeSecretKeyAllowed, + assertStripeWebhookProcessingAllowed, + classifyStripeSecretKey, + evaluateStripeRuntimeAccess, + isStripeEnvironmentDeniedError, + isStripeSecretKeyAllowed, + isStripeWebhookProcessingAllowed, +} from "../stripeRuntimePolicy"; + +const LIVE = "sk_live_synthetic_audit_key_not_real"; +const TEST = "sk_test_synthetic_audit_key_not_real"; +const UNKNOWN = "rk_restricted_or_opaque_key"; + +describe("stripeRuntimePolicy", () => { + describe("classifyStripeSecretKey", () => { + it("classifies live and test prefixes", () => { + expect(classifyStripeSecretKey(LIVE)).toBe("live"); + expect(classifyStripeSecretKey(TEST)).toBe("test"); + expect(classifyStripeSecretKey(UNKNOWN)).toBe("unknown"); + }); + }); + + describe("environment matrix (E01–E10)", () => { + it("E01 Vercel Production + Live → allowed", () => { + const d = evaluateStripeRuntimeAccess( + { VERCEL: "1", VERCEL_ENV: "production" }, + "live" + ); + expect(d).toEqual({ allowed: true, deployment: "vercel_production" }); + expect(() => + assertStripeSecretKeyAllowed(LIVE, { VERCEL: "1", VERCEL_ENV: "production" }) + ).not.toThrow(); + }); + + it("E02 Vercel Preview + Live → blocked", () => { + const d = evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "preview" }, "live"); + expect(d.allowed).toBe(false); + if (!d.allowed) expect(d.deployment).toBe("vercel_preview"); + expect(() => + assertStripeSecretKeyAllowed(LIVE, { VERCEL: "1", VERCEL_ENV: "preview" }) + ).toThrow(StripeEnvironmentDeniedError); + }); + + it("E03 Vercel Development + Live → blocked", () => { + expect( + evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "development" }, "live").allowed + ).toBe(false); + }); + + it("E04 Vercel without VERCEL_ENV + Live → blocked", () => { + const d = evaluateStripeRuntimeAccess({ VERCEL: "1" }, "live"); + expect(d.allowed).toBe(false); + if (!d.allowed) expect(d.deployment).toBe("vercel_unknown"); + }); + + it("E05 VERCEL_ENV=Production (wrong case) → blocked", () => { + expect( + evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "Production" }, "live").allowed + ).toBe(false); + }); + + it("E06 unknown VERCEL_ENV → blocked", () => { + expect( + evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "staging" }, "live").allowed + ).toBe(false); + }); + + it("E07 CI local Live → blocked", () => { + expect(evaluateStripeRuntimeAccess({}, "live").allowed).toBe(false); + expect(isStripeSecretKeyAllowed(LIVE, {})).toBe(false); + }); + + it("E08 Dev local Live → blocked by default", () => { + expect(() => assertStripeSecretKeyAllowed(LIVE, {})).toThrow(StripeEnvironmentDeniedError); + }); + + it("E09 Preview without usable key still denied for webhook", () => { + expect(isStripeWebhookProcessingAllowed({ VERCEL: "1", VERCEL_ENV: "preview" })).toBe( + false + ); + }); + + it("E10 Production policy allows access check even when key missing is separate", () => { + expect( + evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "production" }, "live").allowed + ).toBe(true); + }); + }); + + describe("local test keys (T08)", () => { + it("allows sk_test_ locally without Vercel markers", () => { + expect(evaluateStripeRuntimeAccess({}, "test")).toEqual({ + allowed: true, + deployment: "local_test", + }); + expect(() => assertStripeSecretKeyAllowed(TEST, {})).not.toThrow(); + }); + + it("does not treat test keys as live authorization on Preview", () => { + expect( + evaluateStripeRuntimeAccess({ VERCEL: "1", VERCEL_ENV: "preview" }, "test").allowed + ).toBe(false); + }); + + it("unknown key mode is fail-closed locally", () => { + expect(evaluateStripeRuntimeAccess({}, "unknown").allowed).toBe(false); + }); + }); + + describe("webhook processing", () => { + it("allows Production and local; blocks Preview", () => { + expect(() => + assertStripeWebhookProcessingAllowed({ VERCEL: "1", VERCEL_ENV: "production" }) + ).not.toThrow(); + expect(() => assertStripeWebhookProcessingAllowed({})).not.toThrow(); + expect(() => + assertStripeWebhookProcessingAllowed({ VERCEL: "1", VERCEL_ENV: "preview" }) + ).toThrow(StripeEnvironmentDeniedError); + }); + + it("VERCEL_ENV=production without VERCEL=1 is fail-closed", () => { + expect(isStripeWebhookProcessingAllowed({ VERCEL_ENV: "production" })).toBe(false); + }); + }); + + describe("error sanitization (T09)", () => { + it("denied error has stable code and no secret material", () => { + try { + assertStripeSecretKeyAllowed(LIVE, { VERCEL: "1", VERCEL_ENV: "preview" }); + expect.unreachable(); + } catch (err) { + expect(isStripeEnvironmentDeniedError(err)).toBe(true); + expect((err as StripeEnvironmentDeniedError).code).toBe( + STRIPE_UNAVAILABLE_IN_ENVIRONMENT + ); + expect(String(err)).not.toContain("sk_live_"); + expect(String(err)).not.toContain(LIVE); + } + }); + }); +}); diff --git a/packages/billing-core/src/adapters/StripeAdapter.ts b/packages/billing-core/src/adapters/StripeAdapter.ts index eaa7bbb0..86b1359d 100644 --- a/packages/billing-core/src/adapters/StripeAdapter.ts +++ b/packages/billing-core/src/adapters/StripeAdapter.ts @@ -1,6 +1,9 @@ /** * Adapter de pagamento Stripe: checkout session e webhooks. * Arquitetura permite trocar por Lemon, Paddle, Mercado Pago sem alterar o resto. + * + * Live financial ops require verified Vercel Production — see stripeRuntimePolicy. + * NODE_ENV only selects which env var names to read; it is not a security boundary. */ import Stripe from "stripe"; @@ -10,17 +13,21 @@ import type { WebhookParsedEvent, PlanIdPaid, } from "../types"; +import { + assertStripeSecretKeyAllowed, + assertStripeWebhookProcessingAllowed, +} from "../stripeRuntimePolicy"; /** - * Em desenvolvimento, usa STRIPE_TEST_SECRET_KEY se disponível (cartões de teste). - * Em produção, usa STRIPE_SECRET_KEY (chave live). + * Prefer STRIPE_TEST_SECRET_KEY outside NODE_ENV=production. + * Authorization of the resolved key is enforced separately (fail-closed). */ const getSecretKey = (): string => { - const isDev = process.env.NODE_ENV !== "production"; + const preferTestVars = process.env.NODE_ENV !== "production"; const testKey = process.env.STRIPE_TEST_SECRET_KEY; const liveKey = process.env.STRIPE_SECRET_KEY; - if (isDev && testKey) return testKey; + if (preferTestVars && testKey) return testKey; if (liveKey) return liveKey; throw new Error("STRIPE_SECRET_KEY is not set"); }; @@ -32,18 +39,21 @@ const getWebhookSecret = (): string => { }; const getPriceId = (planId: PlanIdPaid): string => { - const isDev = process.env.NODE_ENV !== "production"; + const preferTestVars = process.env.NODE_ENV !== "production"; const baseKey = planId === "PRO" ? "PRICE_PRO" : "PRICE_TEAM"; const testKey = `STRIPE_TEST_${baseKey}`; const liveKey = `STRIPE_${baseKey}`; - const priceId = (isDev && process.env[testKey]) ? process.env[testKey] : process.env[liveKey]; - if (!priceId) throw new Error(`${isDev ? testKey : liveKey} is not set`); + const priceId = + preferTestVars && process.env[testKey] ? process.env[testKey] : process.env[liveKey]; + if (!priceId) throw new Error(`${preferTestVars ? testKey : liveKey} is not set`); return priceId; }; function getStripe(): Stripe { - return new Stripe(getSecretKey()); + const secretKey = getSecretKey(); + assertStripeSecretKeyAllowed(secretKey); + return new Stripe(secretKey); } /** @@ -88,6 +98,7 @@ export async function createCheckoutSession( * Valida a assinatura do webhook e retorna o evento construído. */ export function validateWebhook(signature: string, payload: string | Buffer): Stripe.Event { + assertStripeWebhookProcessingAllowed(); const secret = getWebhookSecret(); const stripe = getStripe(); return stripe.webhooks.constructEvent(payload, signature, secret); diff --git a/packages/billing-core/src/adapters/StripeCustomerPortalAdapter.ts b/packages/billing-core/src/adapters/StripeCustomerPortalAdapter.ts index e5559f4c..cd8714c1 100644 --- a/packages/billing-core/src/adapters/StripeCustomerPortalAdapter.ts +++ b/packages/billing-core/src/adapters/StripeCustomerPortalAdapter.ts @@ -2,6 +2,8 @@ * Adapter do Stripe Customer Portal. * Cria sessões de self-service para o usuário gerenciar sua assinatura. * Arquitetura permite substituição por outro gateway sem alterar o serviço. + * + * Live financial ops require verified Vercel Production — see stripeRuntimePolicy. */ import Stripe from "stripe"; @@ -10,18 +12,21 @@ import type { CreateCustomerPortalResult, CustomerPortalAdapter, } from "../types"; +import { assertStripeSecretKeyAllowed } from "../stripeRuntimePolicy"; function getSecretKey(): string { - const isDev = process.env.NODE_ENV !== "production"; + const preferTestVars = process.env.NODE_ENV !== "production"; const testKey = process.env.STRIPE_TEST_SECRET_KEY; const liveKey = process.env.STRIPE_SECRET_KEY; - if (isDev && testKey) return testKey; + if (preferTestVars && testKey) return testKey; if (liveKey) return liveKey; throw new Error("STRIPE_SECRET_KEY is not set"); } function getStripe(): Stripe { - return new Stripe(getSecretKey()); + const secretKey = getSecretKey(); + assertStripeSecretKeyAllowed(secretKey); + return new Stripe(secretKey); } export async function createCustomerPortalSession( diff --git a/packages/billing-core/src/index.ts b/packages/billing-core/src/index.ts index 85a0b4f0..9bea16ae 100644 --- a/packages/billing-core/src/index.ts +++ b/packages/billing-core/src/index.ts @@ -8,6 +8,24 @@ export { stripeCustomerPortalAdapter, createCustomerPortalSession, } from "./adapters/StripeCustomerPortalAdapter"; +export { + STRIPE_UNAVAILABLE_IN_ENVIRONMENT, + StripeEnvironmentDeniedError, + assertStripeSecretKeyAllowed, + assertStripeWebhookProcessingAllowed, + classifyStripeSecretKey, + evaluateStripeRuntimeAccess, + isNonProductionHostedDeployment, + isStripeEnvironmentDeniedError, + isStripeSecretKeyAllowed, + isStripeWebhookProcessingAllowed, + isVerifiedVercelProduction, +} from "./stripeRuntimePolicy"; +export type { + StripeEnvSnapshot, + StripeKeyMode, + StripeRuntimeDecision, +} from "./stripeRuntimePolicy"; export type { PlanIdPaid, CreateCheckoutParams, diff --git a/packages/billing-core/src/stripeRuntimePolicy.ts b/packages/billing-core/src/stripeRuntimePolicy.ts new file mode 100644 index 00000000..774801b0 --- /dev/null +++ b/packages/billing-core/src/stripeRuntimePolicy.ts @@ -0,0 +1,156 @@ +/** + * Fail-closed Stripe runtime policy. + * + * NODE_ENV must never authorize Live financial operations. + * Live is allowed only on verified Vercel Production (VERCEL=1 + VERCEL_ENV=production). + * Local/CI may use explicit Test keys (sk_test_ / rk_test_) only. + */ + +export const STRIPE_UNAVAILABLE_IN_ENVIRONMENT = "stripe_unavailable_in_environment" as const; + +export type StripeKeyMode = "live" | "test" | "unknown"; + +/** Explicit env slice — avoid assigning NodeJS.ProcessEnv directly under exactOptionalPropertyTypes. */ +export type StripeEnvSnapshot = { + VERCEL?: string | undefined; + VERCEL_ENV?: string | undefined; +}; + +export type StripeRuntimeDecision = + | { allowed: true; deployment: "vercel_production" | "local_test" } + | { + allowed: false; + code: typeof STRIPE_UNAVAILABLE_IN_ENVIRONMENT; + deployment: "vercel_preview" | "vercel_development" | "vercel_unknown" | "local_live_blocked"; + }; + +export class StripeEnvironmentDeniedError extends Error { + readonly code = STRIPE_UNAVAILABLE_IN_ENVIRONMENT; + + constructor(message = "Stripe is unavailable in this environment") { + super(message); + this.name = "StripeEnvironmentDeniedError"; + } +} + +export function isStripeEnvironmentDeniedError( + err: unknown +): err is StripeEnvironmentDeniedError { + return ( + err instanceof StripeEnvironmentDeniedError || + (typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === STRIPE_UNAVAILABLE_IN_ENVIRONMENT) + ); +} + +function readStripeEnv(env?: StripeEnvSnapshot): StripeEnvSnapshot { + const source = env ?? { + VERCEL: process.env.VERCEL, + VERCEL_ENV: process.env.VERCEL_ENV, + }; + return { + VERCEL: source.VERCEL, + VERCEL_ENV: source.VERCEL_ENV, + }; +} + +export function classifyStripeSecretKey(secretKey: string): StripeKeyMode { + const key = secretKey.trim(); + if (key.startsWith("sk_live_") || key.startsWith("rk_live_")) return "live"; + if (key.startsWith("sk_test_") || key.startsWith("rk_test_")) return "test"; + return "unknown"; +} + +export function isVerifiedVercelProduction(env?: StripeEnvSnapshot): boolean { + const { VERCEL, VERCEL_ENV } = readStripeEnv(env); + return VERCEL === "1" && VERCEL_ENV === "production"; +} + +/** + * Any hosted/non-exact-production Vercel signal → treat as non-production host. + * Includes preview, development, missing/malformed VERCEL_ENV, and VERCEL_ENV=production without VERCEL=1. + */ +export function isNonProductionHostedDeployment(env?: StripeEnvSnapshot): boolean { + if (isVerifiedVercelProduction(env)) return false; + + const { VERCEL, VERCEL_ENV } = readStripeEnv(env); + const vercelEnv = VERCEL_ENV?.trim() ?? ""; + if (VERCEL === "1") return true; + if (vercelEnv.length > 0) return true; + return false; +} + +export function evaluateStripeRuntimeAccess( + env: StripeEnvSnapshot | undefined, + keyMode: StripeKeyMode +): StripeRuntimeDecision { + if (isVerifiedVercelProduction(env)) { + return { allowed: true, deployment: "vercel_production" }; + } + + if (isNonProductionHostedDeployment(env)) { + const vercelEnv = readStripeEnv(env).VERCEL_ENV?.trim() ?? ""; + const deployment = + vercelEnv === "preview" + ? ("vercel_preview" as const) + : vercelEnv === "development" + ? ("vercel_development" as const) + : ("vercel_unknown" as const); + return { + allowed: false, + code: STRIPE_UNAVAILABLE_IN_ENVIRONMENT, + deployment, + }; + } + + if (keyMode === "test") { + return { allowed: true, deployment: "local_test" }; + } + + return { + allowed: false, + code: STRIPE_UNAVAILABLE_IN_ENVIRONMENT, + deployment: "local_live_blocked", + }; +} + +/** Authorize use of a Stripe API secret before constructing the SDK / calling the API. */ +export function assertStripeSecretKeyAllowed( + secretKey: string, + env?: StripeEnvSnapshot +): void { + const decision = evaluateStripeRuntimeAccess(env, classifyStripeSecretKey(secretKey)); + if (!decision.allowed) { + throw new StripeEnvironmentDeniedError(); + } +} + +/** + * Webhook signing secrets are not sk_live_/sk_test_; authorize by deployment identity. + * Local/CI without Vercel markers may process webhooks (Stripe CLI / test endpoint). + * Vercel Preview/Development/unknown must not mutate billing state. + */ +export function assertStripeWebhookProcessingAllowed(env?: StripeEnvSnapshot): void { + if (isVerifiedVercelProduction(env)) return; + if (isNonProductionHostedDeployment(env)) { + throw new StripeEnvironmentDeniedError(); + } +} + +export function isStripeWebhookProcessingAllowed(env?: StripeEnvSnapshot): boolean { + try { + assertStripeWebhookProcessingAllowed(env); + return true; + } catch { + return false; + } +} + +export function isStripeSecretKeyAllowed( + secretKey: string, + env?: StripeEnvSnapshot +): boolean { + return evaluateStripeRuntimeAccess(env, classifyStripeSecretKey(secretKey)).allowed; +} From 61357c192a8ed8f9812994d534411f87d6d6e5e3 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 29 Oct 2026 16:08:07 -0300 Subject: [PATCH 2/2] fix(whatsapp): allow safe preview startup without production database --- apps/whatsapp-platform/instrumentation.ts | 17 +- .../src/config/__tests__/env.test.ts | 194 +++++++++++++++++- apps/whatsapp-platform/src/config/env.ts | 70 ++++++- 3 files changed, 260 insertions(+), 21 deletions(-) diff --git a/apps/whatsapp-platform/instrumentation.ts b/apps/whatsapp-platform/instrumentation.ts index adc95417..abe729f7 100644 --- a/apps/whatsapp-platform/instrumentation.ts +++ b/apps/whatsapp-platform/instrumentation.ts @@ -1,18 +1,19 @@ /** * Executado no arranque do runtime Node (não Edge). * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + * + * A decisão Production vs Preview usa VERCEL/VERCEL_ENV dentro de + * validateProductionServerEnv — não NODE_ENV sozinho. */ export async function register() { if (process.env.NEXT_RUNTIME === "edge") return; - if (process.env.NODE_ENV === "production" && process.env.SKIP_ENV_VALIDATION !== "1") { - const { validateProductionServerEnv } = await import("./src/config/env"); - try { - validateProductionServerEnv(); - } catch (e) { - console.error(e); - throw e; - } + const { validateProductionServerEnv } = await import("./src/config/env"); + try { + validateProductionServerEnv(); + } catch (e) { + console.error(e); + throw e; } } diff --git a/apps/whatsapp-platform/src/config/__tests__/env.test.ts b/apps/whatsapp-platform/src/config/__tests__/env.test.ts index c3ab855b..6462dad0 100644 --- a/apps/whatsapp-platform/src/config/__tests__/env.test.ts +++ b/apps/whatsapp-platform/src/config/__tests__/env.test.ts @@ -1,5 +1,100 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { validateProductionServerEnv } from "../env"; +import { + shouldValidateProductionServerEnv, + validateProductionServerEnv, +} from "../env"; + +const VALID_PROD = { + JWT_SECRET: "x".repeat(32), + WHATSAPP_DATABASE_URL: "postgresql://u:p@h:5432/db", + WHATSAPP_VERIFY_TOKEN: "token-long-enough", + NEXT_PUBLIC_WHATSAPP_APP_URL: "https://example.com", +} as const; + +describe("shouldValidateProductionServerEnv", () => { + it("exige validação em Vercel Production", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "production", + NODE_ENV: "production", + }) + ).toBe(true); + }); + + it("não exige validação em Vercel Preview mesmo com NODE_ENV=production", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "preview", + NODE_ENV: "production", + }) + ).toBe(false); + }); + + it("não exige validação em Vercel Development", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "development", + NODE_ENV: "production", + }) + ).toBe(false); + }); + + it("não exige validação com VERCEL_ENV ausente/inválido sob Vercel", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: undefined, + NODE_ENV: "production", + }) + ).toBe(false); + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "staging", + NODE_ENV: "production", + }) + ).toBe(false); + }); + + it("não exige validação com VERCEL_ENV=production sem VERCEL=1", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL_ENV: "production", + NODE_ENV: "production", + }) + ).toBe(false); + }); + + it("exige validação em local NODE_ENV=production sem sinais Vercel", () => { + expect( + shouldValidateProductionServerEnv({ + NODE_ENV: "production", + }) + ).toBe(true); + }); + + it("não exige validação em local development", () => { + expect( + shouldValidateProductionServerEnv({ + NODE_ENV: "development", + }) + ).toBe(false); + }); + + it("respeita SKIP_ENV_VALIDATION", () => { + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "production", + NODE_ENV: "production", + SKIP_ENV_VALIDATION: "1", + }) + ).toBe(false); + }); +}); describe("validateProductionServerEnv", () => { beforeEach(() => { @@ -10,17 +105,108 @@ describe("validateProductionServerEnv", () => { vi.unstubAllEnvs(); }); - it("não lança fora de produção", () => { + it("não lança fora de produção local", () => { + expect(() => + validateProductionServerEnv({ + NODE_ENV: "development", + }) + ).not.toThrow(); + }); + + it("lança em Vercel Production se JWT_SECRET curto", () => { + expect(() => + validateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "production", + NODE_ENV: "production", + JWT_SECRET: "short", + WHATSAPP_DATABASE_URL: VALID_PROD.WHATSAPP_DATABASE_URL, + WHATSAPP_VERIFY_TOKEN: VALID_PROD.WHATSAPP_VERIFY_TOKEN, + NEXT_PUBLIC_WHATSAPP_APP_URL: VALID_PROD.NEXT_PUBLIC_WHATSAPP_APP_URL, + }) + ).toThrow(/JWT_SECRET/); + }); + + it("lança em Vercel Production sem WHATSAPP_DATABASE_URL", () => { + expect(() => + validateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "production", + NODE_ENV: "production", + ...VALID_PROD, + WHATSAPP_DATABASE_URL: undefined, + }) + ).toThrow(/WHATSAPP_DATABASE_URL/); + }); + + it("aceita Vercel Production com configuração completa", () => { + expect(() => + validateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "production", + NODE_ENV: "production", + ...VALID_PROD, + }) + ).not.toThrow(); + }); + + it("permite Preview sem DB mesmo com NODE_ENV=production", () => { + expect(() => + validateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "preview", + NODE_ENV: "production", + JWT_SECRET: undefined, + WHATSAPP_DATABASE_URL: undefined, + WHATSAPP_VERIFY_TOKEN: undefined, + NEXT_PUBLIC_WHATSAPP_APP_URL: undefined, + }) + ).not.toThrow(); + }); + + it("Preview com STAGING_* presentes não exige DB Production (sem fallback implícito)", () => { + // STAGING_* are ignored by this validator — presence must not change the decision. + expect(() => + validateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "preview", + NODE_ENV: "production", + WHATSAPP_DATABASE_URL: undefined, + }) + ).not.toThrow(); + expect( + shouldValidateProductionServerEnv({ + VERCEL: "1", + VERCEL_ENV: "preview", + NODE_ENV: "production", + }) + ).toBe(false); + }); + + it("lança em local NODE_ENV=production sem DB", () => { + expect(() => + validateProductionServerEnv({ + NODE_ENV: "production", + JWT_SECRET: VALID_PROD.JWT_SECRET, + WHATSAPP_VERIFY_TOKEN: VALID_PROD.WHATSAPP_VERIFY_TOKEN, + NEXT_PUBLIC_WHATSAPP_APP_URL: VALID_PROD.NEXT_PUBLIC_WHATSAPP_APP_URL, + WHATSAPP_DATABASE_URL: undefined, + }) + ).toThrow(/WHATSAPP_DATABASE_URL/); + }); + + it("compatível com testes via process.env stub (comportamento legado)", () => { vi.stubEnv("NODE_ENV", "development"); expect(() => validateProductionServerEnv()).not.toThrow(); - }); - it("lança em produção se JWT_SECRET curto", () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("JWT_SECRET", "short"); vi.stubEnv("WHATSAPP_DATABASE_URL", "postgresql://u:p@h:5432/db"); vi.stubEnv("WHATSAPP_VERIFY_TOKEN", "token-long-enough"); vi.stubEnv("NEXT_PUBLIC_WHATSAPP_APP_URL", "https://example.com"); + // Clear any inherited Vercel marks + vi.stubEnv("VERCEL", ""); + vi.stubEnv("VERCEL_ENV", ""); expect(() => validateProductionServerEnv()).toThrow(/JWT_SECRET/); }); }); diff --git a/apps/whatsapp-platform/src/config/env.ts b/apps/whatsapp-platform/src/config/env.ts index d4260b4f..eafdf632 100644 --- a/apps/whatsapp-platform/src/config/env.ts +++ b/apps/whatsapp-platform/src/config/env.ts @@ -1,11 +1,21 @@ /** - * Validação explícita de variáveis críticas em produção. + * Validação explícita de variáveis críticas em Vercel Production (e local `next start`). * Evita arranque “mudo” com env incompleta (falha cedo com mensagem clara). * + * `NODE_ENV=production` NÃO implica Vercel Production — Preview/Development na Vercel + * também usam NODE_ENV=production. A validação rigorosa exige + * `VERCEL=1` + `VERCEL_ENV=production` (mesma regra que o Stripe guard), ou + * runtime local sem sinais Vercel com NODE_ENV=production. + * * Desligar temporariamente (emergência): SKIP_ENV_VALIDATION=1 */ import { z } from "zod"; +import { + isNonProductionHostedDeployment, + isVerifiedVercelProduction, + type StripeEnvSnapshot, +} from "@devflow/billing-core"; const productionCoreSchema = z.object({ NODE_ENV: z.literal("production"), @@ -26,23 +36,65 @@ const productionCoreSchema = z.object({ export type ProductionCoreEnv = z.infer; +export type WhatsappEnvValidationSnapshot = StripeEnvSnapshot & { + NODE_ENV?: string | undefined; + SKIP_ENV_VALIDATION?: string | undefined; +}; + /** - * Lança erro com mensagem agregada se a configuração de produção for inválida. - * Não chamar em testes unitários sem definir NODE_ENV adequadamente. + * Decide se a validação rigorosa de Production deve correr. + * Preview / Development / Vercel desconhecido: false (boot degradado sem DB Production). */ -export function validateProductionServerEnv(): void { - if (process.env.NODE_ENV !== "production") return; - if (process.env.SKIP_ENV_VALIDATION === "1") { - console.warn("[env] SKIP_ENV_VALIDATION=1 — validação de produção ignorada"); - return; +export function shouldValidateProductionServerEnv( + env: WhatsappEnvValidationSnapshot = { + VERCEL: process.env.VERCEL, + VERCEL_ENV: process.env.VERCEL_ENV, + NODE_ENV: process.env.NODE_ENV, + SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION, } +): boolean { + if (env.SKIP_ENV_VALIDATION === "1") return false; + if (isVerifiedVercelProduction(env)) return true; + // Preview, development, or any hosted non-exact-production Vercel signal + if (isNonProductionHostedDeployment(env)) return false; + // Off Vercel: preserve previous local `next start` / CI production checks + return env.NODE_ENV === "production"; +} - const raw = { +/** + * Lança erro com mensagem agregada se a configuração de Production for inválida. + * Não chamar em testes unitários sem definir env adequadamente. + */ +export function validateProductionServerEnv( + env: WhatsappEnvValidationSnapshot & { + JWT_SECRET?: string | undefined; + WHATSAPP_DATABASE_URL?: string | undefined; + WHATSAPP_VERIFY_TOKEN?: string | undefined; + NEXT_PUBLIC_WHATSAPP_APP_URL?: string | undefined; + } = { + VERCEL: process.env.VERCEL, + VERCEL_ENV: process.env.VERCEL_ENV, NODE_ENV: process.env.NODE_ENV, + SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION, JWT_SECRET: process.env.JWT_SECRET, WHATSAPP_DATABASE_URL: process.env.WHATSAPP_DATABASE_URL, WHATSAPP_VERIFY_TOKEN: process.env.WHATSAPP_VERIFY_TOKEN, NEXT_PUBLIC_WHATSAPP_APP_URL: process.env.NEXT_PUBLIC_WHATSAPP_APP_URL, + } +): void { + if (!shouldValidateProductionServerEnv(env)) { + if (env.SKIP_ENV_VALIDATION === "1") { + console.warn("[env] SKIP_ENV_VALIDATION=1 — validação de produção ignorada"); + } + return; + } + + const raw = { + NODE_ENV: "production" as const, + JWT_SECRET: env.JWT_SECRET, + WHATSAPP_DATABASE_URL: env.WHATSAPP_DATABASE_URL, + WHATSAPP_VERIFY_TOKEN: env.WHATSAPP_VERIFY_TOKEN, + NEXT_PUBLIC_WHATSAPP_APP_URL: env.NEXT_PUBLIC_WHATSAPP_APP_URL, }; const result = productionCoreSchema.safeParse(raw);