animetix-brain / tests /api /test_auth_coverage.py
MissawB's picture
Upload folder using huggingface_hub
b1e14ec verified
Raw
History Blame Contribute Delete
21.3 kB
"""Real-behavior coverage tests for ``animetix.auth``.
Every external boundary (Google IAP token verification, the Google public-keys
HTTP fetch, ``jwt.decode`` / ``jwt.get_unverified_header``) is patched at the
module level so no real network or Google calls happen. All assertions check
observable behavior: returned tuples/values, exception type + message, request
``META`` mutations and database side effects (staff/superuser flags, created
users, saved emails).
The module is imported under the ``animetix.*`` namespace (``backend/api`` is on
the pythonpath) so coverage is attributed to ``animetix.auth``.
"""
# E402: the Django/DRF imports below intentionally follow _force_traced_import()
# so the module under test is re-executed under the live coverage tracer first.
# ruff: noqa: E402
import importlib
import sys
from unittest.mock import MagicMock, patch
import jwt
import pytest
# ``animetix/auth.py`` is imported by Django during ``django.setup()`` -- it is
# wired into AUTHENTICATION_BACKENDS / MIDDLEWARE / DRF auth classes under the
# ``animetix.auth`` dotted path.
# Because the file was executed *before* pytest-cov started tracing, coverage.py
# would otherwise report it as "module-not-measured" and collect zero data.
#
# To fix this we re-execute the module while the tracer is active. Two things
# are required:
# 1. drop the module from ``sys.modules`` so ``import_module`` truly
# re-runs the file body (rather than returning the cached module), and
# 2. invalidate coverage's per-file ``_should_trace`` cache, which already
# contains a "do not trace / already imported" decision for this path from
# startup -- without this the re-execution is silently not recorded.
def _force_traced_import():
try:
import coverage
cov = coverage.Coverage.current()
if cov is not None and getattr(cov, "_collector", None) is not None:
# The collector memoises a "do not trace / already-imported"
# disposition for auth.py (it was first seen before the file was
# registered as a measured source). Clearing that cache lets the
# forthcoming re-import be traced.
cache = getattr(cov._collector, "should_trace_cache", None)
if hasattr(cache, "clear"):
cache.clear()
except Exception: # pragma: no cover - coverage not active (plain run)
pass
sys.modules.pop("animetix.auth", None)
return importlib.import_module("animetix.auth")
auth_mod = _force_traced_import()
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import RemoteUserBackend
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.core.exceptions import PermissionDenied
from django.test import RequestFactory, override_settings
from rest_framework import exceptions
User = get_user_model()
@pytest.fixture
def rf():
return RequestFactory()
@pytest.fixture(autouse=True)
def _reset_public_keys_cache():
"""The module caches public keys in globals; reset around every test."""
auth_mod._public_keys_cache = {}
auth_mod._public_keys_expiry = 0
yield
auth_mod._public_keys_cache = {}
auth_mod._public_keys_expiry = 0
# --------------------------------------------------------------------------- #
# verify_iap_jwt
# --------------------------------------------------------------------------- #
def test_verify_iap_jwt_no_audience_returns_none():
assert auth_mod.verify_iap_jwt("token", None) is None
assert auth_mod.verify_iap_jwt("token", "") is None
def test_verify_iap_jwt_success_returns_payload():
payload = {"email": "a@b.com"}
with patch.object(auth_mod.id_token, "verify_token", return_value=payload) as vt:
result = auth_mod.verify_iap_jwt("token", "aud")
assert result == payload
vt.assert_called_once()
assert vt.call_args.kwargs["audience"] == "aud"
def test_verify_iap_jwt_verify_raises_returns_none():
with patch.object(
auth_mod.id_token, "verify_token", side_effect=ValueError("boom")
):
assert auth_mod.verify_iap_jwt("token", "aud") is None
# --------------------------------------------------------------------------- #
# IAPRemoteUserMiddleware.process_request
# --------------------------------------------------------------------------- #
def _middleware():
return auth_mod.IAPRemoteUserMiddleware(get_response=lambda r: r)
def test_middleware_no_assertion_bypasses(rf):
request = rf.get("/x")
assert _middleware().process_request(request) is None
def test_middleware_falsy_payload_raises_permission_denied(rf):
request = rf.get("/x", HTTP_X_GOOG_IAP_JWT_ASSERTION="jwt")
with patch.object(auth_mod, "verify_iap_jwt", return_value=None):
with pytest.raises(PermissionDenied, match="Invalid IAP JWT Assertion"):
_middleware().process_request(request)
def test_middleware_payload_missing_email_raises(rf):
request = rf.get("/x", HTTP_X_GOOG_IAP_JWT_ASSERTION="jwt")
with patch.object(auth_mod, "verify_iap_jwt", return_value={"sub": "x"}):
with pytest.raises(PermissionDenied, match="missing email claim"):
_middleware().process_request(request)
def test_middleware_success_sets_remote_user(rf):
request = rf.get("/x", HTTP_X_GOOG_IAP_JWT_ASSERTION="jwt")
mw = _middleware()
with patch.object(auth_mod, "verify_iap_jwt", return_value={"email": "u@e.com"}):
# Make super().process_request a no-op so we only assert the META side effect.
with patch.object(RemoteUserMiddleware, "process_request", return_value=None):
mw.process_request(request)
assert request.META[mw.header] == "u@e.com"
# --------------------------------------------------------------------------- #
# IAPRemoteUserBackend
# --------------------------------------------------------------------------- #
def test_clean_username_email_and_plain():
backend = auth_mod.IAPRemoteUserBackend()
assert backend.clean_username("john@example.com") == "john"
assert backend.clean_username("john") == "john"
@pytest.mark.django_db
@override_settings(IAP_APPROVED_ADMIN_EMAILS=["admin@example.com"])
def test_update_user_permissions_grants_admin():
user = User.objects.create_user(username="admin", email="admin@example.com")
assert not user.is_staff and not user.is_superuser
auth_mod.IAPRemoteUserBackend()._update_user_permissions(user)
user.refresh_from_db()
assert user.is_staff and user.is_superuser
@pytest.mark.django_db
@override_settings(IAP_APPROVED_ADMIN_EMAILS=[])
def test_update_user_permissions_revokes_admin():
user = User.objects.create_user(
username="ex-admin",
email="ex@example.com",
is_staff=True,
is_superuser=True,
)
auth_mod.IAPRemoteUserBackend()._update_user_permissions(user)
user.refresh_from_db()
assert not user.is_staff and not user.is_superuser
@pytest.mark.django_db
@override_settings(IAP_APPROVED_ADMIN_EMAILS=[])
def test_configure_user_sets_email_and_permissions():
user = User.objects.create_user(username="cfg")
backend = auth_mod.IAPRemoteUserBackend()
backend.current_email = "cfg@example.com"
returned = backend.configure_user(None, user, created=True)
assert returned is user
user.refresh_from_db()
assert user.email == "cfg@example.com"
@pytest.mark.django_db
@override_settings(IAP_APPROVED_ADMIN_EMAILS=[])
def test_authenticate_happy_path_saves_email():
user = User.objects.create_user(username="auth-u")
backend = auth_mod.IAPRemoteUserBackend()
with patch.object(RemoteUserBackend, "authenticate", return_value=user):
result = backend.authenticate(None, "auth-u@example.com")
assert result is user
assert backend.current_email == "auth-u@example.com"
user.refresh_from_db()
assert user.email == "auth-u@example.com"
@pytest.mark.django_db
def test_authenticate_returns_none_when_super_returns_none():
backend = auth_mod.IAPRemoteUserBackend()
with patch.object(RemoteUserBackend, "authenticate", return_value=None):
assert backend.authenticate(None, "nobody@example.com") is None
# --------------------------------------------------------------------------- #
# get_google_public_keys
# --------------------------------------------------------------------------- #
def test_public_keys_cache_hit_skips_request():
auth_mod._public_keys_cache = {"kid": "cert"}
auth_mod._public_keys_expiry = auth_mod.time.time() + 1000
with patch.object(auth_mod.requests, "get") as get:
result = auth_mod.get_google_public_keys()
assert result == {"kid": "cert"}
get.assert_not_called()
def test_public_keys_success_parses_max_age():
resp = MagicMock()
resp.headers = {"Cache-Control": "public, max-age=600"}
resp.json.return_value = {"kid1": "cert1"}
resp.raise_for_status.return_value = None
before = auth_mod.time.time()
with patch.object(auth_mod.requests, "get", return_value=resp):
result = auth_mod.get_google_public_keys()
assert result == {"kid1": "cert1"}
# expiry advanced roughly by max-age (600s)
assert auth_mod._public_keys_expiry >= before + 500
def test_public_keys_malformed_max_age_uses_default():
resp = MagicMock()
resp.headers = {"Cache-Control": "max-age=notanint"}
resp.json.return_value = {"kid2": "cert2"}
resp.raise_for_status.return_value = None
before = auth_mod.time.time()
with patch.object(auth_mod.requests, "get", return_value=resp):
result = auth_mod.get_google_public_keys()
assert result == {"kid2": "cert2"}
# default 3600 applied despite malformed header
assert auth_mod._public_keys_expiry >= before + 3000
def test_public_keys_request_raises_returns_stale_cache():
auth_mod._public_keys_cache = {"stale": "cert"}
auth_mod._public_keys_expiry = 0 # expired -> forces fetch attempt
with patch.object(
auth_mod.requests, "get", side_effect=RuntimeError("network down")
):
result = auth_mod.get_google_public_keys()
assert result == {"stale": "cert"}
# --------------------------------------------------------------------------- #
# GoogleIdentityAuthentication.authenticate
# --------------------------------------------------------------------------- #
def _auth():
return auth_mod.GoogleIdentityAuthentication()
def test_google_auth_no_header_returns_none(rf):
assert _auth().authenticate(rf.get("/x")) is None
def test_google_auth_malformed_header_returns_none(rf):
assert _auth().authenticate(rf.get("/x", HTTP_AUTHORIZATION="onlyonepart")) is None
assert _auth().authenticate(rf.get("/x", HTTP_AUTHORIZATION="Basic abc")) is None
@pytest.mark.django_db
@override_settings(FIREBASE_AUTH_EMULATOR_HOST="localhost:9099")
def test_google_auth_emulator_success(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
payload = {"email": "emu@example.com"}
with patch.object(auth_mod.jwt, "decode", return_value=payload):
user, returned_payload = _auth().authenticate(request)
assert returned_payload == payload
assert user.email == "emu@example.com"
@pytest.mark.django_db
@override_settings(FIREBASE_AUTH_EMULATOR_HOST="localhost:9099")
def test_google_auth_emulator_missing_email(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with patch.object(auth_mod.jwt, "decode", return_value={"sub": "x"}):
with pytest.raises(exceptions.AuthenticationFailed, match="Invalid Emulator"):
_auth().authenticate(request)
@pytest.mark.django_db
@override_settings(FIREBASE_AUTH_EMULATOR_HOST="localhost:9099")
def test_google_auth_emulator_decode_raises(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with patch.object(auth_mod.jwt, "decode", side_effect=ValueError("bad")):
with pytest.raises(exceptions.AuthenticationFailed, match="Invalid Emulator"):
_auth().authenticate(request)
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_no_public_keys(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with patch.object(auth_mod, "get_google_public_keys", return_value={}):
with pytest.raises(
exceptions.AuthenticationFailed, match="Google public keys unavailable"
):
_auth().authenticate(request)
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_bad_kid(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with patch.object(auth_mod, "get_google_public_keys", return_value={"good": "c"}):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "missing"}
):
with pytest.raises(exceptions.AuthenticationFailed, match="Invalid kid"):
_auth().authenticate(request)
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_expired_signature(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with (
patch.object(auth_mod, "get_google_public_keys", return_value={"k": "c"}),
patch.object(auth_mod, "load_pem_x509_certificate", return_value=MagicMock()),
):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "k"}
):
with patch.object(
auth_mod.jwt, "decode", side_effect=jwt.ExpiredSignatureError()
):
with pytest.raises(
exceptions.AuthenticationFailed, match="ID Token has expired"
):
_auth().authenticate(request)
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_invalid_token(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with (
patch.object(auth_mod, "get_google_public_keys", return_value={"k": "c"}),
patch.object(auth_mod, "load_pem_x509_certificate", return_value=MagicMock()),
):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "k"}
):
with patch.object(
auth_mod.jwt,
"decode",
side_effect=jwt.InvalidTokenError("nope"),
):
with pytest.raises(
exceptions.AuthenticationFailed, match="Invalid ID Token"
):
_auth().authenticate(request)
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_generic_exception(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with (
patch.object(auth_mod, "get_google_public_keys", return_value={"k": "c"}),
patch.object(auth_mod, "load_pem_x509_certificate", return_value=MagicMock()),
):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "k"}
):
with patch.object(
auth_mod.jwt, "decode", side_effect=RuntimeError("weird")
):
with pytest.raises(
exceptions.AuthenticationFailed, match="Authentication failed"
):
_auth().authenticate(request)
@pytest.mark.django_db
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_production_missing_email(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
with (
patch.object(auth_mod, "get_google_public_keys", return_value={"k": "c"}),
patch.object(auth_mod, "load_pem_x509_certificate", return_value=MagicMock()),
):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "k"}
):
with patch.object(auth_mod.jwt, "decode", return_value={"sub": "x"}):
with pytest.raises(
exceptions.AuthenticationFailed, match="missing email claim"
):
_auth().authenticate(request)
@pytest.mark.django_db
@override_settings(FIREBASE_AUTH_EMULATOR_HOST=None)
def test_google_auth_production_success(rf):
request = rf.get("/x", HTTP_AUTHORIZATION="Bearer token")
payload = {"email": "prod@example.com"}
with (
patch.object(auth_mod, "get_google_public_keys", return_value={"k": "c"}),
patch.object(auth_mod, "load_pem_x509_certificate", return_value=MagicMock()),
):
with patch.object(
auth_mod.jwt, "get_unverified_header", return_value={"kid": "k"}
):
with patch.object(auth_mod.jwt, "decode", return_value=payload):
user, returned = _auth().authenticate(request)
assert returned == payload
assert user.email == "prod@example.com"
# --------------------------------------------------------------------------- #
# _get_or_create_user
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_get_or_create_user_returns_existing():
existing = User.objects.create_user(username="ex", email="ex@example.com")
result = _auth()._get_or_create_user("ex@example.com")
assert result.pk == existing.pk
@pytest.mark.django_db
def test_get_or_create_user_creates_new():
result = _auth()._get_or_create_user("fresh@example.com")
assert result.pk is not None
assert result.username == "fresh"
assert result.email == "fresh@example.com"
assert not result.has_usable_password()
@pytest.mark.django_db
def test_get_or_create_user_username_collision_suffix():
# Pre-create a user whose username equals the email prefix.
User.objects.create_user(username="dup", email="other@example.com")
result = _auth()._get_or_create_user("dup@example.com")
assert result.username == "dup_1"
assert result.email == "dup@example.com"
# --------------------------------------------------------------------------- #
# DeveloperApiKeyAuthentication.authenticate
# --------------------------------------------------------------------------- #
def _dev_auth():
return auth_mod.DeveloperApiKeyAuthentication()
def _make_profile(tier="pro", is_active=True, username="dev"):
# A Profile is auto-created via a post_save signal on User; fetch and
# update it rather than creating a second one (OneToOne -> would collide).
user = User.objects.create_user(username=username, is_active=is_active)
profile = user.profile
profile.tier = tier
profile.save()
return profile
def test_dev_auth_no_key_returns_none(rf):
assert _dev_auth().authenticate(rf.get("/x")) is None
def test_dev_auth_bad_prefix(rf):
request = rf.get("/x", HTTP_X_API_KEY="bad_key_value")
with pytest.raises(exceptions.AuthenticationFailed, match="Invalid API Key format"):
_dev_auth().authenticate(request)
def test_dev_auth_too_few_parts(rf):
request = rf.get("/x", HTTP_X_API_KEY="ax_pro_only")
with pytest.raises(
exceptions.AuthenticationFailed, match="Invalid API Key structure"
):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_profile_not_found(rf):
request = rf.get("/x", HTTP_X_API_KEY="ax_pro_999999_secret")
with pytest.raises(
exceptions.AuthenticationFailed, match="Developer Profile not found"
):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_value_error_pk(rf):
# Non-numeric pk -> ValueError caught and surfaced as profile-not-found.
request = rf.get("/x", HTTP_X_API_KEY="ax_pro_notanint_secret")
with pytest.raises(
exceptions.AuthenticationFailed, match="Developer Profile not found"
):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_non_pro_tier(rf):
profile = _make_profile(tier="free")
request = rf.get("/x", HTTP_X_API_KEY=f"ax_pro_{profile.pk}_secret")
with pytest.raises(exceptions.AuthenticationFailed, match="restricted to Pro tier"):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_bad_key(rf):
from animetix.models import Profile
profile = _make_profile(tier="pro")
request = rf.get("/x", HTTP_X_API_KEY=f"ax_pro_{profile.pk}_secret")
with patch.object(Profile, "check_api_key", return_value=False):
with pytest.raises(exceptions.AuthenticationFailed, match="Invalid API Key"):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_inactive_user(rf):
from animetix.models import Profile
profile = _make_profile(tier="pro", is_active=False)
request = rf.get("/x", HTTP_X_API_KEY=f"ax_pro_{profile.pk}_secret")
with patch.object(Profile, "check_api_key", return_value=True):
with pytest.raises(
exceptions.AuthenticationFailed, match="account is disabled"
):
_dev_auth().authenticate(request)
@pytest.mark.django_db
def test_dev_auth_success(rf):
from animetix.models import Profile
profile = _make_profile(tier="pro")
api_key = f"ax_pro_{profile.pk}_secret"
request = rf.get("/x", HTTP_X_API_KEY=api_key)
with patch.object(Profile, "check_api_key", return_value=True):
user, returned_key = _dev_auth().authenticate(request)
assert user.pk == profile.user.pk
assert returned_key == api_key