Spaces:
Running
Running
| """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. | |
| 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()) | |
| 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 | |