From d2ab6aaa8ee17ec7b2f8888a6bfabc3787cb7f31 Mon Sep 17 00:00:00 2001 From: Johan Lindengard Date: Fri, 8 May 2026 09:16:44 +0200 Subject: [PATCH] fix(infra): add explicit cron disable guard Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/operations/environments.md | 15 +++++- scripts/check-prod-env.test.ts | 38 ++++++++++++- scripts/check-prod-env.ts | 24 +++++++-- .../api/cron/booking-reminders/route.test.ts | 29 ++++++++++ src/app/api/cron/booking-reminders/route.ts | 7 +++ src/app/api/cron/data-retention/route.test.ts | 15 ++++++ src/app/api/cron/data-retention/route.ts | 7 +++ src/app/api/cron/send-reminders/route.test.ts | 53 +++++++++++++++++++ src/app/api/cron/send-reminders/route.ts | 7 +++ 9 files changed, 190 insertions(+), 5 deletions(-) diff --git a/docs/operations/environments.md b/docs/operations/environments.md index ba8b5830..fbbdc17d 100644 --- a/docs/operations/environments.md +++ b/docs/operations/environments.md @@ -4,7 +4,7 @@ description: "Konfiguration och skillnader mellan lokal utveckling, staging och category: operations tags: [environments, vercel, supabase, ios, config] status: active -last_updated: 2026-05-06 +last_updated: 2026-05-08 related: - deployment.md - staging-environment-setup.md @@ -163,8 +163,21 @@ Autentiseras med `CRON_SECRET` (Bearer token). | `UPSTASH_REDIS_REST_URL` | (tom = in-memory) | Prod Redis | Prod Redis | Rate limiting | | `RESEND_API_KEY` | (tom = konsol-logg) | -- | Resend API-nyckel | E-post | | `CRON_SECRET` | Valfri | -- | Stark slumpad | Cron-autentisering | +| `DISABLE_CRONS` | -- | `true` (i isolerat staging-projekt) | -- (FAR ALDRIG vara satt) | Skip-flagga for cron-jobb. Pre-build-guard avvisar prod-deploy om DISABLE_CRONS=true. | | `SUBSCRIPTION_PROVIDER` | `mock` | `mock` | `stripe` | Betalning | +### DISABLE_CRONS — anvandning + +`DISABLE_CRONS=true` far cron-routes (`/api/cron/*`) att returnera `200 { skipped: true, reason: "DISABLE_CRONS" }` istallet for att exekvera. Anvands i isolerade staging/preview-projekt dar Vercel klassar staging-branch som production (vilket annars triggar duplicerade cron-jobb mot staging-DB). + +**Belt-and-suspenders i staging-projekt:** +1. `DISABLE_CRONS=true` (explicit guard, syns i kod) +2. `CRON_SECRET` satts INTE (om guarden av nagon anledning hoppas over → `verifyCronAuth` returnerar 401, ingen exekvering) + +**Skydd mot misstag:** `scripts/check-prod-env.ts` har en `checkCronsEnabled`-funktion som kor i pre-build pa Vercel Production. Om `DISABLE_CRONS=true` upptäcks pa production faller bygget med tydligt felmeddelande. + +**Sla av guarden i staging:** ta bort env-flaggan (eller satt `false`) → cron-routes kor som vanligt vid nasta deploy. Reversibelt utan kodandring. + --- ## Deploy-ordning vid schemaandring diff --git a/scripts/check-prod-env.test.ts b/scripts/check-prod-env.test.ts index 27048fb9..157f4069 100644 --- a/scripts/check-prod-env.test.ts +++ b/scripts/check-prod-env.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { checkProdEnv, REQUIRED_PROD_VARS } from './check-prod-env' +import { checkProdEnv, checkCronsEnabled, REQUIRED_PROD_VARS } from './check-prod-env' const allVarsPresent = Object.fromEntries(REQUIRED_PROD_VARS.map(v => [v, 'value'])) @@ -35,6 +35,42 @@ describe('checkProdEnv', () => { }) it('exports the full list of required vars', () => { + expect(REQUIRED_PROD_VARS).toBeDefined() + }) +}) + +describe('checkCronsEnabled', () => { + it('returns ok when DISABLE_CRONS is unset', () => { + const result = checkCronsEnabled({}) + expect(result.ok).toBe(true) + expect(result.reason).toBeUndefined() + }) + + it('returns ok when DISABLE_CRONS is empty', () => { + const result = checkCronsEnabled({ DISABLE_CRONS: '' }) + expect(result.ok).toBe(true) + }) + + it('returns ok when DISABLE_CRONS is "false"', () => { + const result = checkCronsEnabled({ DISABLE_CRONS: 'false' }) + expect(result.ok).toBe(true) + }) + + it('returns FAIL when DISABLE_CRONS is "true"', () => { + const result = checkCronsEnabled({ DISABLE_CRONS: 'true' }) + expect(result.ok).toBe(false) + expect(result.reason).toMatch(/DISABLE_CRONS=true/) + expect(result.reason).toMatch(/skipped/) + }) + + it('treats non-"true" string as enabled (truthy strings other than "true" do not skip)', () => { + const result = checkCronsEnabled({ DISABLE_CRONS: '1' }) + expect(result.ok).toBe(true) + }) +}) + +describe('REQUIRED_PROD_VARS', () => { + it('is the canonical list of required vars', () => { const expectedVars = [ 'APP_URL', 'DATABASE_URL', diff --git a/scripts/check-prod-env.ts b/scripts/check-prod-env.ts index 26595c60..14920da9 100644 --- a/scripts/check-prod-env.ts +++ b/scripts/check-prod-env.ts @@ -17,9 +17,20 @@ export function checkProdEnv(env: Record): { missing return { missing } } +export function checkCronsEnabled(env: Record): { ok: boolean; reason?: string } { + if (env.DISABLE_CRONS === 'true') { + return { + ok: false, + reason: 'DISABLE_CRONS=true is set on production. Cron jobs would be silently skipped (no reminders sent, no data retention).', + } + } + return { ok: true } +} + // CLI entry point — only runs when VERCEL_ENV=production if (process.env.VERCEL_ENV === 'production') { - const { missing } = checkProdEnv(process.env as Record) + const env = process.env as Record + const { missing } = checkProdEnv(env) if (missing.length > 0) { console.error(`\n[check-prod-env] FAIL: Missing required environment variables:`) for (const v of missing) { @@ -27,7 +38,14 @@ if (process.env.VERCEL_ENV === 'production') { } console.error(`\nFix: Set these variables in Vercel Project Settings > Environment Variables (Production).\n`) process.exit(1) - } else { - console.log('[check-prod-env] OK: All required production environment variables are set.') } + + const cronCheck = checkCronsEnabled(env) + if (!cronCheck.ok) { + console.error(`\n[check-prod-env] FAIL: ${cronCheck.reason}`) + console.error(`\nFix: Remove DISABLE_CRONS or set it to 'false' in production. DISABLE_CRONS is meant for staging projects only.\n`) + process.exit(1) + } + + console.log('[check-prod-env] OK: All required production environment variables are set and crons are enabled.') } diff --git a/src/app/api/cron/booking-reminders/route.test.ts b/src/app/api/cron/booking-reminders/route.test.ts index 716d0293..d6eb6f45 100644 --- a/src/app/api/cron/booking-reminders/route.test.ts +++ b/src/app/api/cron/booking-reminders/route.test.ts @@ -17,7 +17,10 @@ import { GET } from "./route" describe("GET /api/cron/booking-reminders", () => { beforeEach(() => { vi.clearAllMocks() + vi.unstubAllEnvs() process.env.CRON_SECRET = "test-secret" + delete process.env.DISABLE_CRONS + mockProcessAll.mockResolvedValue(3) }) it("returns 200 and processes reminders with valid CRON_SECRET", async () => { @@ -52,6 +55,32 @@ describe("GET /api/cron/booking-reminders", () => { expect(response.status).toBe(401) }) + it("skips with 200 when DISABLE_CRONS=true (skip wins before auth)", async () => { + vi.stubEnv("DISABLE_CRONS", "true") + + const request = new Request("http://localhost/api/cron/booking-reminders", { + headers: { authorization: "Bearer test-secret" }, + }) + + const response = await GET(request as never) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ skipped: true, reason: "DISABLE_CRONS" }) + expect(mockProcessAll).not.toHaveBeenCalled() + }) + + it("skips with 200 when DISABLE_CRONS=true even without valid auth", async () => { + vi.stubEnv("DISABLE_CRONS", "true") + + const request = new Request("http://localhost/api/cron/booking-reminders") + + const response = await GET(request as never) + + expect(response.status).toBe(200) + expect(mockProcessAll).not.toHaveBeenCalled() + }) + it("returns 500 on database error", async () => { mockProcessAll.mockRejectedValueOnce(new Error("DB down")) diff --git a/src/app/api/cron/booking-reminders/route.ts b/src/app/api/cron/booking-reminders/route.ts index 4db940a0..8af58d8f 100644 --- a/src/app/api/cron/booking-reminders/route.ts +++ b/src/app/api/cron/booking-reminders/route.ts @@ -10,6 +10,13 @@ import { verifyCronAuth } from "@/lib/cron-auth" * Protected by CRON_SECRET (Bearer + HMAC signature). */ export async function GET(request: NextRequest) { + if (process.env.DISABLE_CRONS === "true") { + return NextResponse.json( + { skipped: true, reason: "DISABLE_CRONS" }, + { status: 200 } + ) + } + const auth = verifyCronAuth( request.headers.get("authorization"), process.env.CRON_SECRET, diff --git a/src/app/api/cron/data-retention/route.test.ts b/src/app/api/cron/data-retention/route.test.ts index 2c6349ca..3ff3cbc1 100644 --- a/src/app/api/cron/data-retention/route.test.ts +++ b/src/app/api/cron/data-retention/route.test.ts @@ -48,6 +48,8 @@ function makeRequest(): NextRequest { describe("GET /api/cron/data-retention", () => { beforeEach(() => { vi.clearAllMocks() + vi.unstubAllEnvs() + delete process.env.DISABLE_CRONS mockVerifyCronAuth.mockReturnValue({ ok: true }) mockIsFeatureEnabled.mockResolvedValue(true) mockProcessRetention.mockResolvedValue({ @@ -100,4 +102,17 @@ describe("GET /api/cron/data-retention", () => { expect(res.status).toBe(500) }) + + it("skips with 200 when DISABLE_CRONS=true (skip wins before auth and feature flag)", async () => { + vi.stubEnv("DISABLE_CRONS", "true") + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ skipped: true, reason: "DISABLE_CRONS" }) + expect(mockVerifyCronAuth).not.toHaveBeenCalled() + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + expect(mockProcessRetention).not.toHaveBeenCalled() + }) }) diff --git a/src/app/api/cron/data-retention/route.ts b/src/app/api/cron/data-retention/route.ts index 1b937389..08a35ba6 100644 --- a/src/app/api/cron/data-retention/route.ts +++ b/src/app/api/cron/data-retention/route.ts @@ -17,6 +17,13 @@ import { logger } from "@/lib/logger" * 3. Notified 30+ days ago -> delete/anonymize account */ export async function GET(request: NextRequest) { + if (process.env.DISABLE_CRONS === "true") { + return NextResponse.json( + { skipped: true, reason: "DISABLE_CRONS" }, + { status: 200 } + ) + } + const auth = verifyCronAuth( request.headers.get("authorization"), process.env.CRON_SECRET, diff --git a/src/app/api/cron/send-reminders/route.test.ts b/src/app/api/cron/send-reminders/route.test.ts index 7933f329..5973c65f 100644 --- a/src/app/api/cron/send-reminders/route.test.ts +++ b/src/app/api/cron/send-reminders/route.test.ts @@ -15,6 +15,7 @@ describe("GET /api/cron/send-reminders", () => { beforeEach(() => { vi.clearAllMocks() + vi.unstubAllEnvs() vi.stubEnv("CRON_SECRET", CRON_SECRET) }) @@ -65,6 +66,58 @@ describe("GET /api/cron/send-reminders", () => { expect(mockProcessAll).not.toHaveBeenCalled() }) + it("skips with 200 when DISABLE_CRONS=true (skip wins before auth)", async () => { + vi.stubEnv("DISABLE_CRONS", "true") + + const request = new NextRequest( + "http://localhost:3000/api/cron/send-reminders", + { + headers: { + authorization: `Bearer ${CRON_SECRET}`, + }, + } + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ skipped: true, reason: "DISABLE_CRONS" }) + expect(mockProcessAll).not.toHaveBeenCalled() + }) + + it("skips with 200 when DISABLE_CRONS=true even without valid auth (skip is first guard)", async () => { + vi.stubEnv("DISABLE_CRONS", "true") + + const request = new NextRequest( + "http://localhost:3000/api/cron/send-reminders" + ) + + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockProcessAll).not.toHaveBeenCalled() + }) + + it("processes normally when DISABLE_CRONS is empty string", async () => { + vi.stubEnv("DISABLE_CRONS", "") + mockProcessAll.mockResolvedValue(0) + + const request = new NextRequest( + "http://localhost:3000/api/cron/send-reminders", + { + headers: { + authorization: `Bearer ${CRON_SECRET}`, + }, + } + ) + + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockProcessAll).toHaveBeenCalledTimes(1) + }) + it("should return 500 when processing fails", async () => { mockProcessAll.mockRejectedValue(new Error("Database error")) diff --git a/src/app/api/cron/send-reminders/route.ts b/src/app/api/cron/send-reminders/route.ts index 8f01e1a1..13faee17 100644 --- a/src/app/api/cron/send-reminders/route.ts +++ b/src/app/api/cron/send-reminders/route.ts @@ -10,6 +10,13 @@ import { verifyCronAuth } from "@/lib/cron-auth" * Protected by CRON_SECRET (Bearer + HMAC signature). */ export async function GET(request: NextRequest) { + if (process.env.DISABLE_CRONS === "true") { + return NextResponse.json( + { skipped: true, reason: "DISABLE_CRONS" }, + { status: 200 } + ) + } + const auth = verifyCronAuth( request.headers.get("authorization"), process.env.CRON_SECRET,