diff --git a/DEPLOY.md b/DEPLOY.md index e7cfb3c..8047a78 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,6 +36,7 @@ The backend image runs in three roles, all from `deploy/docker-compose.yml`: - `WORKER_ROLE` — set per-container in `docker-compose.yml` (`web`/`worker`/`scheduler`); no need to set in Doppler. - `STRIPE_PORTAL_CONFIG_ID` — optional `bpc_...`; empty uses the Stripe dashboard default portal config. Used by the Phase 1 billing portal endpoint. Create the portal configuration in Stripe (enable subscription update to the localmate price, payment-method update, cancellation) — one-time dashboard/API step. - `DASHBOARD_URL` — `https://localmate.crewcircle.co`; used as the Stripe portal `return_url` base. +- `SUPABASE_JWT_SECRET` — **Phase 1 (required for billing).** The Supabase project's JWT secret (Settings → API → JWT Settings in the Supabase dashboard). Used to verify dashboard bearer tokens and derive `client_id` from the authenticated user (tenant-auth binding, C8/D20). When unset, the legacy `require_auth` falls back to anonymous for old callers, but the strict `/billing/*` endpoints reject with 401 — so it must be set in prd. ### Migrations (Phase 0) @@ -64,6 +65,21 @@ It asserts, and FAILS the build otherwise: Record the gate's `ALL PHASE 0 STAGING GATE CHECKS PASSED` output on the PR before merge. +### Phase 1 — billing visibility + tenant auth + +**Migration:** apply `supabase/migrations/013_user_client_map.sql` on top of `012`. It creates `user_client_map (user_id text primary key, client_id uuid references clients(id))` with RLS + a `service_role_all` policy (matches `001_clients.sql`). One row per Supabase auth user → owned client; populate it at signup (or backfill existing users to their `clients` row by email). Until a user has a row, `/billing/*` returns 403. + +**Tenant-auth binding (C8/D20):** `GET /billing/usage` and `POST /billing/portal` derive `client_id` from the authenticated identity via `user_client_map` (never from the request body/query), so a user can only reach their own tenant. `SUPABASE_JWT_SECRET` must be set in Doppler — with it empty these endpoints reject 401 (the legacy `require_auth` still falls back to anonymous for old non-client-scoped callers). + +**One-time Stripe Billing Portal configuration:** +1. Stripe Dashboard → Settings → Billing → Customer portal → Add configuration (or via API: `stripe.billing_portal.Configuration.create(...)`). +2. Enable: subscription update (to the localmate price), payment-method update, subscription cancellation. +3. Copy the configuration id (`bpc_...`). +4. Set `STRIPE_PORTAL_CONFIG_ID` in Doppler (`localmate/prd`). Leave empty to use the Stripe dashboard default portal config. +5. `DASHBOARD_URL` controls where the user lands after leaving the portal (the `return_url`). + +Portal-driven subscription changes (plan/card/cancel) flow back through the existing Stripe webhook handlers (`customer.subscription.updated` / `deleted`) which already update `clients.subscription_status` — no new reconciliation path. + --- diff --git a/backend/.env.example b/backend/.env.example index ba9f41d..752f5fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,8 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +# Phase 1 — tenant auth (Supabase JWT secret; required for /billing/* client-scoped endpoints) +SUPABASE_JWT_SECRET= STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_WEBHOOK_SECRET= diff --git a/backend/jobs/trial_emails.py b/backend/jobs/trial_emails.py index 897e8d9..57edbaa 100644 --- a/backend/jobs/trial_emails.py +++ b/backend/jobs/trial_emails.py @@ -1,8 +1,17 @@ """Trial milestone emails — day1, day7, day13, and expired. -APScheduler-compatible async job. Runs hourly (wired via CronTrigger(hour=9) -in scheduler.py). Uses trial_emails_sent table for idempotency: one email -per (client_id, day_number) pair, ever. +APScheduler-compatible async job. Runs daily (wired via CronTrigger(hour=9) in +scheduler.py, which enqueues the ``run_trial_emails_daily`` arq task). Uses the +``trial_emails_sent`` table for idempotency: one email per (client_id, +day_number) pair, ever. + +Outbound sends are durable (C4): instead of calling the Resend senders directly, +this job enqueues a ``send_email_task`` arq job (the Phase 0 durable wrapper) so +a Resend transport failure retries with backoff and dead-letters on exhaustion +rather than being silently swallowed. The idempotency row is recorded BEFORE the +enqueue and rolled back if the enqueue fails, so a failed enqueue can never leave +a row that would block a future send (which would cause a silent drop); a +permanently-failed send lands in ``dead_letter`` for operator replay. """ import logging @@ -11,22 +20,16 @@ import pytz from db import get_db -from services.resend_email import ( - send_trial_day1_email, - send_trial_day7_email, - send_trial_day13_email, - send_trial_expired_email, -) AEST = pytz.timezone("Australia/Sydney") logger = logging.getLogger(__name__) -# days_since_trial_start -> (send_fn, needs_client_id_arg). -# send_trial_expired_email differs: (to, business_name) only — handled separately. -_DAY_DISPATCH: dict[int, tuple] = { - 1: (send_trial_day1_email, True), - 7: (send_trial_day7_email, True), - 13: (send_trial_day13_email, True), +# days_since_trial_start -> Resend email kind (see services.resend_email._content). +# The expired email differs (no client_id arg) and is handled separately. +_DAY_KIND: dict[int, str] = { + 1: "trial_day1", + 7: "trial_day7", + 13: "trial_day13", } @@ -48,7 +51,28 @@ def _record_send(db, client_id: str, day_number: int) -> None: ).execute() -async def run_trial_emails() -> None: +def _unrecord_send(db, client_id: str, day_number: int) -> None: + """Remove a tentatively-recorded idempotency row (enqueue-failure rollback).""" + ( + db.table("trial_emails_sent") + .delete() + .eq("client_id", client_id) + .eq("day_number", day_number) + .execute() + ) + + +async def run_trial_emails(pool) -> None: + """Enqueue durable trial-milestone emails for all active-trial clients. + + ``pool`` is the arq Redis pool (``ctx["redis"]`` from the + ``run_trial_emails_daily`` cron task). When absent the run is skipped — the + next run with a live pool picks it up. + """ + if pool is None: + logger.warning("run_trial_emails: no arq pool available — skipping this run") + return + db = get_db() now = datetime.now(AEST) @@ -79,22 +103,29 @@ async def run_trial_emails() -> None: ).astimezone(AEST) days_since = (now.date() - trial_start.date()).days - if days_since in _DAY_DISPATCH: - fn, _needs_id = _DAY_DISPATCH[days_since] + if days_since in _DAY_KIND: if not _already_sent(db, client_id, days_since): + # Record the idempotency row FIRST so a crash between + # enqueue-success and record does not cause a duplicate + # delivery on the next run. If the enqueue fails, roll the + # row back so the next run can retry (no silent drop). + _record_send(db, client_id, days_since) try: - await fn(client["email"], client["business_name"], client_id) - _record_send(db, client_id, days_since) + await pool.enqueue_job( + "send_email_task", + _DAY_KIND[days_since], + client["email"], + client["business_name"], + client_id, + ) sent += 1 logger.info( - "Sent day-%d email to %s (%s)", + "Enqueued day-%d email to %s (%s)", days_since, client["email"], client_id, ) - except Exception as exc: - logger.error( - "send_trial_day%d_email failed for %s: %s", - days_since, client_id, exc, - ) + except Exception: + _unrecord_send(db, client_id, days_since) + raise raw_ends = client.get("trial_ends_at") if raw_ends: @@ -102,23 +133,25 @@ async def run_trial_emails() -> None: raw_ends.replace("Z", "+00:00") ).astimezone(AEST) if now > trial_ends and not _already_sent(db, client_id, -1): + # Record-first / rollback-on-enqueue-failure (see above). + _record_send(db, client_id, -1) try: - await send_trial_expired_email( - client["email"], client["business_name"] + await pool.enqueue_job( + "send_email_task", + "trial_expired", + client["email"], + client["business_name"], ) - _record_send(db, client_id, -1) sent += 1 logger.info( - "Sent expired email to %s (%s)", + "Enqueued expired email to %s (%s)", client["email"], client_id, ) - except Exception as exc: - logger.error( - "send_trial_expired_email failed for %s: %s", - client_id, exc, - ) + except Exception: + _unrecord_send(db, client_id, -1) + raise except Exception as exc: logger.error("trial_emails: unexpected error for client %s: %s", client_id, exc) - logger.info("run_trial_emails: processed %d clients, sent %d emails", processed, sent) + logger.info("run_trial_emails: processed %d clients, enqueued %d emails", processed, sent) diff --git a/backend/main.py b/backend/main.py index 271873f..a1ff3f2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ from config import settings from db import init_db from scheduler import create_scheduler -from routers import auth, webhooks, drafts +from routers import auth, webhooks, drafts, billing logging.basicConfig(level=logging.INFO) @@ -75,6 +75,7 @@ async def lifespan(app: FastAPI): app.include_router(auth.router, prefix="/auth") app.include_router(webhooks.router, prefix="/webhooks") app.include_router(drafts.router, prefix="/drafts") +app.include_router(billing.router, prefix="/billing") try: from routers import approve app.include_router(approve.router, prefix="/approve") diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py index 2745fee..d667fa3 100644 --- a/backend/middleware/auth.py +++ b/backend/middleware/auth.py @@ -1,21 +1,18 @@ import logging import jwt -from fastapi import Depends, HTTPException, Request +from fastapi import HTTPException, Request from config import settings +from db import get_db logger = logging.getLogger(__name__) -async def verify_supabase_jwt(request: Request) -> dict: - """Verify Supabase JWT from Authorization Bearer header. Returns decoded payload. +async def _decode_bearer(request: Request) -> dict: + """Extract and decode the Supabase JWT from the Authorization Bearer header. - When supabase_jwt_secret is empty (e.g. CI / local dev stubs), logs a - warning and returns an anonymous payload so tests don't break. + Raises 401 when the header is missing; 403 when the token is invalid. + Shared by :func:`verify_supabase_jwt` and :func:`verify_supabase_jwt_strict`. """ - if not settings.supabase_jwt_secret: - logger.warning("SUPABASE_JWT_SECRET not configured — allowing anonymous access") - return {"sub": "anonymous", "email": None} - auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing auth") @@ -33,4 +30,63 @@ async def verify_supabase_jwt(request: Request) -> dict: return payload +async def verify_supabase_jwt(request: Request) -> dict: + """Verify Supabase JWT from Authorization Bearer header. Returns decoded payload. + + When supabase_jwt_secret is empty (e.g. CI / local dev stubs), logs a + warning and returns an anonymous payload so tests don't break. + """ + if not settings.supabase_jwt_secret: + logger.warning("SUPABASE_JWT_SECRET not configured — allowing anonymous access") + return {"sub": "anonymous", "email": None} + + return await _decode_bearer(request) + + require_auth = verify_supabase_jwt + + +async def verify_supabase_jwt_strict(request: Request) -> dict: + """Strict JWT verification — raises 401 when SUPABASE_JWT_SECRET is unset. + + Unlike :func:`verify_supabase_jwt` (kept for backward compat), this variant + NEVER falls back to an anonymous payload when the secret is empty. It is the + basis for tenant-scoped endpoints (C8/D20) where an unauthenticated caller + must not reach client data. + """ + if not settings.supabase_jwt_secret: + raise HTTPException(status_code=401, detail="Auth not configured") + + return await _decode_bearer(request) + + +async def require_client_id(request: Request) -> str: + """Derive the caller's ``client_id`` from the authenticated identity (C8/D20). + + The client_id is resolved from the JWT subject via the ``user_client_map`` + table — NEVER from the request body or query string — so a user can only + ever reach their own tenant. All Phase 1+ client-scoped endpoints + (``/billing/usage``, ``/billing/portal``, …) inject this as a dependency. + + Raises: + 401 — auth not configured, or missing/invalid token. + 403 — authenticated user has no client binding (cross-tenant denial). + """ + payload = await verify_supabase_jwt_strict(request) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Invalid identity") + + db = get_db() + resp = ( + db.table("user_client_map") + .select("client_id") + .eq("user_id", user_id) + .maybe_single() + .execute() + ) + if not resp or not resp.data: + # Authenticated but owns no client — deny (cross-tenant protection). + raise HTTPException(status_code=403, detail="No client bound to this user") + + return resp.data["client_id"] diff --git a/backend/routers/billing.py b/backend/routers/billing.py new file mode 100644 index 0000000..429cecf --- /dev/null +++ b/backend/routers/billing.py @@ -0,0 +1,105 @@ +"""Billing router — usage visibility + Stripe Billing Portal (Phase 1). + +All ``/billing/*`` client-scoped routes live here under a single ``/billing`` +prefix (D7-A). ``/auth/billing/setup-*`` stays in ``routers/auth.py`` so the +dashboard's existing SetupIntent calls keep working. + +Tenant scoping (C8/D20): ``client_id`` is derived from the authenticated +identity via :func:`middleware.auth.require_client_id` — never from the request +body/query — so a user can only ever see / act on their own tenant. +""" +import logging + +import stripe +from fastapi import APIRouter, Depends, HTTPException + +from config import settings +from db import get_db +from middleware.auth import require_client_id +from middleware.trial_gate import TRIAL_CAPS + +router = APIRouter() +logger = logging.getLogger(__name__) +stripe.api_key = settings.stripe_secret_key + + +@router.get("/usage") +async def billing_usage(client_id: str = Depends(require_client_id)): + """Per-job-type trial usage (used / cap / remaining) + trial/subscription state. + + Caps are sourced from :data:`middleware.trial_gate.TRIAL_CAPS` (single source + of truth). An active subscription reports unlimited caps (``cap``/``remaining`` + as ``null``) to mirror the :func:`check_trial_gate` bypass. Only the keys in + ``TRIAL_CAPS`` are reported; a job type absent from ``trial_usage`` is treated + as 0 used. + """ + db = get_db() + resp = ( + db.table("clients") + .select("trial_status, trial_ends_at, subscription_status, trial_usage") + .eq("id", client_id) + .single() + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Client not found") + + client = resp.data + usage_map = client.get("trial_usage") or {} + active = client.get("subscription_status") == "active" + + usage = {} + for job_type, cap in TRIAL_CAPS.items(): + used = usage_map.get(job_type, 0) + if active: + # Paid = unlimited (D6-A): mirror the trial-gate bypass. + usage[job_type] = {"used": used, "cap": None, "remaining": None} + else: + usage[job_type] = {"used": used, "cap": cap, "remaining": max(cap - used, 0)} + + return { + "trial_status": client.get("trial_status"), + "trial_ends_at": client.get("trial_ends_at"), + "subscription_status": client.get("subscription_status"), + "usage": usage, + } + + +@router.post("/portal") +async def billing_portal(client_id: str = Depends(require_client_id)): + """Create a Stripe Billing Portal session for self-serve plan/card/cancel. + + Returns ``{"url": ...}``. The dashboard redirects to ``url``. Portal-driven + subscription changes flow back through the existing Stripe webhook handlers + (``customer.subscription.updated`` / ``deleted``) — no new reconciliation path. + """ + db = get_db() + resp = ( + db.table("clients") + .select("stripe_customer_id") + .eq("id", client_id) + .single() + .execute() + ) + if not resp.data: + raise HTTPException(status_code=404, detail="Client not found") + + customer_id = resp.data.get("stripe_customer_id") + if not customer_id: + raise HTTPException(status_code=422, detail="No Stripe customer id for this client") + + return_url = settings.dashboard_url or "https://localmate.crewcircle.co/dashboard/settings" + params = {"customer": customer_id, "return_url": return_url} + # `configuration` (NOT `configuration_id`) is the verified Stripe API param. + # Pass it only when a portal config id is configured; omit to use the + # Stripe dashboard default portal config. + if settings.stripe_portal_config_id: + params["configuration"] = settings.stripe_portal_config_id + + try: + session = stripe.billing_portal.Session.create(**params) + except Exception as e: + logger.error("Stripe billing portal session failed for %s: %s", client_id, e) + raise HTTPException(status_code=502, detail="Billing portal unavailable") + + return {"url": session.url} diff --git a/backend/task_queue.py b/backend/task_queue.py index 782bd43..e9b43aa 100644 --- a/backend/task_queue.py +++ b/backend/task_queue.py @@ -457,7 +457,7 @@ async def run_trial_hourly(ctx: dict) -> None: async def run_trial_emails_daily(ctx: dict) -> None: from jobs.trial_emails import run_trial_emails - await run_trial_emails() + await run_trial_emails(ctx.get("redis")) async def reconcile_webhooks(ctx: dict) -> dict: diff --git a/backend/tests/unit/test_billing_portal.py b/backend/tests/unit/test_billing_portal.py new file mode 100644 index 0000000..a701c26 --- /dev/null +++ b/backend/tests/unit/test_billing_portal.py @@ -0,0 +1,151 @@ +"""Tests for POST /billing/portal (Phase 1) — Stripe Billing Portal session. + +Happy path returns a url; no stripe_customer_id -> 422; Stripe error -> 502; +missing client -> 404; client_id derived from auth (cross-tenant). The Stripe +param is `configuration` (not `configuration_id`) and is passed only when +STRIPE_PORTAL_CONFIG_ID is set. +""" +import inspect +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.params import Depends as DependsParam +from fastapi import HTTPException + + +def _clients_db(client_row): + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data=client_row + ) + return db + + +def _settings_mock(*, portal_config_id="", dashboard_url="https://localmate.crewcircle.co"): + s = MagicMock() + s.stripe_portal_config_id = portal_config_id + s.dashboard_url = dashboard_url + return s + + +@pytest.mark.asyncio +async def test_portal_happy_path_returns_url(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/session_xyz") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(portal_config_id="")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + result = await billing_portal(client_id="client-1") + + assert result["url"] == "https://billing.stripe.com/p/session_xyz" + call = mock_create.call_args + assert call[1]["customer"] == "cus_abc" + assert call[1]["return_url"] == "https://localmate.crewcircle.co" + # configuration omitted when STRIPE_PORTAL_CONFIG_ID is empty + assert "configuration" not in call[1] + + +@pytest.mark.asyncio +async def test_portal_passes_configuration_when_configured(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/session_xyz") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(portal_config_id="bpc_123")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + await billing_portal(client_id="client-1") + + call = mock_create.call_args + # param is `configuration`, NOT `configuration_id` + assert call[1]["configuration"] == "bpc_123" + assert "configuration_id" not in call[1] + + +@pytest.mark.asyncio +async def test_portal_fallback_return_url_when_dashboard_url_empty(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + mock_session = MagicMock(url="https://billing.stripe.com/p/s") + + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock(dashboard_url="")), \ + patch("routers.billing.stripe.billing_portal.Session.create", return_value=mock_session) as mock_create: + await billing_portal(client_id="client-1") + + call = mock_create.call_args + assert call[1]["return_url"] == "https://localmate.crewcircle.co/dashboard/settings" + + +@pytest.mark.asyncio +async def test_portal_no_customer_id_422(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": None}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_portal_empty_customer_id_422(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": ""}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_portal_stripe_error_502(): + from routers.billing import billing_portal + + db = _clients_db({"stripe_customer_id": "cus_abc"}) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()), \ + patch("routers.billing.stripe.billing_portal.Session.create", side_effect=RuntimeError("stripe down")): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-1") + + assert exc_info.value.status_code == 502 + assert "unavailable" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_portal_missing_client_404(): + from routers.billing import billing_portal + + db = _clients_db(None) + with patch("routers.billing.get_db", return_value=db), \ + patch("routers.billing.settings", _settings_mock()): + with pytest.raises(HTTPException) as exc_info: + await billing_portal(client_id="client-missing") + + assert exc_info.value.status_code == 404 + + +def test_portal_client_id_derived_from_auth_not_request(): + """Cross-tenant protection (C8/D20): client_id is injected by the + require_client_id auth dependency, never accepted from query/body.""" + from routers.billing import billing_portal + from middleware.auth import require_client_id + + sig = inspect.signature(billing_portal) + default = sig.parameters["client_id"].default + assert isinstance(default, DependsParam), "client_id must be injected via Depends" + assert default.dependency is require_client_id, ( + "client_id must be derived from require_client_id (tenant binding), " + "not a query/body param" + ) diff --git a/backend/tests/unit/test_billing_usage.py b/backend/tests/unit/test_billing_usage.py new file mode 100644 index 0000000..be8e065 --- /dev/null +++ b/backend/tests/unit/test_billing_usage.py @@ -0,0 +1,134 @@ +"""Tests for GET /billing/usage (Phase 1) — used/cap/remaining visibility. + +Caps are sourced from middleware.trial_gate.TRIAL_CAPS (single source of truth). +Active subs report unlimited; trial/expired report real caps; missing trial_usage +keys are 0; missing client -> 404; client_id is derived from auth (cross-tenant). +""" +import inspect +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +import pytz +import pytest +from fastapi.params import Depends as DependsParam +from fastapi import HTTPException + +from middleware.trial_gate import TRIAL_CAPS + +AEST = pytz.timezone("Australia/Sydney") + + +def _clients_db(client_row): + db = MagicMock() + db.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data=client_row + ) + return db + + +@pytest.mark.asyncio +async def test_usage_active_subscription_unlimited(): + from routers.billing import billing_usage + + client = { + "trial_status": "converted", + "trial_ends_at": (datetime.now(AEST) + timedelta(days=7)).isoformat(), + "subscription_status": "active", + "trial_usage": {"review_drafts": 250, "seo_reports": 9}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-active") + + assert result["subscription_status"] == "active" + assert set(result["usage"]) == set(TRIAL_CAPS) + for job_type in TRIAL_CAPS: + entry = result["usage"][job_type] + assert entry["cap"] is None + assert entry["remaining"] is None + # used is still the real count, even when unlimited + assert result["usage"]["review_drafts"]["used"] == 250 + assert result["usage"]["seo_reports"]["used"] == 9 + + +@pytest.mark.asyncio +async def test_usage_trial_within_cap(): + from routers.billing import billing_usage + + client = { + "trial_status": "active", + "trial_ends_at": (datetime.now(AEST) + timedelta(days=10)).isoformat(), + "subscription_status": "trialing", + # competitor_briefs + followup_messages intentionally absent -> 0 used + "trial_usage": {"review_drafts": 10, "seo_reports": 1}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-trial") + + assert result["subscription_status"] == "trialing" + u = result["usage"] + assert u["review_drafts"] == {"used": 10, "cap": 100, "remaining": 90} + assert u["seo_reports"] == {"used": 1, "cap": 2, "remaining": 1} + # missing keys default to 0 used + assert u["competitor_briefs"] == {"used": 0, "cap": 1, "remaining": 1} + assert u["followup_messages"] == {"used": 0, "cap": 50, "remaining": 50} + + +@pytest.mark.asyncio +async def test_usage_trial_expired_still_reports_caps(): + """An expired trial still returns real caps (only active subs go unlimited).""" + from routers.billing import billing_usage + + client = { + "trial_status": "expired", + "trial_ends_at": (datetime.now(AEST) - timedelta(days=2)).isoformat(), + "subscription_status": "trial_expired", + "trial_usage": {"review_drafts": 100}, + } + db = _clients_db(client) + with patch("routers.billing.get_db", return_value=db): + result = await billing_usage(client_id="client-expired") + + assert result["trial_status"] == "expired" + # caps are NOT null for expired (only active subs are unlimited) + assert result["usage"]["review_drafts"] == {"used": 100, "cap": 100, "remaining": 0} + assert result["usage"]["seo_reports"]["cap"] == 2 + assert result["usage"]["seo_reports"]["remaining"] == 2 + + +@pytest.mark.asyncio +async def test_usage_missing_client_404(): + from routers.billing import billing_usage + + db = _clients_db(None) + with patch("routers.billing.get_db", return_value=db): + with pytest.raises(HTTPException) as exc_info: + await billing_usage(client_id="client-missing") + + assert exc_info.value.status_code == 404 + + +def test_usage_caps_sourced_from_trial_gate(): + """The endpoint must NOT duplicate caps — they come from TRIAL_CAPS.""" + assert set(TRIAL_CAPS) == { + "review_drafts", "seo_reports", "competitor_briefs", "followup_messages" + } + assert TRIAL_CAPS == { + "review_drafts": 100, "seo_reports": 2, "competitor_briefs": 1, "followup_messages": 50, + } + + +def test_usage_client_id_derived_from_auth_not_request(): + """Cross-tenant protection (C8/D20): client_id is injected by the + require_client_id auth dependency, never accepted from query/body.""" + from routers.billing import billing_usage + from middleware.auth import require_client_id + + sig = inspect.signature(billing_usage) + default = sig.parameters["client_id"].default + assert isinstance(default, DependsParam), "client_id must be injected via Depends" + assert default.dependency is require_client_id, ( + "client_id must be derived from require_client_id (tenant binding), " + "not a query/body param" + ) diff --git a/backend/tests/unit/test_tenant_auth.py b/backend/tests/unit/test_tenant_auth.py new file mode 100644 index 0000000..9d551e5 --- /dev/null +++ b/backend/tests/unit/test_tenant_auth.py @@ -0,0 +1,169 @@ +"""Tests for tenant-auth binding (C8/D20) — the strict client-resolving dependency. + +Covers: empty SUPABASE_JWT_SECRET must NOT return anonymous (strict path raises +401), missing token -> 401, and cross-tenant denial (a user's token resolves to +ONLY their bound client via user_client_map — another tenant's client is +unreachable because the lookup is keyed on the JWT subject, not request input). +""" +import inspect +from unittest.mock import patch, MagicMock + +import jwt as pyjwt +import pytest +from fastapi import HTTPException + +SECRET = "test-jwt-secret-must-be-at-least-32-bytes" + + +def _make_jwt(sub: str) -> str: + return pyjwt.encode({"sub": sub}, SECRET, algorithm="HS256") + + +def _request(token: str | None = None) -> MagicMock: + req = MagicMock() + req.headers = {} if token is None else {"Authorization": f"Bearer {token}"} + return req + + +def _map_db(bindings: dict[str, str]) -> MagicMock: + """Mock supabase where user_client_map lookup by user_id returns the bound + client_id (or None when the user has no binding).""" + db = MagicMock() + select = db.table.return_value.select.return_value + + def _eq(col, val): + cid = bindings.get(val) + data = {"client_id": cid} if cid is not None else None + ret = MagicMock() + ret.maybe_single.return_value.execute.return_value = MagicMock(data=data) + return ret + + select.eq.side_effect = _eq + return db + + +@pytest.mark.asyncio +async def test_strict_rejects_empty_secret_not_anonymous(): + """Empty SUPABASE_JWT_SECRET must raise 401, NOT return an anonymous payload.""" + from middleware.auth import verify_supabase_jwt, verify_supabase_jwt_strict + + req = _request(token=None) + with patch("middleware.auth.settings") as s: + s.supabase_jwt_secret = "" + + # Legacy path still returns anonymous (backward compat). + legacy = await verify_supabase_jwt(req) + assert legacy == {"sub": "anonymous", "email": None} + + # Strict path rejects — no anonymous fallback. + with pytest.raises(HTTPException) as exc_info: + await verify_supabase_jwt_strict(req) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_require_client_id_missing_token_rejected_401(): + from middleware.auth import require_client_id + + req = _request(token=None) + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 401 + db.table.assert_not_called() # never reached the client lookup + + +@pytest.mark.asyncio +async def test_require_client_id_invalid_token_rejected_403(): + from middleware.auth import require_client_id + + req = _request(token="not-a-real-jwt") + db = _map_db({}) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_require_client_id_resolves_bound_client(): + """A valid token resolves to the client bound to its subject.""" + from middleware.auth import require_client_id + + db = _map_db({"userA": "clientA"}) + req = _request(token=_make_jwt("userA")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + client_id = await require_client_id(req) + + assert client_id == "clientA" + # The lookup is keyed on the JWT subject, NOT any request input. + select = db.table.return_value.select.return_value + select.eq.assert_called_once_with("user_id", "userA") + + +@pytest.mark.asyncio +async def test_require_client_id_denies_user_without_binding(): + """An authenticated user with no client binding is denied (403).""" + from middleware.auth import require_client_id + + db = _map_db({}) # userC has no mapping + req = _request(token=_make_jwt("userC")) + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + with pytest.raises(HTTPException) as exc_info: + await require_client_id(req) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_cross_tenant_user_only_reaches_own_client(): + """User A's token resolves to client A and can NEVER yield client B; user B's + token resolves to client B. Tenant isolation is enforced by the JWT-subject + binding (C8/D20).""" + from middleware.auth import require_client_id + + # userA -> clientA, userB -> clientB + db = _map_db({"userA": "clientA", "userB": "clientB"}) + + with patch("middleware.auth.settings") as s, \ + patch("middleware.auth.get_db", return_value=db): + s.supabase_jwt_secret = SECRET + + cid_a = await require_client_id(_request(token=_make_jwt("userA"))) + cid_b = await require_client_id(_request(token=_make_jwt("userB"))) + + assert cid_a == "clientA" + assert cid_b == "clientB" + # user A's token never yields client B — the other tenant is unreachable. + assert cid_a != "clientB" + # Both lookups were bound to their respective JWT subjects. + eq_calls = [c.args for c in db.table.return_value.select.return_value.eq.call_args_list] + assert ("user_id", "userA") in eq_calls + assert ("user_id", "userB") in eq_calls + + +def test_require_auth_kept_for_backward_compat(): + """The legacy anonymous-fallback dependency is unchanged for existing callers.""" + from middleware.auth import require_auth, verify_supabase_jwt + + assert require_auth is verify_supabase_jwt + + +def test_require_client_id_is_a_fastapi_dependency_signature(): + """Sanity: require_client_id is a plain async callable usable with Depends.""" + from middleware.auth import require_client_id + + sig = inspect.signature(require_client_id) + assert "request" in sig.parameters diff --git a/backend/tests/unit/test_trial_emails_enqueue.py b/backend/tests/unit/test_trial_emails_enqueue.py new file mode 100644 index 0000000..e0f7f2b --- /dev/null +++ b/backend/tests/unit/test_trial_emails_enqueue.py @@ -0,0 +1,222 @@ +"""Tests that trial milestone emails enqueue durable arq jobs (C4 outbound migration). + +run_trial_emails now enqueues `send_email_task` arq jobs (the Phase 0 durable +Resend wrapper) instead of calling the Resend senders directly, so a transport +failure retries + dead-letters rather than being silently swallowed. +""" +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock, AsyncMock + +import pytz +import pytest + +AEST = pytz.timezone("Australia/Sydney") + + +def _trial_emails_db(clients): + """Mock supabase routing .table('clients') and .table('trial_emails_sent') + to separate chain mocks (a single shared chain cannot serve both).""" + db = MagicMock() + + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=clients) + + sent_table = MagicMock() + # _already_sent: select('id').eq('client_id').eq('day_number').limit(1).execute() -> [] + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock(data=[]) + sent_table.insert.return_value.execute.return_value = MagicMock(data=[{"id": "row-1"}]) + + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + return db, sent_table + + +@pytest.mark.asyncio +async def test_day_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Enqueued a durable send_email_task for the day-1 email, with the right kind + # and positional args (kind, to, business_name, client_id). + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_day1", "owner@biz1.com.au", "Biz One", "c1" + ) + # Idempotency row recorded after successful enqueue. + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_expired_email_enqueues_send_email_task(): + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + # Expired email enqueued (kind, to, business_name) — no client_id arg. + pool.enqueue_job.assert_any_call( + "send_email_task", "trial_expired", "owner@biz2.com.au", "Biz Two" + ) + sent_table.insert.assert_called() + + +@pytest.mark.asyncio +async def test_does_not_directly_call_resend_senders(): + """The Resend senders must no longer be called inline — sends go via arq.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db, _ = _trial_emails_db([client]) + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db), \ + patch("services.resend_email.send_email_strict", new_callable=AsyncMock) as mock_strict, \ + patch("services.resend_email.send_trial_day1_email", new_callable=AsyncMock) as mock_day1: + await run_trial_emails(pool) + + mock_strict.assert_not_awaited() + mock_day1.assert_not_awaited() + pool.enqueue_job.assert_awaited() + + +@pytest.mark.asyncio +async def test_idempotent_already_sent_not_reenqueued(): + """A milestone already recorded in trial_emails_sent is not re-enqueued.""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), + "trial_ends_at": (now + timedelta(days=13)).isoformat(), + "trial_status": "active", + } + db = MagicMock() + clients_table = MagicMock() + clients_table.select.return_value.eq.return_value.execute.return_value = MagicMock(data=[client]) + sent_table = MagicMock() + # _already_sent returns a row -> already sent + sent_table.select.return_value.eq.return_value.eq.return_value.limit.return_value.execute.return_value = MagicMock( + data=[{"id": "existing"}] + ) + db.table.side_effect = lambda name: clients_table if name == "clients" else sent_table + + pool = MagicMock() + pool.enqueue_job = AsyncMock() + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_pool_skips_safely(): + """Without an arq pool the run skips rather than crashing.""" + from jobs.trial_emails import run_trial_emails + + db = MagicMock() + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(None) # must not raise + + +@pytest.mark.asyncio +async def test_enqueue_failure_day_rolls_back_idempotency_record(): + """An enqueue failure must roll back the tentatively-recorded idempotency + row so the next run can retry — never leaving a row that blocks future sends + (which would silently drop the email).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c1", + "email": "owner@biz1.com.au", + "business_name": "Biz One", + "trial_started_at": (now - timedelta(days=1)).isoformat(), # days_since == 1 + "trial_ends_at": (now + timedelta(days=13)).isoformat(), # not expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) # per-client handler swallows; run completes + + pool.enqueue_job.assert_awaited() + # The idempotency row was tentatively recorded... + sent_table.insert.assert_called() + # ...then rolled back (delete targeting this client + day) so it does not + # block the next run. + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c1") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", 1) + + +@pytest.mark.asyncio +async def test_enqueue_failure_expired_rolls_back_idempotency_record(): + """Same rollback guarantee for the expired-email loop (day_number -1).""" + from jobs.trial_emails import run_trial_emails + + now = datetime.now(AEST) + client = { + "id": "c2", + "email": "owner@biz2.com.au", + "business_name": "Biz Two", + "trial_started_at": (now - timedelta(days=20)).isoformat(), # not a milestone day + "trial_ends_at": (now - timedelta(days=6)).isoformat(), # expired + "trial_status": "active", + } + db, sent_table = _trial_emails_db([client]) + + pool = MagicMock() + pool.enqueue_job = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("jobs.trial_emails.get_db", return_value=db): + await run_trial_emails(pool) + + pool.enqueue_job.assert_awaited() + sent_table.insert.assert_called() + sent_table.delete.assert_called_once() + sent_table.delete.return_value.eq.assert_called_with("client_id", "c2") + sent_table.delete.return_value.eq.return_value.eq.assert_called_with("day_number", -1) diff --git a/supabase/migrations/013_user_client_map.sql b/supabase/migrations/013_user_client_map.sql new file mode 100644 index 0000000..65b582a --- /dev/null +++ b/supabase/migrations/013_user_client_map.sql @@ -0,0 +1,23 @@ +-- 013_user_client_map.sql +-- User -> client ownership binding for tenant authorization (C8/D20). +-- +-- Maps a Supabase auth user (the JWT `sub` claim) to exactly one client/tenant +-- row so that client-scoped endpoints derive `client_id` from the authenticated +-- identity via this table — NEVER from a request body/query param. This is the +-- Phase 1 prerequisite (D20-A) that all later client-scoped endpoints reuse +-- (/billing/usage, /billing/portal, /locations, /practitioners, /rankings, …). +-- +-- One client per user (1:1) today; a later phase may relax to many-if-needed. +-- RLS + service_role_all policy matches 001_clients.sql so the service-role +-- backend can read/write (the backend uses the Supabase service role key). + +create table user_client_map ( + user_id text primary key, -- Supabase auth user id (JWT `sub`) + client_id uuid not null references clients(id), -- owned client/tenant + created_at timestamptz default now() +); + +alter table user_client_map enable row level security; + +create policy "service_role_all" on user_client_map + using (auth.role() = 'service_role');