diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab4c586..fba36de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,8 +160,9 @@ jobs: id: cf env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | - if [ -n "$CLOUDFLARE_API_TOKEN" ]; then + if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" diff --git a/app/sentry-test/client-test.tsx b/app/admin/sentry-test/client-test.tsx similarity index 86% rename from app/sentry-test/client-test.tsx rename to app/admin/sentry-test/client-test.tsx index a1ff82c..a1db85d 100644 --- a/app/sentry-test/client-test.tsx +++ b/app/admin/sentry-test/client-test.tsx @@ -29,13 +29,9 @@ export function SentryClientTest() { {eventId ? "Captured" : "Waiting"} -

- Use this only in development and preview. Production returns not found. -

+

Use this only in development and preview.

{eventId ? ( -

- {eventId} -

+

{eventId}

) : null} + + + + + + ); +} diff --git a/app/admin/sentry-test/server/route.ts b/app/admin/sentry-test/server/route.ts new file mode 100644 index 0000000..5143b49 --- /dev/null +++ b/app/admin/sentry-test/server/route.ts @@ -0,0 +1,30 @@ +import * as Sentry from "@sentry/nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { ADMIN_COOKIE_NAME, verifyAdminSession } from "@/lib/auth/admin-session"; +import { appEnvironment } from "@/lib/env"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const cookieStore = await cookies(); + const session = cookieStore.get(ADMIN_COOKIE_NAME)?.value; + if ( + appEnvironment === "production" || + !(await verifyAdminSession(session, Date.now())) + ) { + return NextResponse.json({ message: "Not found." }, { status: 404 }); + } + + const eventId = Sentry.captureException(new Error("Stoop server Sentry test event")); + + return NextResponse.json( + { eventId, message: "Sentry server test event captured." }, + { + headers: { + "Cache-Control": "no-store" + } + } + ); +} diff --git a/app/api/sentry-test/route.ts b/app/api/sentry-test/route.ts index 38787a4..59d8406 100644 --- a/app/api/sentry-test/route.ts +++ b/app/api/sentry-test/route.ts @@ -1,28 +1,7 @@ -import * as Sentry from "@sentry/nextjs"; import { NextResponse } from "next/server"; -import { appEnvironment } from "@/lib/env"; - -export const dynamic = "force-dynamic"; - +// Legacy public diagnostic URL. The functional Sentry test is now under /admin/sentry-test so an +// unauthenticated request can never create monitoring events in preview deployments. export function GET() { - if (appEnvironment === "production") { - return NextResponse.json( - { message: "Sentry test events are disabled in production." }, - { status: 404 } - ); - } - - const eventId = Sentry.captureException( - new Error("Stoop server Sentry test event") - ); - - return NextResponse.json( - { eventId, message: "Sentry server test event captured." }, - { - headers: { - "Cache-Control": "no-store" - } - } - ); + return NextResponse.json({ message: "Not found." }, { status: 404 }); } diff --git a/app/dashboard/qr/sharing-summary.tsx b/app/dashboard/qr/sharing-summary.tsx index 981d0fb..c436d9e 100644 --- a/app/dashboard/qr/sharing-summary.tsx +++ b/app/dashboard/qr/sharing-summary.tsx @@ -17,7 +17,8 @@ const CHANNEL_LABELS: Record = { direct: "Direct", instagram: "Instagram bio", whatsapp: "WhatsApp", - poster: "Printed poster" + poster: "Printed poster", + qr: "QR code" }; function channelLabel(src: string): string { diff --git a/app/sentry-test/page.tsx b/app/sentry-test/page.tsx index 1be6961..f1a47c7 100644 --- a/app/sentry-test/page.tsx +++ b/app/sentry-test/page.tsx @@ -1,50 +1,6 @@ -import { ExternalLink } from "lucide-react"; -import Link from "next/link"; import { notFound } from "next/navigation"; -import { Button } from "@/app/components/ui/button"; -import { Card } from "@/app/components/ui/card"; -import { Stamp } from "@/app/components/ui/stamp"; -import { appEnvironment } from "@/lib/env"; - -import { SentryClientTest } from "./client-test"; - -export const dynamic = "force-dynamic"; - -export default function SentryTestPage() { - if (appEnvironment === "production") { - notFound(); - } - - return ( -
-
-
- Preview only -

