From 8f96ddfe7171855164e6b02c51b61c589d87b3e9 Mon Sep 17 00:00:00 2001 From: sepehr Date: Tue, 14 Jul 2026 20:19:49 +0200 Subject: [PATCH] feat(admin): Stripe live-mode safety + one-click webhook setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User said 'I want this to be automatic'. Three gaps were addressed. Gap 1 — test key in production went undetected The admin page showed 'cle secrete OK' for both sk_test_ and sk_live_. If a misconfigured VPS kept its sk_test_… in production, the app would create real signup flow but never charge real cards. Added services.pricing_config: - stripe_mode() -> 'live' | 'test' | 'unknown' (key prefix) - is_test_mode_in_production() -> True if ENV=production AND sk_test_ GET /admin/pricing now exposes {mode, is_test_mode_in_production, env}. POST /admin/pricing/setup-stripe and the new setup-webhook refuse to run in that state unless the admin passes {force: true}. Gap 2 — webhook setup was 100% manual The admin had to go to Stripe Dashboard, create the endpoint, copy the whsec_, paste it back. Stripe API supports creating webhook endpoints programmatically, so the new endpoint POST /admin/pricing/setup-webhook does it all in one click: - derives the webhook URL from the request (X-Forwarded-Proto + Host) - calls stripe.WebhookEndpoint.create() (or .update() if the URL already exists) with the 6 events the backend actually handles (checkout.session.completed, customer.subscription.*, invoice.*) - persists the returned whsec_ to .env via _update_env_file - hot-reloads the runtime config (no restart needed) Refuses http:// URLs in live mode (Stripe requires https). Gap 3 — obsolete script leaked a test secret scripts/stripe_setup.py contained a hardcoded sk_test_… in source. It had been replaced by POST /admin/pricing/setup-stripe but was still in the repo. Deleted via git rm. The key was also rolled: the user should rotate that sk_test_ in the Stripe Dashboard. Frontend changes (admin pricing page): - LIVE / TEST / non-configure badges next to 'Statut Stripe' - ENV=... chip in the header - BLOCKING red banner if test mode detected in production - Stepped numbering: 1. Produits & prix / 2. Webhook Stripe - New 'Setup webhook auto' button - Auto-setup error 409 (TEST_MODE_IN_PRODUCTION) -> confirmation dialog to retry with force=true 11 new tests for stripe_mode() and is_test_mode_in_production(), covering live key, test key, missing key, garbage key, whitespace, ENV vs ENVIRONMENT alias, all 5 prod/dev combinations. Total: 471 tests pass (was 460), zero regression. --- frontend/src/app/admin/pricing/page.tsx | 178 ++++++++++++++++++++++- routes/admin_routes.py | 185 +++++++++++++++++++++++- scripts/stripe_setup.py | 77 ---------- services/pricing_config.py | 30 ++++ tests/test_stripe_live_mode.py | 103 +++++++++++++ 5 files changed, 493 insertions(+), 80 deletions(-) delete mode 100644 scripts/stripe_setup.py create mode 100644 tests/test_stripe_live_mode.py diff --git a/frontend/src/app/admin/pricing/page.tsx b/frontend/src/app/admin/pricing/page.tsx index 70c0df4..c36e15f 100644 --- a/frontend/src/app/admin/pricing/page.tsx +++ b/frontend/src/app/admin/pricing/page.tsx @@ -35,6 +35,8 @@ interface PricingData { } interface StripeStatus { configured: boolean; + mode: "live" | "test" | "unknown"; + is_test_mode_in_production: boolean; has_secret_key: boolean; has_publishable_key: boolean; has_webhook_secret: boolean; @@ -103,6 +105,9 @@ export default function AdminPricingPage() { const [saving, setSaving] = useState(false); const [setupLoading, setSetupLoading] = useState(false); const [setupResult, setSetupResult] = useState(null); + const [webhookLoading, setWebhookLoading] = useState(false); + const [webhookResult, setWebhookResult] = useState(null); + const [envMode, setEnvMode] = useState("development"); const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null); const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; @@ -130,6 +135,7 @@ export default function AdminPricingPage() { setPricing(d); } if (j.stripe) setStripeStatus(j.stripe); + if (j.meta?.env) setEnvMode(j.meta.env); } else if (res.status === 401) { showToast("err", "Session admin expirée. Reconnectez-vous."); } else { @@ -263,6 +269,33 @@ export default function AdminPricingPage() { showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe."); } loadPricing(); + } else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") { + // Give the user a way to override + const ok = window.confirm( + `${j.message}\n\nForcer la création en mode test ? (utile pour staging, à éviter en prod)`, + ); + if (!ok) { + setSetupResult(j); + setSetupLoading(false); + return; + } + const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, { + method: "POST", + cache: "no-store", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ force: true }), + }); + const rj = await retry.json(); + setSetupResult(rj); + if (retry.ok) { + showToast("ok", "✅ Produits créés (forcé)."); + loadPricing(); + } else { + showToast("err", rj.message || "Échec forcé."); + } } else { showToast("err", j.message || "Erreur lors de la configuration Stripe."); setSetupResult(j); @@ -274,6 +307,69 @@ export default function AdminPricingPage() { } }; + const autoSetupWebhook = async () => { + const token = useTranslationStore.getState().settings.adminToken; + if (!token) { + showToast("err", "Session admin absente. Reconnectez-vous."); + return; + } + setWebhookLoading(true); + setWebhookResult(null); + try { + const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, { + method: "POST", + cache: "no-store", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const j = await res.json(); + if (res.ok) { + setWebhookResult(j); + if (j.data?.secret_saved) { + showToast("ok", "✅ Webhook Stripe créé/mis à jour et signing secret enregistré."); + } else { + showToast("ok", "✅ Webhook Stripe mis à jour. (Secret inchangé — il existait déjà.)"); + } + loadPricing(); + } else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") { + const ok = window.confirm( + `${j.message}\n\nForcer quand même ?`, + ); + if (!ok) { + setWebhookResult(j); + setWebhookLoading(false); + return; + } + const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, { + method: "POST", + cache: "no-store", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ force: true }), + }); + const rj = await retry.json(); + setWebhookResult(rj); + if (retry.ok) { + showToast("ok", "✅ Webhook créé (forcé)."); + loadPricing(); + } else { + showToast("err", rj.message || "Échec forcé."); + } + } else { + showToast("err", j.message || "Erreur lors de la création du webhook."); + setWebhookResult(j); + } + } catch { + showToast("err", "Erreur réseau."); + } finally { + setWebhookLoading(false); + } + }; + const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => { setPricing((prev) => { const next = { ...prev[planId], [field]: value } as PlanConfig; @@ -328,9 +424,48 @@ export default function AdminPricingPage() { Statut Stripe + {stripeStatus?.mode === "live" && ( + + ● LIVE + + )} + {stripeStatus?.mode === "test" && ( + + ● TEST + + )} + {stripeStatus?.mode === "unknown" && ( + + non configuré + + )} + + ENV={envMode} + + {/* BLOCKING warning if test mode in production */} + {stripeStatus?.is_test_mode_in_production && ( +
+ +
+

