Skip to content

Commit c0c83e5

Browse files
vitoficoclaude
andcommitted
✅ test(server): CWA-proxy auth tests + Basic-auth integration
conftest fakes CWA via httpx.MockTransport. test_auth covers cache hit/miss/TTL/503/non-Basic. Integration tests use Basic auth helpers keyed by the cwa_users fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9067d2d commit c0c83e5

5 files changed

Lines changed: 163 additions & 197 deletions

File tree

server/opds_sync/core/auth.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import logging
44
import time
55
from collections import OrderedDict
6-
from typing import Annotated, Callable
6+
from collections.abc import Callable
7+
from typing import Annotated
78

89
import httpx
910
from fastapi import Depends, HTTPException, Request, status
@@ -45,7 +46,9 @@ def __init__(
4546
async def validate(self, auth_header: str) -> str:
4647
"""Returns the user_id (lowercased CWA username) or raises HTTPException(401/503)."""
4748
if not auth_header.lower().startswith("basic "):
48-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="basic auth required")
49+
raise HTTPException(
50+
status_code=status.HTTP_401_UNAUTHORIZED, detail="basic auth required"
51+
)
4952

5053
b64 = auth_header[6:].strip()
5154
key = hashlib.sha256(b64.encode("ascii")).digest()
@@ -56,7 +59,9 @@ async def validate(self, auth_header: str) -> str:
5659
self._cache.move_to_end(key)
5760
if cached.is_valid:
5861
return cached.user_id # type: ignore[return-value]
59-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
62+
raise HTTPException(
63+
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials"
64+
)
6065