Sentry test counter

-

- Send one server event and one browser event before closing Phase 1. -

-
-
- -
-

Server event

-

Send a route test

-
-

- The route captures a server exception and returns the event id. -

- -
- -
-
-
- ); +// Keep the legacy public URL non-enumerable after moving the diagnostic behind founder access. +export default function LegacySentryTestPage() { + notFound(); } diff --git a/image-processor/container/process-image.mjs b/image-processor/container/process-image.mjs index 47ee0a9..e67fb54 100644 --- a/image-processor/container/process-image.mjs +++ b/image-processor/container/process-image.mjs @@ -7,6 +7,7 @@ import sharp from "sharp"; export const MAX_EDGE = 2048; export const MAX_BYTES = 4 * 1024 * 1024; +export const MAX_INPUT_PIXELS = MAX_EDGE * MAX_EDGE; export const ALLOWED_FORMATS = new Set(["jpeg", "png", "webp"]); export const GENERIC_REASON = "That image didn't work — try a JPG or PNG under 4 MB."; export const ANIMATED_REASON = "Animated images aren't supported yet — try a still photo."; @@ -22,13 +23,23 @@ export async function processImage(buf) { } try { - const image = sharp(buf, { failOn: "error" }); + // Limit pixels before Sharp decodes raster data so a small compressed image cannot force a + // disproportionate allocation. The explicit edge check below keeps the product's 2048px rule. + const image = sharp(buf, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }); const meta = await image.metadata(); // Reject svg (vector / script-bearing), unknown formats, and animated frames. if (!meta.format || !ALLOWED_FORMATS.has(meta.format)) { return { ok: false, reason: GENERIC_REASON }; } + if ( + !meta.width || + !meta.height || + meta.width > MAX_EDGE || + meta.height > MAX_EDGE + ) { + return { ok: false, reason: GENERIC_REASON }; + } if ((meta.pages ?? 1) > 1) { return { ok: false, reason: ANIMATED_REASON }; } diff --git a/image-processor/container/process-image.test.mjs b/image-processor/container/process-image.test.mjs index be1a7a1..90307fe 100644 --- a/image-processor/container/process-image.test.mjs +++ b/image-processor/container/process-image.test.mjs @@ -46,16 +46,14 @@ describe("processImage", () => { expect(result.data.subarray(8, 12).toString("ascii")).toBe("WEBP"); }); - it("clamps an oversized image to the max edge", async () => { + it("rejects an image with a source edge over the max", async () => { const huge = await sharp({ create: { width: 3000, height: 1200, channels: 3, background: { r: 10, g: 10, b: 10 } } }) .png() .toBuffer(); const result = await processImage(huge); - expect(result.ok).toBe(true); - expect(result.width).toBe(2048); - expect(result.height).toBeLessThanOrEqual(2048); + expect(result).toEqual({ ok: false, reason: GENERIC_REASON }); }); it("rejects an SVG (vector / script-bearing)", async () => { diff --git a/lib/actions/refunds.ts b/lib/actions/refunds.ts index 0cb5842..8ebdbc9 100644 --- a/lib/actions/refunds.ts +++ b/lib/actions/refunds.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { writeAuditLog } from "@/lib/audit/log"; import { getStripe } from "@/lib/stripe/client"; +import { createSupabaseSecretClient } from "@/lib/supabase/secret"; import { createSupabaseServerClient } from "@/lib/supabase/server"; // Phase 5.8: refund an order. v1 is full refunds only — stripe.refunds.create with no amount @@ -34,7 +35,9 @@ export async function refundOrder(orderId: string): Promise { // ownership check. Explicit columns, never select * against orders (hard invariant 6). const { data: order } = await supabase .from("orders") - .select("id, payment_mode, payment_status, stripe_payment_intent_id, idempotency_key") + .select( + "id, total_cents, currency, payment_mode, payment_status, stripe_payment_intent_id, idempotency_key" + ) .eq("id", parsed.data) .maybeSingle(); if (!order) { @@ -53,11 +56,25 @@ export async function refundOrder(orderId: string): Promise { } try { + const stripe = getStripe(); + const paymentIntent = await stripe.paymentIntents.retrieve( + order.stripe_payment_intent_id + ); + // Payment metadata is written only when Stoop creates Checkout. Verifying it here prevents a + // stale or previously tampered database value from targeting another Stripe PaymentIntent. + if ( + paymentIntent.metadata.order_id !== order.id || + paymentIntent.amount !== order.total_cents || + paymentIntent.currency !== order.currency.toLowerCase() + ) { + return { ok: false, error: "Only a paid online order can be refunded." }; + } + // Destination charge: the funds settled on the seller's connected account and Stoop took an // application fee. A full refund must pull the transfer back from the connected account // (reverse_transfer) and return our fee (refund_application_fee) — otherwise the platform // balance funds the refund and the seller keeps both the sale and the fee. - await getStripe().refunds.create( + await stripe.refunds.create( { payment_intent: order.stripe_payment_intent_id, reverse_transfer: true, @@ -73,7 +90,8 @@ export async function refundOrder(orderId: string): Promise { // timeline, and the refund can still fail. Move to the in-between refund_pending state now; the // charge.refunded webhook confirms it to 'refunded', a refund-failure event flips it to // 'refund_failed'. The guarded paid→refund_pending update also serializes a double-click. - const { data: updated, error: updateError } = await supabase + const secret = createSupabaseSecretClient(); + const { data: updated, error: updateError } = await secret .from("orders") .update({ payment_status: "refund_pending" }) .eq("id", order.id) diff --git a/lib/auth/admin-session.ts b/lib/auth/admin-session.ts index 2cf62de..b04d216 100644 --- a/lib/auth/admin-session.ts +++ b/lib/auth/admin-session.ts @@ -14,23 +14,32 @@ import { export const ADMIN_COOKIE_NAME = "admin_session"; // 12 hours: long enough for a founder working session, short enough that a leaked cookie expires. export const ADMIN_COOKIE_TTL_SECONDS = 12 * 60 * 60; +const ADMIN_SESSION_AUDIENCE = "stoop-admin-session"; interface AdminSessionPayload { + audience: typeof ADMIN_SESSION_AUDIENCE; issuedAt: number; } /** Returns the signed cookie value proving the holder cleared the /admin login gate. */ export async function signAdminSession(issuedAt: number): Promise { - return signCookiePayload({ issuedAt } satisfies AdminSessionPayload); + return signCookiePayload({ + audience: ADMIN_SESSION_AUDIENCE, + issuedAt + } satisfies AdminSessionPayload); } /** True only when the cookie was signed by us and is still within its TTL. */ export async function verifyAdminSession( raw: string | undefined, - now: number + now = Date.now() ): Promise { const parsed = (await readCookiePayload(raw)) as Partial | null; - if (!parsed || typeof parsed.issuedAt !== "number") { + if ( + !parsed || + parsed.audience !== ADMIN_SESSION_AUDIENCE || + typeof parsed.issuedAt !== "number" + ) { return false; } return now - parsed.issuedAt <= ADMIN_COOKIE_TTL_SECONDS * 1000; diff --git a/lib/ratelimit/anon-guard.ts b/lib/ratelimit/anon-guard.ts index 99dae49..458e055 100644 --- a/lib/ratelimit/anon-guard.ts +++ b/lib/ratelimit/anon-guard.ts @@ -7,12 +7,12 @@ import { clientIp } from "@/lib/security/request-ip"; import { verifyTurnstile } from "@/lib/security/turnstile"; // Phase 9.3: the soft abuse-control gate shared by the anon order + subscribe server actions. Both -// resolve the edge IP, reserve a per-(ip, store) + per-store KV window pair as one decision, then -// run the Turnstile challenge. The local hard cap goes first so a scripted flood can't force -// unlimited third-party siteverify calls with random tokens. The orchestration is identical; only -// the keys/limits and the caller-facing copy differ, so callers pass a window builder and map the -// reason to their own message. KV-null (plain `next dev` / tests) fails open — the same soft-control -// contract as the rest of the limiter. +// resolve the edge IP, verify the Turnstile challenge, then reserve a per-(ip, store) + per-store +// KV window pair as one decision. Failed challenges must not consume a store's finite public-write +// capacity; otherwise an attacker could deny service without solving Turnstile. The orchestration is +// identical; only the keys/limits and the caller-facing copy differ, so callers pass a window builder +// and map the reason to their own message. KV-null (plain `next dev` / tests) fails open — the same +// soft-control contract as the rest of the limiter. export type AnonGuardResult = | { ok: true } @@ -23,6 +23,10 @@ export async function guardAnonWrite( buildWindows: (ip: string, now: number) => RateLimitReservation[] ): Promise { const ip = await clientIp(); + if (!(await verifyTurnstile(turnstileToken, ip))) { + return { ok: false, reason: "turnstile" }; + } + const kv = getRateLimitKv(); if (kv) { const now = Date.now(); @@ -32,9 +36,5 @@ export async function guardAnonWrite( } } - if (!(await verifyTurnstile(turnstileToken, ip))) { - return { ok: false, reason: "turnstile" }; - } - return { ok: true }; } diff --git a/lib/schemas/scan.ts b/lib/schemas/scan.ts index cc09b38..b97c1c8 100644 --- a/lib/schemas/scan.ts +++ b/lib/schemas/scan.ts @@ -7,11 +7,20 @@ import { uuid } from "./common"; // and anything malformed or missing degrades to "direct" rather than rejecting the beacon. export const SCAN_SRC_FALLBACK = "direct"; +// Attribution is a fixed product taxonomy, not user-controlled analytics text. Keeping this set +// bounded guarantees public scan requests cannot create unbounded aggregate-row cardinality. +export const SCAN_SOURCES = [ + "direct", + "instagram", + "whatsapp", + "poster", + "qr" +] as const; const srcSchema = z .preprocess( (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), - z.string().regex(/^[a-z0-9_-]{1,20}$/) + z.enum(SCAN_SOURCES) ) .catch(SCAN_SRC_FALLBACK); diff --git a/lib/utils/csv.ts b/lib/utils/csv.ts index 86dbf7f..af94bdd 100644 --- a/lib/utils/csv.ts +++ b/lib/utils/csv.ts @@ -8,11 +8,18 @@ export type CsvColumn = { value: (row: Row) => string; }; +function neutralizeFormula(value: string): string { + // Spreadsheet apps can ignore leading whitespace before interpreting a formula. Prefix an + // apostrophe before RFC-4180 escaping so exported subscriber data always opens as text. + return /^[\t\r ]*[=+\-@]/.test(value) ? `'${value}` : value; +} + function escapeField(value: string): string { - if (/[",\r\n]/.test(value)) { - return `"${value.replace(/"/g, '""')}"`; + const safeValue = neutralizeFormula(value); + if (/[",\r\n]/.test(safeValue)) { + return `"${safeValue.replace(/"/g, '""')}"`; } - return value; + return safeValue; } export function toCsv(rows: Row[], columns: CsvColumn[]): string { diff --git a/supabase/migrations/0042_security_scan_hardening.sql b/supabase/migrations/0042_security_scan_hardening.sql new file mode 100644 index 0000000..4a44a0b --- /dev/null +++ b/supabase/migrations/0042_security_scan_hardening.sql @@ -0,0 +1,29 @@ +-- Security-scan follow-up. These changes are intentionally forward-only: existing migrations are +-- immutable once applied, while this migration removes direct-write paths that bypass server checks. + +-- Seller-authenticated clients may edit only the two seller-authored notes. Payment, Checkout, and +-- lifecycle state are changed by narrowly scoped server-side RPCs or verified Stripe webhooks. +revoke update on public.orders from authenticated; +grant update (notes_seller, notes_shared) on public.orders to authenticated; + +-- Building memberships are derived from a store's normalized address and visibility by the +-- service-role grouping RPC. Letting a seller PATCH their own row lets them forge another building. +revoke update on public.building_memberships from authenticated; +drop policy if exists building_memberships_owner_update on public.building_memberships; + +-- Public subscriber inserts must remain email-shaped even when callers bypass the Next.js Zod +-- schema. The formula-prefix guard also protects future exports from spreadsheet formula injection. +alter table public.subscribers + add constraint subscribers_email_safe_format_check + check ( + email = btrim(email) + and char_length(email) between 3 and 254 + and email !~ '^[=+@-]' + and email ~* '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$' + ) not valid; + +-- Scan attribution is a fixed taxonomy. Enforcing the same bound in Postgres prevents future +-- service code from reintroducing unbounded public aggregate-row cardinality. +alter table public.scan_event_daily + add constraint scan_event_daily_src_allowed_check + check (src in ('direct', 'instagram', 'whatsapp', 'poster', 'qr')) not valid; diff --git a/supabase/migrations/20260709212011_cost_snapshot_service_role_policy.sql b/supabase/migrations/20260709212011_cost_snapshot_service_role_policy.sql new file mode 100644 index 0000000..e6f1f0f --- /dev/null +++ b/supabase/migrations/20260709212011_cost_snapshot_service_role_policy.sql @@ -0,0 +1,6 @@ +-- The cost snapshot is an internal service-role-only table. Its grants already revoke every +-- browser role; this explicit policy documents that model and satisfies the RLS policy advisor. +create policy cost_snapshot_service_role_all on public.cost_snapshot + for all to service_role + using (true) + with check (true); diff --git a/tests/integration/subscriber-drops.test.ts b/tests/integration/subscriber-drops.test.ts index edfcb2a..e584d9b 100644 --- a/tests/integration/subscriber-drops.test.ts +++ b/tests/integration/subscriber-drops.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { + anonClient, authedClient, cleanupUser, seedSeller, @@ -24,6 +25,7 @@ vi.mock("server-only", () => ({})); // Requires migration 0032 applied to the target project. const service = serviceClient(); +const anon = anonClient(); let sellerA: SeededSeller; let sellerB: SeededSeller; @@ -104,6 +106,20 @@ describe("subscribers_owner_delete RLS", () => { }); }); +describe("public subscriber input", () => { + it("rejects a formula-shaped email when callers bypass the app schema", async () => { + const { error } = await anon.from("subscribers").insert({ + store_id: sellerA.storeId, + email: "=formula@example.test", + consent_email: true, + unsubscribe_token: generateToken(), + verified_at: new Date().toISOString() + }); + + expect(error).not.toBeNull(); + }); +}); + describe("loadActiveRecipients", () => { it("returns only verified, not-unsubscribed subscribers", async () => { const fresh = await seedSeller(service, { slug: `subR-${Date.now()}` }); diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index 5796ca9..c7ce4e2 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -20,6 +20,8 @@ let deleteAttempt: SeededSeller; let deleteClient: Db; let buildingId: string; let membershipId: string; +let ownMembershipId: string; +let ownMembershipBuildingId: string; let stripeEventId: string; let connectedAccountId: string; let auditLogId: string; @@ -61,6 +63,28 @@ beforeAll(async () => { .single(); membershipId = (membership as { id: string }).id; + const { data: ownMembershipBuilding } = await service + .from("buildings") + .insert({ + normalized_key: `owner-key-${Date.now()}`, + display_name: "Owner building", + public_slug: `owner-${Date.now()}` + }) + .select("id") + .single(); + ownMembershipBuildingId = (ownMembershipBuilding as { id: string }).id; + + const { data: ownMembership } = await service + .from("building_memberships") + .insert({ + building_id: ownMembershipBuildingId, + store_id: sellerA.storeId, + status: "active" + }) + .select("id") + .single(); + ownMembershipId = (ownMembership as { id: string }).id; + const { data: stripeEvent } = await service .from("stripe_events") .insert({ @@ -131,6 +155,7 @@ afterAll(async () => { await cleanupUser(service, sellerB.userId); await cleanupUser(service, inactive.userId); await cleanupUser(service, deleteAttempt.userId); + await service.from("buildings").delete().eq("id", ownMembershipBuildingId); await service.from("buildings").delete().eq("id", buildingId); }); @@ -281,6 +306,29 @@ describe("anon (public storefront)", () => { const { data } = await service.from("orders").select("notes").eq("id", sellerA.orderId).single(); expect((data as { notes: string | null }).notes).toBeNull(); }); + + it("cannot directly change payment state on an owned order", async () => { + const { data: before } = await service + .from("orders") + .select("payment_status, stripe_payment_intent_id") + .eq("id", sellerA.orderId) + .single(); + + await clientA + .from("orders") + .update({ + payment_status: "paid", + stripe_payment_intent_id: "pi_tampered_by_seller" + }) + .eq("id", sellerA.orderId); + + const { data: after } = await service + .from("orders") + .select("payment_status, stripe_payment_intent_id") + .eq("id", sellerA.orderId) + .single(); + expect(after).toEqual(before); + }); }); describe("sensitive seller operations stay behind server actions", () => { @@ -294,6 +342,26 @@ describe("sensitive seller operations stay behind server actions", () => { .single(); expect(data?.id).toBe(deleteAttempt.storeId); }); + + it("cannot rewrite an owned building membership", async () => { + const { data: before } = await service + .from("building_memberships") + .select("building_id, status") + .eq("id", ownMembershipId) + .single(); + + await clientA + .from("building_memberships") + .update({ building_id: buildingId, status: "active" }) + .eq("id", ownMembershipId); + + const { data: after } = await service + .from("building_memberships") + .select("building_id, status") + .eq("id", ownMembershipId) + .single(); + expect(after).toEqual(before); + }); }); describe("order tracking token (capability read)", () => { diff --git a/tests/unit/admin-session.test.ts b/tests/unit/admin-session.test.ts new file mode 100644 index 0000000..c9a4c63 --- /dev/null +++ b/tests/unit/admin-session.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { signAdminSession, verifyAdminSession } from "@/lib/auth/admin-session"; +import { signCookiePayload } from "@/lib/utils/signed-cookie"; + +beforeEach(() => { + process.env.SIGNUP_COOKIE_SECRET = "test-secret-please-rotate-in-prod"; +}); + +describe("admin session cookies", () => { + it("accepts only the admin-audience cookie shape", async () => { + const now = Date.now(); + const session = await signAdminSession(now); + + await expect(verifyAdminSession(session, now)).resolves.toBe(true); + }); + + it("rejects a valid signature from another cookie purpose", async () => { + const signupShapedCookie = await signCookiePayload({ issuedAt: Date.now() }); + + await expect(verifyAdminSession(signupShapedCookie, Date.now())).resolves.toBe( + false + ); + }); +}); diff --git a/tests/unit/anon-guard.test.ts b/tests/unit/anon-guard.test.ts index 8d4daba..48d2985 100644 --- a/tests/unit/anon-guard.test.ts +++ b/tests/unit/anon-guard.test.ts @@ -47,7 +47,7 @@ describe("guardAnonWrite", () => { mocks.verifyTurnstile.mockResolvedValue(true); }); - it("sheds over-limit traffic before calling Turnstile siteverify", async () => { + it("checks Turnstile before applying the rate limit", async () => { expect(await guardAnonWrite("tok", oneShotWindow)).toEqual({ ok: true }); expect(mocks.verifyTurnstile).toHaveBeenCalledTimes(1); @@ -56,15 +56,20 @@ describe("guardAnonWrite", () => { ok: false, reason: "rate_limit" }); - expect(mocks.verifyTurnstile).not.toHaveBeenCalled(); + expect(mocks.verifyTurnstile).toHaveBeenCalledTimes(1); }); - it("still reports a Turnstile failure when the request is under the hard cap", async () => { + it("does not reserve public-write capacity for a failed Turnstile challenge", async () => { mocks.verifyTurnstile.mockResolvedValue(false); await expect(guardAnonWrite("bad-token", oneShotWindow)).resolves.toEqual({ ok: false, reason: "turnstile" }); + + mocks.verifyTurnstile.mockResolvedValue(true); + await expect(guardAnonWrite("good-token", oneShotWindow)).resolves.toEqual({ + ok: true + }); }); }); diff --git a/tests/unit/csv.test.ts b/tests/unit/csv.test.ts index 1a0cfda..e489cb7 100644 --- a/tests/unit/csv.test.ts +++ b/tests/unit/csv.test.ts @@ -48,4 +48,17 @@ describe("toCsv", () => { ); expect(csv).toContain("plain@x.com,Jan 2026,active"); }); + + it("neutralizes spreadsheet formulas before CSV escaping", () => { + const csv = toCsv( + [ + { email: "=HYPERLINK(\"https://evil.test\")", joined: "Jan 2026", status: "active" }, + { email: " @SUM(1,1)", joined: "Jan 2026", status: "active" } + ], + cols + ); + + expect(csv).toContain("'="); + expect(csv).toContain("' @SUM"); + }); }); diff --git a/tests/unit/scan-schema.test.ts b/tests/unit/scan-schema.test.ts index 82b583c..20fbfda 100644 --- a/tests/unit/scan-schema.test.ts +++ b/tests/unit/scan-schema.test.ts @@ -12,6 +12,7 @@ describe("scanParamsSchema", () => { it("accepts a valid store + channel", () => { const parsed = scanParamsSchema.parse({ store: STORE, src: "instagram" }); expect(parsed).toEqual({ store: STORE, src: "instagram" }); + expect(scanParamsSchema.parse({ store: STORE, src: "qr" }).src).toBe("qr"); }); it("defaults a missing channel to direct", () => { @@ -24,11 +25,11 @@ describe("scanParamsSchema", () => { ); }); - it("clamps a malformed or over-long channel to direct", () => { + it("clamps unknown channels to direct", () => { expect(scanParamsSchema.parse({ store: STORE, src: "bad src!!" }).src).toBe( SCAN_SRC_FALLBACK ); - expect(scanParamsSchema.parse({ store: STORE, src: "x".repeat(40) }).src).toBe( + expect(scanParamsSchema.parse({ store: STORE, src: "unbounded-campaign" }).src).toBe( SCAN_SRC_FALLBACK ); }); diff --git a/tests/unit/sql-scale-guard.test.ts b/tests/unit/sql-scale-guard.test.ts index f80ab99..958a95b 100644 --- a/tests/unit/sql-scale-guard.test.ts +++ b/tests/unit/sql-scale-guard.test.ts @@ -20,6 +20,14 @@ describe("SQL scale guards", () => { expect(sql).not.toMatch(/jsonb_build_object[\s\S]+select count\(\*\) from public\./i); }); + it("keeps cost snapshots explicitly service-role-only under RLS", () => { + const sql = readMigration("20260709212011_cost_snapshot_service_role_policy.sql"); + + expect(sql).toMatch( + /create policy cost_snapshot_service_role_all on public\.cost_snapshot\s+for all to service_role\s+using \(true\)\s+with check \(true\)/i + ); + }); + it("does not hold the store row lock across the whole place_order transaction", () => { const sql = maybeReadMigration("0039_order_caps_and_free.sql"); if (!sql) return;