"""Unit tests for the HF token resolver dependency in ``app.py``. The resolver is the first line of defence for authenticated endpoints; a regression here can silently re-introduce token leaks or accept malformed Authorization schemes. No test infrastructure exists on this HF Space yet. Run manually with:: pip install pytest httpx fastapi python -m pytest test_resolver.py -v Keep these tests here so whoever adds CI can wire them up without writing the suite from scratch. """ from __future__ import annotations from typing import Optional from unittest.mock import MagicMock import pytest from fastapi import HTTPException from app import _resolve_hf_token def _req(ip: str = "1.2.3.4") -> MagicMock: """Build a minimal ``Request``-shaped mock with .client.host.""" request = MagicMock() request.client.host = ip return request def _run( authorization: Optional[str] = None, token: str = "", ip: str = "1.2.3.4", ) -> str: """Invoke the resolver synchronously for a given input.""" import asyncio return asyncio.run(_resolve_hf_token(_req(ip), authorization, token)) # ---- Header form (preferred) ---- def test_bearer_header_is_accepted(): assert _run(authorization="Bearer hf_abc123") == "hf_abc123" def test_bearer_header_case_insensitive_scheme(): assert _run(authorization="bearer hf_abc123") == "hf_abc123" assert _run(authorization="BEARER hf_abc123") == "hf_abc123" def test_bearer_header_trims_whitespace(): assert _run(authorization="Bearer hf_abc123 ") == "hf_abc123" def test_bearer_header_empty_token_is_rejected(): """'Bearer' with nothing after must 401, not return ''.""" with pytest.raises(HTTPException) as exc: _run(authorization="Bearer") assert exc.value.status_code == 401 def test_bearer_header_whitespace_only_token_is_rejected(): with pytest.raises(HTTPException) as exc: _run(authorization="Bearer ") assert exc.value.status_code == 401 # ---- Unknown / malformed Authorization schemes ---- def test_basic_auth_scheme_is_rejected(): """Basic auth must NOT leak through as a bare-string token.""" with pytest.raises(HTTPException) as exc: _run(authorization="Basic dXNlcjpwdw==") assert exc.value.status_code == 401 assert "Bearer" in exc.value.detail def test_digest_scheme_is_rejected(): with pytest.raises(HTTPException) as exc: _run(authorization="Digest username=foo") assert exc.value.status_code == 401 def test_bare_token_in_header_is_rejected(): """A raw token with no scheme is not RFC 6750 shaped — reject it.""" with pytest.raises(HTTPException) as exc: _run(authorization="hf_abc123") assert exc.value.status_code == 401 # ---- Query-string fallback ---- def test_query_token_is_accepted_and_deprecation_is_logged_once(caplog): # Clear the sampler state between tests — the module-level set # persists within a process. from app import _deprecation_warned_ips _deprecation_warned_ips.clear() with caplog.at_level("WARNING"): assert _run(token="hf_legacy") == "hf_legacy" warnings = [r for r in caplog.records if "deprecation" in r.message] assert len(warnings) == 1 # Second call from the same IP does NOT re-log. caplog.clear() with caplog.at_level("WARNING"): assert _run(token="hf_legacy") == "hf_legacy" warnings = [r for r in caplog.records if "deprecation" in r.message] assert len(warnings) == 0 # But a different IP triggers its own one-shot warning. caplog.clear() with caplog.at_level("WARNING"): assert _run(token="hf_legacy", ip="9.9.9.9") == "hf_legacy" warnings = [r for r in caplog.records if "deprecation" in r.message] assert len(warnings) == 1 # ---- Precedence (both forms present) ---- def test_header_takes_precedence_over_query(): """If both are sent, the Authorization header wins and the query is ignored without logging deprecation (header users are compliant).""" assert _run(authorization="Bearer from_header", token="from_query") == "from_header" # ---- Neither form present ---- def test_missing_both_raises_401(): with pytest.raises(HTTPException) as exc: _run() assert exc.value.status_code == 401 assert "Missing" in exc.value.detail