6166
try:
6267
resp = await self._client.get(

server/tests/conftest.py

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import time
1+
import base64
22
from collections.abc import AsyncIterator, Iterator
33

4-
import jwt
4+
import httpx
55
import pytest
66
from alembic import command
77
from alembic.config import Config as AlembicConfig
8-
from cryptography.hazmat.primitives import serialization
9-
from cryptography.hazmat.primitives.asymmetric import rsa
108
from sqlalchemy.ext.asyncio import (
119
AsyncEngine,
1210
AsyncSession,
@@ -33,7 +31,7 @@ def _key(item):
3331
@pytest.fixture(scope="session")
3432
def postgres_url() -> Iterator[str]:
3533
with PostgresContainer("postgres:16-alpine") as pg:
36-
sync_url = pg.get_connection_url() # postgresql+psycopg2://...
34+
sync_url = pg.get_connection_url()
3735
async_url = sync_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://")
3836
yield async_url
3937

@@ -60,72 +58,64 @@ async def session(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
6058
await s.rollback()
6159

6260

63-
@pytest.fixture(scope="session")
64-
def signing_key():
65-
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
66-
priv = key.private_bytes(
67-
encoding=serialization.Encoding.PEM,
68-
format=serialization.PrivateFormat.PKCS8,
69-
encryption_algorithm=serialization.NoEncryption(),
70-
)
71-
pub = key.public_key()
72-
return priv, pub
61+
# ---- CWA mock --------------------------------------------------------------
7362

7463

7564
@pytest.fixture
76-
def issuer() -> str:
77-
return "https://test-iss.example/"
65+
def cwa_users() -> dict[str, str]:
66+
"""Mutable per-test dict of valid CWA username → password."""
67+
return {"alice": "alicepass", "bob": "bobpass"}
7868

7969

8070
@pytest.fixture
81-
def audience() -> str:
82-
return "test-aud"
71+
def cwa_transport(cwa_users: dict[str, str]) -> httpx.MockTransport:
72+
def handler(request: httpx.Request) -> httpx.Response:
73+
if not request.url.path.endswith("/opds"):
74+
return httpx.Response(404)
75+
auth = request.headers.get("authorization", "")
76+
if not auth.lower().startswith("basic "):
77+
return httpx.Response(401)
78+
try:
79+
decoded = base64.b64decode(auth[6:].strip()).decode("utf-8")
80+
user, pw = decoded.split(":", 1)
81+
except Exception:
82+
return httpx.Response(401)
83+
if cwa_users.get(user) == pw:
84+
return httpx.Response(200, text="<feed/>")
85+
return httpx.Response(401)
86+
87+
return httpx.MockTransport(handler)
8388

8489

8590
@pytest.fixture
86-
def make_token(signing_key, issuer, audience):
87-
priv, _ = signing_key
88-
89-
def _make(sub: str, ttl: int = 60, **extra) -> str:
90-
now = int(time.time())
91-
claims = {
92-
"sub": sub,
93-
"iss": issuer,
94-
"aud": audience,
95-
"iat": now,
96-
"nbf": now,
97-
"exp": now + ttl,
98-
**extra,
99-
}
100-
return jwt.encode(claims, priv, algorithm="RS256", headers={"kid": "test-key"})
91+
def basic_header():
92+
def _make(user: str, pw: str) -> str:
93+
token = base64.b64encode(f"{user}:{pw}".encode()).decode("ascii")
94+
return f"Basic {token}"
10195

10296
return _make
10397

10498

10599
@pytest.fixture
106-
def app_under_test(postgres_url, alembic_upgrade, monkeypatch, signing_key, issuer, audience):
107-
"""A FastAPI app wired to the test Postgres + a static JWKS fetcher."""
100+
def app_under_test(postgres_url, alembic_upgrade, monkeypatch, cwa_transport):
101+
"""A FastAPI app wired to the test Postgres + a mock CWA transport."""
108102
monkeypatch.setenv("OPDS_SYNC_DATABASE_URL", postgres_url)
109-
monkeypatch.setenv("OPDS_SYNC_AUTHENTIK_ISSUER", issuer)
110-
monkeypatch.setenv("OPDS_SYNC_AUTHENTIK_AUDIENCE", audience)
103+
monkeypatch.setenv("OPDS_SYNC_CWA_BASE_URL", "http://test-cwa")
111104

112-
# Bust the lru_cache so the env vars take effect.
113105
from opds_sync.config import get_settings
114106

115107
get_settings.cache_clear()
116108

117-
from opds_sync.core.auth import JwksFetcher, JwtValidator
109+
from opds_sync.core.auth import CalibreAuthValidator
118110
from opds_sync.main import create_app
119111

120-
_, pub = signing_key
121-
122-
class StaticJwks(JwksFetcher):
123-
async def get_signing_key(self, kid): # type: ignore[override]
124-
class _K:
125-
key = pub # noqa: E701
126-
127-
return _K()
128-
129112
app = create_app()
130-
app.state.jwt_validator = JwtValidator(jwks=StaticJwks(), issuer=issuer, audience=audience)
113+
test_client = httpx.AsyncClient(
114+
transport=cwa_transport, base_url="http://test-cwa", timeout=3.0
115+
)
116+
app.state.httpx_client = test_client
117+
app.state.auth_validator = CalibreAuthValidator(
118+
client=test_client,
119+
cwa_base_url="http://test-cwa",
120+
)
131121
return app
Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
11
from httpx import ASGITransport, AsyncClient
22

33

4-
async def test_healthz_returns_200():
5-
from opds_sync.main import create_app
6-
7-
app = create_app()
8-
transport = ASGITransport(app=app)
4+
async def test_healthz_returns_200(app_under_test):
5+
transport = ASGITransport(app=app_under_test)
96
async with AsyncClient(transport=transport, base_url="http://test") as client:
107
r = await client.get("/sync/v1/healthz")
118
assert r.status_code == 200
129
assert r.json() == {"status": "ok"}
1310

1411

15-
async def test_readyz_returns_200_when_db_reachable(postgres_url, alembic_upgrade, monkeypatch):
16-
monkeypatch.setenv("OPDS_SYNC_DATABASE_URL", postgres_url)
17-
# Bust the lru_cache on get_settings so the new env var is honoured
18-
from opds_sync.config import get_settings
19-
20-
get_settings.cache_clear()
21-
22-
from opds_sync.main import create_app
23-
24-
app = create_app()
25-
transport = ASGITransport(app=app)
12+
async def test_readyz_returns_200_when_db_reachable(app_under_test):
13+
transport = ASGITransport(app=app_under_test)
2614
async with AsyncClient(transport=transport, base_url="http://test") as client:
2715
r = await client.get("/sync/v1/readyz")
2816
assert r.status_code == 200

server/tests/integration/test_progress.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import base64
2+
13
from httpx import ASGITransport, AsyncClient
24

35

4-
def _bearer(token: str) -> dict[str, str]:
5-
return {"Authorization": f"Bearer {token}"}
6+
def _basic(user: str, pw: str) -> dict[str, str]:
7+
token = base64.b64encode(f"{user}:{pw}".encode()).decode("ascii")
8+
return {"Authorization": f"Basic {token}"}
69

710

8-
async def test_post_progress_creates_document_and_progress(app_under_test, make_token):
11+
async def test_post_progress_creates_document_and_progress(app_under_test):
912
transport = ASGITransport(app=app_under_test)
10-
headers = _bearer(make_token("alice"))
13+
headers = _basic("alice", "alicepass")
1114
body = {
1215
"items": [
1316
{
@@ -27,9 +30,9 @@ async def test_post_progress_creates_document_and_progress(app_under_test, make_
2730
assert data["results"][0]["server_client_updated_at"] == "2026-05-05T12:00:00+00:00"
2831

2932

30-
async def test_post_progress_lww_keeps_newer(app_under_test, make_token):
33+
async def test_post_progress_lww_keeps_newer(app_under_test):
3134
transport = ASGITransport(app=app_under_test)
32-
headers = _bearer(make_token("alice"))
35+
headers = _basic("alice", "alicepass")
3336
base = {
3437
"document": {"metadata_id": "abc", "content_hash": "hash1"},
3538
"locator": "loc",
@@ -67,12 +70,12 @@ async def test_post_progress_lww_keeps_newer(app_under_test, make_token):
6770
assert items[0]["percent"] == 0.5
6871

6972

70-
async def test_get_progress_filters_by_user(app_under_test, make_token):
73+
async def test_get_progress_filters_by_user(app_under_test):
7174
transport = ASGITransport(app=app_under_test)
7275
async with AsyncClient(transport=transport, base_url="http://test") as c:
7376
await c.post(
7477
"/sync/v1/progress",
75-
headers=_bearer(make_token("alice")),
78+
headers=_basic("alice", "alicepass"),
7679
json={
7780
"items": [
7881
{
@@ -85,7 +88,7 @@ async def test_get_progress_filters_by_user(app_under_test, make_token):
8588
},
8689
)
8790
r = await c.get(
88-
"/sync/v1/progress?since=2026-01-01T00:00:00+00:00", headers=_bearer(make_token("bob"))
91+
"/sync/v1/progress?since=2026-01-01T00:00:00+00:00", headers=_basic("bob", "bobpass")
8992
)
9093
assert r.status_code == 200
9194
assert r.json()["items"] == []

0 commit comments

Comments
 (0)