agriflow-api / tests /test_auth.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
8.45 kB
"""
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}"}
@pytest.fixture
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
@pytest.fixture
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
@pytest.mark.parametrize("header", [
{"Authorization": "Bearer"},
{"Authorization": "Bearer "},
{"Authorization": "Basic abc123"},
{"Authorization": make_token()}, # missing the Bearer scheme
{"Authorization": "Bearer not.a.token"},
])
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"]
@pytest.mark.parametrize("path,params", PREMIUM)
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
@pytest.mark.parametrize("path,params", PREMIUM)
def test_premium_closed_when_require_auth_on(self, strict_client, path, params):
assert strict_client.get(path, params=params).status_code == 401
@pytest.mark.parametrize("path,params", PREMIUM)
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
@pytest.mark.parametrize("path", PUBLIC)
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