Spaces:
Sleeping
Sleeping
| """ | |
| Supabase JWT verification and endpoint gating. | |
| The point of these tests is that the login actually protects something. Each | |
| rejection case below is a way an attacker would try to get past it: | |
| forged signature, no signature at all ("alg": "none"), expired token, wrong | |
| audience, another project's secret, and simply omitting the header. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| from dataclasses import replace | |
| from datetime import datetime, timedelta, timezone | |
| import jwt | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| SECRET = "unit-test-jwt-secret" | |
| OTHER_SECRET = "some-other-projects-secret" | |
| SUB = "11111111-2222-3333-4444-555555555555" | |
| def make_token( | |
| *, secret=SECRET, sub=SUB, aud="authenticated", expires_in=3600, | |
| algorithm="HS256", email="dinas@example.go.id", | |
| ): | |
| now = datetime.now(timezone.utc) | |
| payload = { | |
| "sub": sub, "email": email, "role": "authenticated", | |
| "iat": now, "exp": now + timedelta(seconds=expires_in), | |
| } | |
| if aud is not None: | |
| payload["aud"] = aud | |
| return jwt.encode(payload, secret, algorithm=algorithm) | |
| def bearer(token): | |
| return {"Authorization": f"Bearer {token}"} | |
| def client(monkeypatch): | |
| monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET) | |
| monkeypatch.delenv("SUPABASE_URL", raising=False) | |
| monkeypatch.setenv("PHONE_HASH_SALT", "t") | |
| from whatsapp_bot import server | |
| with TestClient(server.app) as c: | |
| yield c | |
| def strict_client(monkeypatch): | |
| """A client with REQUIRE_AUTH on — the production posture.""" | |
| monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET) | |
| monkeypatch.delenv("SUPABASE_URL", raising=False) | |
| monkeypatch.setenv("PHONE_HASH_SALT", "t") | |
| monkeypatch.setenv("REQUIRE_AUTH", "true") | |
| from whatsapp_bot import server | |
| with TestClient(server.app) as c: | |
| yield c | |
| # ============================================================================= | |
| # A. TOKEN VERIFICATION | |
| # ============================================================================= | |
| class TestA_Verification: | |
| def test_valid_token_is_accepted(self, client): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token())) | |
| assert r.status_code == 200 | |
| def test_no_header_is_rejected(self, client): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}) | |
| assert r.status_code == 401 | |
| def test_forged_signature_is_rejected(self, client): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(secret=OTHER_SECRET))) | |
| assert r.status_code == 401 | |
| def test_alg_none_token_is_rejected(self, client): | |
| # The classic JWT bypass: strip the signature and claim it isn't needed. | |
| now = datetime.now(timezone.utc) | |
| unsigned = jwt.encode( | |
| {"sub": SUB, "aud": "authenticated", "exp": now + timedelta(hours=1)}, | |
| key="", algorithm="none", | |
| ) | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(unsigned)) | |
| assert r.status_code == 401 | |
| def test_expired_token_is_rejected(self, client): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(expires_in=-60))) | |
| assert r.status_code == 401 | |
| def test_wrong_audience_is_rejected(self, client): | |
| # A token minted for another Supabase surface must not open this door. | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(aud="some-other-service"))) | |
| assert r.status_code == 401 | |
| def test_token_without_subject_is_rejected(self, client): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(sub=None))) | |
| assert r.status_code == 401 | |
| def test_malformed_headers_are_rejected(self, client, header): | |
| r = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=header) | |
| assert r.status_code == 401 | |
| def test_rejection_does_not_leak_the_reason(self, client): | |
| # Expired vs forged must look identical, or a prober learns which | |
| # tokens are real. | |
| expired = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(expires_in=-60))) | |
| forged = client.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token(secret=OTHER_SECRET))) | |
| assert expired.json() == forged.json() | |
| # ============================================================================= | |
| # B. ENDPOINT GATING | |
| # ============================================================================= | |
| class TestB_Gating: | |
| PREMIUM = [ | |
| ("/api/v1/matches", {}), | |
| ("/api/v1/forecast", {"commodity": "cabai_rawit", "city": "3578"}), | |
| ("/api/v1/anomalies", {}), | |
| ] | |
| PUBLIC = ["/api/v1/commodities", "/api/v1/kabupaten"] | |
| def test_premium_open_when_require_auth_off(self, client, path, params): | |
| # Demo posture: judges can browse without an account. | |
| assert client.get(path, params=params).status_code != 401 | |
| def test_premium_closed_when_require_auth_on(self, strict_client, path, params): | |
| assert strict_client.get(path, params=params).status_code == 401 | |
| def test_premium_open_with_token_when_require_auth_on(self, strict_client, path, params): | |
| r = strict_client.get(path, params=params, headers=bearer(make_token())) | |
| assert r.status_code != 401 | |
| def test_reference_data_stays_public(self, strict_client, path): | |
| # Commodity and kabupaten lists are public government reference data; | |
| # gating them would break the map for anonymous visitors. | |
| assert strict_client.get(path).status_code == 200 | |
| def test_billing_status_ignores_require_auth_flag(self, client): | |
| # Per-person data is gated even in demo posture, because the endpoint | |
| # is an enumeration oracle for phone numbers. | |
| assert client.get("/billing/status", | |
| params={"phone": "+628111222333"}).status_code == 401 | |
| def test_health_is_always_public(self, strict_client): | |
| assert strict_client.get("/health").status_code == 200 | |
| def test_health_reports_auth_posture(self, strict_client): | |
| body = strict_client.get("/health").json() | |
| assert body["auth_configured"] is True | |
| assert body["require_auth"] is True | |
| # ============================================================================= | |
| # C. MISCONFIGURATION | |
| # ============================================================================= | |
| class TestC_Misconfiguration: | |
| def test_unconfigured_auth_still_rejects_protected_routes(self, monkeypatch): | |
| # Fail closed: no secret and no URL must mean "nobody gets in", | |
| # never "everybody gets in". | |
| monkeypatch.delenv("SUPABASE_JWT_SECRET", raising=False) | |
| monkeypatch.delenv("SUPABASE_URL", raising=False) | |
| monkeypatch.setenv("PHONE_HASH_SALT", "t") | |
| from whatsapp_bot import server | |
| with TestClient(server.app) as c: | |
| r = c.get("/billing/status", params={"phone": "+628111222333"}, | |
| headers=bearer(make_token())) | |
| assert r.status_code == 401 | |
| def test_health_flags_unconfigured_auth(self, monkeypatch): | |
| monkeypatch.delenv("SUPABASE_JWT_SECRET", raising=False) | |
| monkeypatch.delenv("SUPABASE_URL", raising=False) | |
| from whatsapp_bot import server | |
| with TestClient(server.app) as c: | |
| assert c.get("/health").json()["auth_configured"] is False | |