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
17 changes: 9 additions & 8 deletions apps/whatsapp-platform/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 4 additions & 0 deletions apps/whatsapp-platform/src/app/api/billing/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/whatsapp-platform/src/app/api/billing/portal/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions apps/whatsapp-platform/src/app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
7 changes: 7 additions & 0 deletions apps/whatsapp-platform/src/app/api/stripe/portal/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 12 additions & 0 deletions apps/whatsapp-platform/src/app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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 });
}
Expand Down
Loading
Loading