"""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