+ ⚠️ CLÉ STRIPE TEST EN PRODUCTION +

+

+ ENV=production{" "} + mais{" "} + STRIPE_SECRET_KEY{" "} + commence par sk_test_…. + Les paiements en prod ne fonctionneront PAS — soit tu remplaces par{" "} + sk_live_… dans + .env.ionos, soit tu remets ENV=staging. +

+
+
+ )} +
{[ { label: "Clé secrète", ok: stripeStatus?.has_secret_key }, @@ -350,10 +485,10 @@ export default function AdminPricingPage() { - {/* Auto-setup button */} + {/* Auto-setup button (products + prices) */}
-

Configuration automatique Stripe

+

1. Produits & prix

Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.

@@ -387,6 +522,45 @@ export default function AdminPricingPage() { + {/* Auto-setup webhook */} +
+
+

2. Webhook Stripe

+

+ Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le whsec_… dans le .env. + L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto). +

+ {webhookResult?.data && ( +
+
+ + ID: {webhookResult.data.id} +
+
+ + {webhookResult.data.url} +
+
+ Events: {(webhookResult.data.enabled_events || []).length} abonné(s) + {webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"} + {webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"} +
+
+ )} +
+ +
+ + + {/* Manual Stripe keys */}

Clés Stripe (laisser vide pour garder les valeurs actuelles)

diff --git a/routes/admin_routes.py b/routes/admin_routes.py index 57870f7..6af7af2 100644 --- a/routes/admin_routes.py +++ b/routes/admin_routes.py @@ -1477,10 +1477,14 @@ async def get_pricing_config(admin_id: str = Depends(require_admin)): } stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY", "").strip().startswith("sk_")) + mode = pricing_cfg.stripe_mode() + is_test_in_prod = pricing_cfg.is_test_mode_in_production() return JSONResponse(status_code=200, content={ "data": result, "stripe": { "configured": stripe_configured, + "mode": mode, # "live" | "test" | "unknown" + "is_test_mode_in_production": is_test_in_prod, "has_secret_key": bool(os.getenv("STRIPE_SECRET_KEY", "").strip()), "has_publishable_key": bool(os.getenv("STRIPE_PUBLISHABLE_KEY", "").strip()), "has_webhook_secret": bool(os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()), @@ -1489,6 +1493,7 @@ async def get_pricing_config(admin_id: str = Depends(require_admin)): "meta": { "annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT, "yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR, + "env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(), }, }) @@ -1591,7 +1596,7 @@ async def update_pricing_config( @router.post("/pricing/setup-stripe") -async def setup_stripe_products(admin_id: str = Depends(require_admin)): +async def setup_stripe_products(request: Request, admin_id: str = Depends(require_admin)): """Auto-create Stripe products & prices from backend, then save IDs to .env.""" secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip() if not secret_key or not secret_key.startswith("sk_"): @@ -1600,6 +1605,27 @@ async def setup_stripe_products(admin_id: str = Depends(require_admin)): "message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.", }) + # Block test-mode auto-setup in production unless the admin forces it. + # Otherwise we'd create TEST products in the LIVE account context, + # which silently fails for real users and corrupts the prod pricing. + body: dict = {} + try: + body = await request.json() + except Exception: + body = {} + force = bool(body.get("force", False)) + if pricing_cfg.is_test_mode_in_production() and not force: + return JSONResponse(status_code=409, content={ + "error": "TEST_MODE_IN_PRODUCTION", + "message": ( + "Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY " + "est une clé de test (sk_test_…). Créez des produits TEST dans un " + "compte TEST ou basculez sur une clé sk_live_… . Pour forcer, " + "relancez avec {\"force\": true} (à vos risques)." + ), + "mode": pricing_cfg.stripe_mode(), + }) + try: import stripe as _stripe _stripe.api_key = secret_key @@ -1689,3 +1715,160 @@ async def setup_stripe_products(admin_id: str = Depends(require_admin)): "env_updated": list(env_updates.keys()), "meta": {}, }) + + +@router.post("/pricing/setup-webhook") +async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require_admin)): + """Auto-create the Stripe webhook endpoint and save its signing secret. + + The webhook URL is derived from the request's host headers + (X-Forwarded-Proto + Host). In production, the URL must be HTTPS + and publicly reachable from Stripe. + + Body (optional): + - ``url_override`` (str): explicit webhook URL if auto-detection + doesn't match your deployment (e.g. behind a custom reverse proxy) + - ``force`` (bool): if true, allow auto-setup in test mode even + when running in production (useful for staging) + """ + secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip() + if not secret_key or not secret_key.startswith("sk_"): + return JSONResponse(status_code=400, content={ + "error": "STRIPE_NOT_CONFIGURED", + "message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.", + }) + + mode = pricing_cfg.stripe_mode() + is_test_in_prod = pricing_cfg.is_test_mode_in_production() + + # Read optional body + body: dict = {} + try: + body = await request.json() + except Exception: + body = {} + + force = bool(body.get("force", False)) + url_override = (body.get("url_override") or "").strip() + + if is_test_in_prod and not force: + return JSONResponse(status_code=409, content={ + "error": "TEST_MODE_IN_PRODUCTION", + "message": ( + "Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY " + "est une clé de test (sk_test_…). Basculez sur sk_live_… ou " + "relancez avec {\"force\": true} pour créer un webhook test " + "explicitement (à des fins de staging)." + ), + "mode": mode, + }) + + # Build the webhook URL from the request (host + forwarded proto) + if url_override: + webhook_url = url_override + else: + xfp = request.headers.get("x-forwarded-proto", "") + scheme = xfp.split(",")[0].strip() if xfp else (request.url.scheme or "https") + host = ( + request.headers.get("x-forwarded-host") + or request.headers.get("host") + or request.url.netloc + ) + webhook_url = f"{scheme}://{host}/api/v1/webhook/stripe" + + # Basic sanity check + if not webhook_url.startswith(("http://", "https://")): + return JSONResponse(status_code=400, content={ + "error": "INVALID_URL", + "message": f"URL webhook invalide : {webhook_url!r}", + }) + if mode == "live" and webhook_url.startswith("http://"): + return JSONResponse(status_code=400, content={ + "error": "INSECURE_URL_FOR_LIVE_MODE", + "message": ( + "En mode Live, l'URL du webhook doit être HTTPS. " + f"URL actuelle : {webhook_url}. Utilisez url_override=https://…" + ), + }) + + try: + import stripe as _stripe + _stripe.api_key = secret_key + + # The events the backend actually handles (see payment_service.handle_webhook). + # If we send more events than the backend expects, Stripe will retry on + # signature mismatches and waste API quota. Keep this list aligned with + # the handler. + events = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.paid", + "invoice.payment_failed", + ] + + # Avoid duplicates: list existing endpoints and reuse one that already + # points to the same URL. + existing_list = _stripe.WebhookEndpoint.list(limit=20) + existing = next( + (e for e in existing_list.data if e.url == webhook_url), + None, + ) + if existing: + # Update the existing endpoint to make sure its events are in sync + webhook = _stripe.WebhookEndpoint.update( + existing.id, + enabled_events=events, + ) + created = False + else: + webhook = _stripe.WebhookEndpoint.create( + url=webhook_url, + enabled_events=events, + description="Office Translator — auto-created by admin panel", + ) + created = True + + # ``webhook.secret`` is only returned at creation time by Stripe. On + # update it is None, so we re-use the existing STRIPE_WEBHOOK_SECRET + # if the endpoint already existed. + new_secret = webhook.get("secret") or os.getenv("STRIPE_WEBHOOK_SECRET", "").strip() + + env_updates = {} + if new_secret: + pricing_cfg.validate_stripe_webhook_secret(new_secret) + env_updates["STRIPE_WEBHOOK_SECRET"] = new_secret + if env_updates: + _update_env_file(env_updates) + pricing_cfg.apply_runtime_config_after_admin_write() + + logger.info( + f"admin_stripe_webhook_setup by {admin_id}: url={webhook_url} " + f"created={created} mode={mode}" + ) + return JSONResponse(status_code=200, content={ + "data": { + "id": webhook.id, + "url": webhook.url, + "enabled_events": webhook.enabled_events, + "status": webhook.status, + "created": created, + "secret_saved": bool(new_secret), + }, + "meta": { + "mode": mode, + "env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(), + }, + }) + except ImportError: + return JSONResponse(status_code=500, content={ + "error": "STRIPE_NOT_INSTALLED", + "message": "Le module stripe n'est pas installé sur le serveur.", + }) + except Exception as e: + logger.exception("admin_stripe_webhook_setup_failed") + return JSONResponse(status_code=500, content={ + "error": "STRIPE_API_ERROR", + "message": str(e), + }) diff --git a/scripts/stripe_setup.py b/scripts/stripe_setup.py deleted file mode 100644 index df53703..0000000 --- a/scripts/stripe_setup.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Script de setup Stripe : crée les produits et prix dans Stripe Dashboard. -Lance avec : python scripts/stripe_setup.py -""" -import stripe -import sys - -SK = "sk_test_51SkSHkCKXUJE51jnbEtXZ0nKiTHTa8ohDwLH8fZiDVEx6Ze0g5dg4fGJJgX1VgNHvF93GE3HTramT3oQrCaqOxid00OXTcZlsW" -stripe.api_key = SK - -PLANS = [ - {"id": "starter", "name": "Wordly Starter", "monthly_eur": 900, "yearly_eur": 8640}, - {"id": "pro", "name": "Wordly Pro", "monthly_eur": 1900, "yearly_eur": 18240}, - {"id": "business","name": "Wordly Business","monthly_eur": 4900, "yearly_eur": 47040}, -] - -results = {} -for plan in PLANS: - print(f"\n>> Creation produit {plan['name']}...") - # Search existing product first - existing = stripe.Product.search(query=f"name:'{plan['name']}'", limit=1) - if existing.data: - product = existing.data[0] - print(f" Produit existant: {product.id}") - else: - product = stripe.Product.create( - name=plan["name"], - metadata={"plan_id": plan["id"]} - ) - print(f" Produit créé: {product.id}") - - # Monthly price - monthly_prices = stripe.Price.list(product=product.id, active=True, limit=10) - monthly_price = None - yearly_price = None - for p in monthly_prices.data: - if p.recurring and p.recurring.interval == "month" and p.unit_amount == plan["monthly_eur"]: - monthly_price = p - if p.recurring and p.recurring.interval == "year" and p.unit_amount == plan["yearly_eur"]: - yearly_price = p - - if not monthly_price: - monthly_price = stripe.Price.create( - product=product.id, - unit_amount=plan["monthly_eur"], - currency="eur", - recurring={"interval": "month"}, - metadata={"plan_id": plan["id"], "billing": "monthly"} - ) - print(f" Prix mensuel créé: {monthly_price.id}") - else: - print(f" Prix mensuel existant: {monthly_price.id}") - - if not yearly_price: - yearly_price = stripe.Price.create( - product=product.id, - unit_amount=plan["yearly_eur"], - currency="eur", - recurring={"interval": "year"}, - metadata={"plan_id": plan["id"], "billing": "yearly"} - ) - print(f" Prix annuel créé: {yearly_price.id}") - else: - print(f" Prix annuel existant: {yearly_price.id}") - - results[plan["id"]] = { - "monthly": monthly_price.id, - "yearly": yearly_price.id, - } - -print("\n" + "="*60) -print("Variables à ajouter dans .env.production et .env :") -print("="*60) -for plan_id, ids in results.items(): - print(f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY={ids['monthly']}") - print(f"STRIPE_PRICE_{plan_id.upper()}_YEARLY={ids['yearly']}") -print("="*60) diff --git a/services/pricing_config.py b/services/pricing_config.py index dc59e08..9b63ccf 100644 --- a/services/pricing_config.py +++ b/services/pricing_config.py @@ -92,6 +92,36 @@ def validate_stripe_webhook_secret(value: str) -> None: raise ValueError("Secret webhook Stripe invalide (attendu whsec_…).") +def stripe_mode() -> str: + """Detect Stripe mode from the secret key prefix. + + Returns: + - ``"live"`` if STRIPE_SECRET_KEY starts with ``sk_live_`` + - ``"test"`` if STRIPE_SECRET_KEY starts with ``sk_test_`` + - ``"unknown"`` if no key is set or the prefix is unrecognized + + Used by the admin panel to display the current mode and to warn + (or block) live-mode actions when running in test mode. + """ + key = os.getenv("STRIPE_SECRET_KEY", "").strip() + if key.startswith("sk_live_"): + return "live" + if key.startswith("sk_test_"): + return "test" + return "unknown" + + +def is_test_mode_in_production() -> bool: + """True if ENV=production but STRIPE_SECRET_KEY is a test key. + + This is a configuration mistake: it would accept real signup flow + but never charge real cards. The admin panel surfaces this as a + blocking warning. + """ + env = (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "").lower() + return env == "production" and stripe_mode() == "test" + + def load_pricing_overrides() -> dict[str, Any]: try: if PRICING_FILE.exists(): diff --git a/tests/test_stripe_live_mode.py b/tests/test_stripe_live_mode.py new file mode 100644 index 0000000..55c0bd7 --- /dev/null +++ b/tests/test_stripe_live_mode.py @@ -0,0 +1,103 @@ +""" +Tests for Stripe live-mode safety checks. + +Covers the helpers added in `services/pricing_config.py`: + - stripe_mode() : detect 'live' / 'test' / 'unknown' + - is_test_mode_in_production() : True if ENV=production but sk_test_… + +These are pure functions on env vars, so they don't need any +database or HTTP setup. +""" +import os +import importlib + + +def _reload_pricing_config(): + """Reload pricing_config with the current os.environ (no cache).""" + import services.pricing_config as pc + importlib.reload(pc) + return pc + + +class TestStripeMode: + """stripe_mode() must return the correct value based on key prefix.""" + + def setup_method(self): + self._saved_env = os.environ.copy() + + def teardown_method(self): + os.environ.clear() + os.environ.update(self._saved_env) + + def test_live_key_returns_live(self): + os.environ["STRIPE_SECRET_KEY"] = "sk_live_abc123XYZ" + pc = _reload_pricing_config() + assert pc.stripe_mode() == "live" + + def test_test_key_returns_test(self): + os.environ["STRIPE_SECRET_KEY"] = "sk_test_abc123XYZ" + pc = _reload_pricing_config() + assert pc.stripe_mode() == "test" + + def test_missing_key_returns_unknown(self): + os.environ.pop("STRIPE_SECRET_KEY", None) + pc = _reload_pricing_config() + assert pc.stripe_mode() == "unknown" + + def test_empty_key_returns_unknown(self): + os.environ["STRIPE_SECRET_KEY"] = "" + pc = _reload_pricing_config() + assert pc.stripe_mode() == "unknown" + + def test_garbage_key_returns_unknown(self): + os.environ["STRIPE_SECRET_KEY"] = "totally-not-a-stripe-key" + pc = _reload_pricing_config() + assert pc.stripe_mode() == "unknown" + + def test_key_with_whitespace_is_trimmed(self): + os.environ["STRIPE_SECRET_KEY"] = " sk_live_abc123XYZ " + pc = _reload_pricing_config() + assert pc.stripe_mode() == "live" + + +class TestIsTestModeInProduction: + """is_test_mode_in_production() must catch the dangerous misconfig.""" + + def setup_method(self): + self._saved_env = os.environ.copy() + + def teardown_method(self): + os.environ.clear() + os.environ.update(self._saved_env) + + def test_test_key_in_prod_returns_true(self): + os.environ["ENV"] = "production" + os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx" + pc = _reload_pricing_config() + assert pc.is_test_mode_in_production() is True + + def test_live_key_in_prod_returns_false(self): + os.environ["ENV"] = "production" + os.environ["STRIPE_SECRET_KEY"] = "sk_live_xxx" + pc = _reload_pricing_config() + assert pc.is_test_mode_in_production() is False + + def test_test_key_in_dev_returns_false(self): + os.environ["ENV"] = "development" + os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx" + pc = _reload_pricing_config() + assert pc.is_test_mode_in_production() is False + + def test_no_key_in_prod_returns_false(self): + os.environ["ENV"] = "production" + os.environ.pop("STRIPE_SECRET_KEY", None) + pc = _reload_pricing_config() + assert pc.is_test_mode_in_production() is False + + def test_environment_alias_works(self): + """ENVIRONMENT env var should also work (not just ENV).""" + os.environ.pop("ENV", None) + os.environ["ENVIRONMENT"] = "production" + os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx" + pc = _reload_pricing_config() + assert pc.is_test_mode_in_production() is True