clanker / tests /test_auth.py
deucebucket's picture
M2.5: recognition-only HF login (anonymous-first, hashed ids)
2bb8b45 verified
Raw
History Blame Contribute Delete
8.41 kB
"""Tests for HF OAuth flow — anonymous-first, recognition-only.
Strategy: build a minimal Starlette/FastAPI app with SessionMiddleware so
request.session works, then assert privacy and graceful-degradation invariants.
No real network or HF OAuth server is involved.
"""
import os
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.sessions import SessionMiddleware
from app.identity import hash_uid
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app():
"""Tiny FastAPI app that mounts the auth router + SessionMiddleware."""
from app.auth import make_auth_router
test_app = FastAPI()
test_app.add_middleware(SessionMiddleware, secret_key="test-secret")
test_app.include_router(make_auth_router())
return test_app
# ---------------------------------------------------------------------------
# /me — signed_in boolean only
# ---------------------------------------------------------------------------
def test_me_returns_false_when_no_session(monkeypatch):
"""/me must return signed_in:false when there is no session uid."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
client = TestClient(_make_test_app())
r = client.get("/me")
assert r.status_code == 200
body = r.json()
assert body == {"signed_in": False}
def test_me_never_exposes_username(monkeypatch):
"""Response body must not contain username or sub fields."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
client = TestClient(_make_test_app())
r = client.get("/me")
body = r.json()
assert "username" not in body
assert "sub" not in body
assert "preferred_username" not in body
# ---------------------------------------------------------------------------
# /login — graceful when OAuth is unconfigured
# ---------------------------------------------------------------------------
def test_login_no_oauth_env_does_not_500(monkeypatch):
"""/login must NOT crash (500) when OAUTH_CLIENT_ID is absent.
A deliberate 503 'service unavailable' is the correct friendly response."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
client = TestClient(_make_test_app(), raise_server_exceptions=False)
r = client.get("/login", follow_redirects=False)
# 500 means unhandled exception; 503 is the intentional "unavailable" signal.
assert r.status_code != 500, f"Got 500 — route must not crash"
def test_login_unavailable_body_or_redirect(monkeypatch):
"""When unconfigured, /login returns a meaningful message or redirect."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
client = TestClient(_make_test_app(), raise_server_exceptions=False)
r = client.get("/login", follow_redirects=False)
if r.status_code == 200:
# JSON or HTML body should mention unavailability
assert r.text # non-empty
elif r.status_code in (302, 303, 307, 308):
pass # redirect is fine
elif r.status_code == 503:
# Friendly "service unavailable" — valid anonymous-first response
body = r.json()
assert "error" in body or "detail" in body
else:
# Any other non-500 is acceptable
assert r.status_code != 500
# ---------------------------------------------------------------------------
# /auth/callback — hash_uid, privacy
# ---------------------------------------------------------------------------
def test_callback_sets_hashed_uid_not_raw_sub(monkeypatch):
"""Callback must store hash_uid(sub) in session, never the raw sub."""
fake_sub = "hf|user-sub-test-9999"
expected_uid = hash_uid(fake_sub)
# Patch make_oauth so it returns a fake oauth object whose authorize_access_token
# returns a token dict, and parse_id_token / userinfo returns claims with 'sub'.
import app.auth as auth_module
class FakeHFClient:
async def authorize_access_token(self, request):
return {"access_token": "tok", "userinfo": {"sub": fake_sub}}
class FakeOAuth:
def hf(self, request): # authlib attribute access pattern
return FakeHFClient()
# Replace module-level oauth with our fake
original_oauth = auth_module._oauth
monkeypatch.setattr(auth_module, "_oauth", FakeOAuth())
test_app = FastAPI()
test_app.add_middleware(SessionMiddleware, secret_key="test-secret")
test_app.include_router(auth_module.make_auth_router())
# We need to capture the session after callback. Use a sentinel endpoint.
@test_app.get("/session-dump")
def session_dump(request):
from fastapi.responses import JSONResponse
return JSONResponse(dict(request.session))
client = TestClient(test_app, raise_server_exceptions=False)
r = client.get("/auth/callback", follow_redirects=False)
# Should redirect to / after success (or possibly fail due to missing state cookie;
# what we care about: if it did set a uid, it must be the hashed one)
# Try reading the session via the dump endpoint
dump = client.get("/session-dump").json()
if "uid" in dump:
assert dump["uid"] == expected_uid, "uid in session must be hash_uid(sub)"
assert fake_sub not in dump.values(), "raw sub must NOT be stored in session"
assert fake_sub not in str(dump), "raw sub must not appear anywhere in session dump"
monkeypatch.setattr(auth_module, "_oauth", original_oauth)
def test_callback_never_stores_username(monkeypatch):
"""Session must never contain preferred_username or name."""
import app.auth as auth_module
fake_sub = "hf|privacy-test-user"
class FakeHFClient:
async def authorize_access_token(self, request):
return {
"access_token": "tok",
"userinfo": {
"sub": fake_sub,
"preferred_username": "badactor",
"name": "Bad Actor",
},
}
class FakeOAuth:
def hf(self, request):
return FakeHFClient()
original_oauth = auth_module._oauth
monkeypatch.setattr(auth_module, "_oauth", FakeOAuth())
test_app = FastAPI()
test_app.add_middleware(SessionMiddleware, secret_key="test-secret")
test_app.include_router(auth_module.make_auth_router())
@test_app.get("/session-dump")
def session_dump(request):
from fastapi.responses import JSONResponse
return JSONResponse(dict(request.session))
client = TestClient(test_app, raise_server_exceptions=False)
client.get("/auth/callback", follow_redirects=False)
dump = client.get("/session-dump").json()
assert "preferred_username" not in dump
assert "username" not in dump
assert "name" not in dump
assert "badactor" not in str(dump)
monkeypatch.setattr(auth_module, "_oauth", original_oauth)
# ---------------------------------------------------------------------------
# /logout
# ---------------------------------------------------------------------------
def test_logout_clears_session_and_redirects(monkeypatch):
"""/logout clears the session and redirects (3xx)."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
client = TestClient(_make_test_app(), raise_server_exceptions=False)
r = client.get("/logout", follow_redirects=False)
assert r.status_code in (302, 303, 307, 308)
# ---------------------------------------------------------------------------
# make_oauth factory
# ---------------------------------------------------------------------------
def test_make_oauth_returns_none_when_unconfigured(monkeypatch):
"""make_oauth() must return None (not raise) when OAUTH_CLIENT_ID is absent."""
monkeypatch.delenv("OAUTH_CLIENT_ID", raising=False)
from app.auth import make_oauth
result = make_oauth()
assert result is None
def test_make_oauth_returns_object_when_configured(monkeypatch):
"""make_oauth() returns an OAuth object when env vars are present."""
monkeypatch.setenv("OAUTH_CLIENT_ID", "fake-client-id")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "fake-secret")
monkeypatch.setenv("OPENID_PROVIDER_URL", "https://huggingface.co")
from app.auth import make_oauth
result = make_oauth()
assert result is not None