diff --git a/main.py b/main.py index c14f60f..728981d 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,14 @@ import logging import os import threading +from urllib.parse import quote, urlsplit + from fastapi import FastAPI, HTTPException, Query import requests import humanize from datetime import datetime, timezone +from math import isfinite +from pydantic import BaseModel, ConfigDict, StrictFloat, StrictInt, ValidationError, field_validator # Print ASCII skull skull = r""" @@ -22,7 +26,7 @@ ⠄⠄⠂⠄⠄⠨⣔⡝⠼⡄⠂⣦⡆⣿⣲⠐⠑⠁⠄⠃ ⠄⠄⠄⠄⠄⠄⠃⢫⢛⣙⡊⣜⣏⡝⣝⠆ ⠄⠄⠄⠄⠄⠄⠈⠈⠁⠁⠁⠈⠈⠊ - + RSS api - Starting... """ print(skull) @@ -41,6 +45,62 @@ AUTH_TOKEN_LOCK = threading.Lock() +class FreshRSSOrigin(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + title: str | None = None + + +class FreshRSSAlternate(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + href: str | None = None + + +class FreshRSSItem(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + title: str | None = None + origin: FreshRSSOrigin | None = None + published: StrictInt | StrictFloat | None = None + alternate: list[FreshRSSAlternate] | None = None + + @field_validator("published") + @classmethod + def validate_published_timestamp(cls, value): + if value is None: + return value + try: + if not isfinite(value): + raise ValueError("timestamp must be finite") + datetime.fromtimestamp(value, timezone.utc) + except OverflowError: + raise ValueError("timestamp is outside the supported range") + except (OSError, ValueError) as exc: + raise ValueError("timestamp is outside the supported range") from exc + return value + + +class FreshRSSResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + items: list[FreshRSSItem] + + +def validate_freshrss_response(raw): + """Validate FreshRSS data, rejecting the whole malformed response. + + A response with any malformed item produces a sanitized 502 rather than a + partial result. This keeps upstream data-quality failures visible and gives + callers consistent all-or-nothing results. + """ + try: + return FreshRSSResponse.model_validate(raw) + except ValidationError as exc: + logging.warning("FreshRSS returned an invalid unread response: %s", exc) + raise HTTPException(status_code=502, detail="FreshRSS returned an invalid unread response") from exc + + def get_greader_token(): global AUTH_TOKEN with AUTH_TOKEN_LOCK: @@ -57,7 +117,12 @@ def get_greader_token(): logging.warning("FreshRSS login request failed: %s", exc) raise HTTPException(status_code=502, detail="FreshRSS login request failed") from exc if res.status_code != 200: - logging.warning("FreshRSS login failed (status %d): %s", res.status_code, res.text) + upstream_host = urlsplit(FRESHRSS_HOST).hostname or "unknown" + logging.warning( + "FreshRSS login failed (status=%d, upstream_host=%s)", + res.status_code, + upstream_host, + ) raise HTTPException(status_code=502, detail=f"FreshRSS login failed with status {res.status_code}") # Find and extract 'Auth=' line for line in res.text.splitlines(): @@ -75,8 +140,14 @@ def request_unread(token, n, category): "output": "json", "n": n, } - category_label = category if isinstance(category, str) and category else None - stream_id = f"user/-/label/{category_label}" if category_label else "user/-/state/com.google/reading-list" + category_label = category.strip() if isinstance(category, str) else None + if category_label in (".", ".."): + category_label = None # dot-only labels resolve as path traversal; use reading-list + stream_id = ( + f"user/-/label/{quote(category_label, safe='')}" + if category_label + else "user/-/state/com.google/reading-list" + ) url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}" return requests.get(url, headers=headers, params=params, timeout=10) @@ -89,7 +160,7 @@ def health(): @app.get("/freshrss/unread") def freshrss_unread( n: int = Query(default=10, ge=1, le=100), - category: str | None = Query(default=None), + category: str | None = Query(default=None, max_length=200), ): token = get_greader_token() try: @@ -106,25 +177,26 @@ def freshrss_unread( except requests.RequestException as exc: logging.warning("FreshRSS unread request failed: %s", exc) raise HTTPException(status_code=502, detail="FreshRSS unread request failed") from exc + response = validate_freshrss_response(raw) items = [] now = datetime.now(timezone.utc) - for entry in raw.get("items", []): - published_ts = entry.get("published") + for entry in response.items: + published_ts = entry.published if published_ts is None: continue published_dt = datetime.fromtimestamp(published_ts, timezone.utc) published_str = humanize.naturaltime(now - published_dt) - alternates = entry.get("alternate") or [] - item_url = alternates[0].get("href", "") if alternates else "" + alternates = entry.alternate or [] + item_url = alternates[0].href or "" if alternates else "" items.append( { - "title": entry.get("title"), - "feed": entry.get("origin", {}).get("title"), - "published": entry.get("published"), + "title": entry.title, + "feed": entry.origin.title if entry.origin else None, + "published": published_ts, "url": item_url, - "display": f"{entry.get('title')} • {published_str}", + "display": f"{entry.title} • {published_str}", } ) return items diff --git a/pyproject.toml b/pyproject.toml index 9fa2bfc..7b3e6d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn>=0.32.0", "humanize>=4.12.3", + "pydantic>=2.0", "requests>=2.32.4", ] diff --git a/tests/test_main.py b/tests/test_main.py index 7106dce..0c55507 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,9 +6,9 @@ from pathlib import Path import pytest +import requests from fastapi import HTTPException from fastapi.testclient import TestClient -import requests ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: @@ -35,225 +35,290 @@ def json(self): def raise_for_status(self): if self._raise_error: raise self._raise_error - if self.status_code >= 400: - raise requests.HTTPError(f"{self.status_code} upstream error") -def import_app(monkeypatch, env=None): - for name in REQUIRED_ENV: - monkeypatch.delenv(name, raising=False) - for name, value in (env or REQUIRED_ENV).items(): +@pytest.fixture +def main_module(monkeypatch): + """Import the application only after installing a deterministic environment.""" + for name, value in REQUIRED_ENV.items(): monkeypatch.setenv(name, value) sys.modules.pop("main", None) - return importlib.import_module("main") - - -def test_import_requires_freshrss_environment(monkeypatch): - for name in REQUIRED_ENV: - monkeypatch.delenv(name, raising=False) + module = importlib.import_module("main") + yield module sys.modules.pop("main", None) - with pytest.raises(RuntimeError) as excinfo: - importlib.import_module("main") - - message = str(excinfo.value) - assert "FRESHRSS_HOST" in message - assert "FRESHRSS_USER" in message - assert "FRESHRSS_PASS" in message +@pytest.fixture +def test_client(main_module): + with TestClient(main_module.app) as client: + yield client -def test_health_endpoint_returns_ok(monkeypatch): - main = import_app(monkeypatch) - assert main.health() == {"status": "ok"} - assert any(route.path == "/health" for route in main.app.routes) - - -def test_get_greader_token_logs_in_once_and_caches_token(monkeypatch): - main = import_app(monkeypatch) - calls = [] +def install_freshrss_transport(monkeypatch, main_module, *, payload=None): + """Stub requests at the FreshRSS boundary, leaving the HTTP app intact.""" + calls = {"post": [], "get": []} def fake_post(url, data, timeout): - calls.append({"url": url, "data": data, "timeout": timeout}) - return FakeResponse(text="SID=ignored\nAuth= cached-token \n") - - monkeypatch.setattr(main.requests, "post", fake_post) - - assert main.get_greader_token() == "cached-token" - assert main.get_greader_token() == "cached-token" - assert calls == [ - { - "url": "https://freshrss.example.test/api/greader.php/accounts/ClientLogin", - "data": {"Email": "reader", "Passwd": "secret"}, - "timeout": 10, - } - ] - + calls["post"].append({"url": url, "data": data, "timeout": timeout}) + return FakeResponse(text="SID=ignored\nAuth= transport-token \n") -def test_get_greader_token_rejects_failed_login(monkeypatch): - main = import_app(monkeypatch) - monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(status_code=403, text="nope")) + def fake_get(url, headers, params, timeout): + calls["get"].append( + {"url": url, "headers": headers, "params": params, "timeout": timeout} + ) + return FakeResponse(payload=payload or {"items": []}) - with pytest.raises(HTTPException) as excinfo: - main.get_greader_token() + monkeypatch.setattr(main_module.requests, "post", fake_post) + monkeypatch.setattr(main_module.requests, "get", fake_get) + return calls - assert excinfo.value.status_code == 502 - assert "FreshRSS login failed with status 403" == excinfo.value.detail +# ── Environment / import ────────────────────────────────────────────── -def test_get_greader_token_rejects_missing_auth_line(monkeypatch): - main = import_app(monkeypatch) - monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(text="SID=only")) - with pytest.raises(HTTPException) as excinfo: - main.get_greader_token() +def test_import_requires_freshrss_environment(monkeypatch): + for name in REQUIRED_ENV: + monkeypatch.delenv(name, raising=False) + sys.modules.pop("main", None) - assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "Auth token not found in FreshRSS response" + with pytest.raises(RuntimeError) as excinfo: + importlib.import_module("main") + assert all(name in str(excinfo.value) for name in REQUIRED_ENV) -def test_get_greader_token_wraps_request_failures(monkeypatch): - main = import_app(monkeypatch) - def fake_post(*args, **kwargs): - raise requests.Timeout("slow upstream") +# ── Health endpoint ─────────────────────────────────────────────────── - monkeypatch.setattr(main.requests, "post", fake_post) - with pytest.raises(HTTPException) as excinfo: - main.get_greader_token() +def test_health_endpoint_returns_json_over_http(test_client): + response = test_client.get("/health") - assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "FreshRSS login request failed" + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "ok"} -def test_freshrss_unread_fetches_reading_list_and_shapes_items(monkeypatch): - main = import_app(monkeypatch) - monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") - captured = {} +# ── Unread endpoint (via TestClient) ────────────────────────────────── - def fake_get(url, headers, params, timeout): - captured.update({"url": url, "headers": headers, "params": params, "timeout": timeout}) - return FakeResponse( - payload={ - "items": [ - { - "title": "Release shipped", - "origin": {"title": "GitHub Releases"}, - "published": 1700000000, - "alternate": [{"href": "https://example.test/release"}], - }, - { - "title": "Missing timestamp is ignored", - "origin": {"title": "Bad Feed"}, - }, - ] - } - ) - monkeypatch.setattr(main.requests, "get", fake_get) +def test_unread_uses_default_query_and_authenticates_once( + test_client, monkeypatch, main_module +): + calls = install_freshrss_transport(monkeypatch, main_module) - result = main.freshrss_unread(n=5) + first = test_client.get("/freshrss/unread") + second = test_client.get("/freshrss/unread") - assert captured == { + assert first.status_code == second.status_code == 200 + assert first.json() == second.json() == [] + assert calls["post"] == [ + { + "url": "https://freshrss.example.test/api/greader.php/accounts/ClientLogin", + "data": {"Email": "reader", "Passwd": "secret"}, + "timeout": 10, + } + ] + assert len(calls["get"]) == 2 + assert calls["get"][0] == { "url": "https://freshrss.example.test/api/greader.php/reader/api/0/stream/contents/user/-/state/com.google/reading-list", - "headers": {"Authorization": "GoogleLogin auth=token-123"}, - "params": {"xt": "user/-/state/com.google/read", "output": "json", "n": 5}, + "headers": {"Authorization": "GoogleLogin auth=transport-token"}, + "params": { + "xt": "user/-/state/com.google/read", + "output": "json", + "n": 10, + }, "timeout": 10, } - assert len(result) == 1 - assert result[0]["title"] == "Release shipped" - assert result[0]["feed"] == "GitHub Releases" - assert result[0]["published"] == 1700000000 - assert result[0]["url"] == "https://example.test/release" - assert result[0]["display"].startswith("Release shipped • ") -@pytest.mark.parametrize("n", [0, 101]) -def test_freshrss_unread_rejects_out_of_range_n_without_contacting_freshrss(monkeypatch, n): - main = import_app(monkeypatch) +def test_unread_accepts_explicit_query_and_encoded_category( + test_client, monkeypatch, main_module +): + calls = install_freshrss_transport(monkeypatch, main_module) - def unexpected_request(*args, **kwargs): - pytest.fail("FreshRSS must not be contacted for an invalid n value") + response = test_client.get( + "/freshrss/unread", params={"n": "25", "category": "Tech & Science/News"} + ) - monkeypatch.setattr(main.requests, "post", unexpected_request) - monkeypatch.setattr(main.requests, "get", unexpected_request) + assert response.status_code == 200 + request = calls["get"][0] + assert request["params"]["n"] == 25 + assert request["url"].endswith("/user/-/label/Tech%20%26%20Science%2FNews") + + +@pytest.mark.parametrize( + ("category", "expected_stream"), + [ + (" Tech News ", "user/-/label/Tech%20News"), + ("Tech/News", "user/-/label/Tech%2FNews"), + ("100% News", "user/-/label/100%25%20News"), + ("What?", "user/-/label/What%3F"), + ("日本語", "user/-/label/%E6%97%A5%E6%9C%AC%E8%AA%9E"), + (" \t\n ", "user/-/state/com.google/reading-list"), + (".", "user/-/state/com.google/reading-list"), + ("..", "user/-/state/com.google/reading-list"), + ], +) +def test_freshrss_unread_normalizes_and_encodes_category( + test_client, monkeypatch, main_module, category, expected_stream +): + calls = install_freshrss_transport(monkeypatch, main_module) + + response = test_client.get("/freshrss/unread", params={"category": category}) - response = TestClient(main.app).get("/freshrss/unread", params={"n": n}) + assert response.status_code == 200 + assert calls["get"][0]["url"] == ( + "https://freshrss.example.test/api/greader.php/reader/api/0/stream/contents/" + f"{expected_stream}" + ) - assert response.status_code == 422 +@pytest.mark.parametrize("value", ["not-a-number", "1.5", "", "0", "-1"]) +def test_unread_rejects_invalid_counts_without_contacting_upstream( + value, test_client, monkeypatch, main_module +): + calls = install_freshrss_transport(monkeypatch, main_module) -@pytest.mark.parametrize("n", [1, 100]) -def test_freshrss_unread_accepts_boundary_n_values(monkeypatch, n): - main = import_app(monkeypatch) - monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") - captured = {} + response = test_client.get("/freshrss/unread", params={"n": value}) - def fake_get(url, headers, params, timeout): - captured["n"] = params["n"] - return FakeResponse(payload={"items": []}) + assert response.status_code == 422 + assert response.json()["detail"] + assert calls == {"post": [], "get": []} + + +def test_unread_response_json_structure(test_client, monkeypatch, main_module): + calls = install_freshrss_transport( + monkeypatch, + main_module, + payload={ + "items": [ + { + "title": "Release shipped", + "origin": {"title": "GitHub Releases"}, + "published": 1700000000, + "alternate": [{"href": "https://example.test/release"}], + }, + {"title": "No timestamp", "origin": {"title": "Bad Feed"}}, + ] + }, + ) + + response = test_client.get("/freshrss/unread", params={"n": 5}) - monkeypatch.setattr(main.requests, "get", fake_get) + assert response.status_code == 200 + assert calls["get"][0]["params"]["n"] == 5 + body = response.json() + assert len(body) == 1 + assert set(body[0]) == {"title", "feed", "published", "url", "display"} + assert body[0] == { + "title": "Release shipped", + "feed": "GitHub Releases", + "published": 1700000000, + "url": "https://example.test/release", + "display": body[0]["display"], + } + assert body[0]["display"].startswith("Release shipped • ") + + +def test_unread_serializes_upstream_failure_as_502( + test_client, monkeypatch, main_module +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse( + raise_error=requests.HTTPError("500 Server Error") + ), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS unread request failed"} + + +# ── Token / login unit tests ────────────────────────────────────────── + + +def test_get_greader_token_rejects_failed_login_without_logging_secrets( + monkeypatch, caplog +): + """Verify that no credentials or response body leak into warning logs.""" + credentials = { + "FRESHRSS_HOST": "https://freshrss.example.test", + "FRESHRSS_USER": "sentinel-username", + "FRESHRSS_PASS": "sentinel-password", + } + for name, value in credentials.items(): + monkeypatch.setenv(name, value) + sys.modules.pop("main", None) + main = importlib.import_module("main") - response = TestClient(main.app).get("/freshrss/unread", params={"n": n}) + response_body = "sentinel-response-body\nAuth=sentinel-token" + monkeypatch.setattr( + main.requests, + "post", + lambda *args, **kwargs: FakeResponse(status_code=403, text=response_body), + ) - assert response.status_code == 200 - assert response.json() == [] - assert captured["n"] == n + with caplog.at_level("WARNING"): + with pytest.raises(HTTPException) as excinfo: + main.get_greader_token() + + assert excinfo.value.status_code == 502 + assert "FreshRSS login failed with status 403" == excinfo.value.detail + assert "status=403" in caplog.text + assert "upstream_host=freshrss.example.test" in caplog.text + for secret in (*credentials.values(), response_body, "sentinel-token"): + assert secret not in caplog.text + sys.modules.pop("main", None) -def test_freshrss_unread_scopes_to_category_and_handles_missing_url(monkeypatch): - main = import_app(monkeypatch) - monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") - captured = {} - def fake_get(url, headers, params, timeout): - captured.update({"url": url, "params": params}) - return FakeResponse( - payload={ - "items": [ - { - "title": "Category item", - "origin": {}, - "published": 1700000000, - "alternate": [], - } - ] - } - ) +def test_get_greader_token_rejects_missing_auth_line(monkeypatch, main_module): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="SID=only"), + ) - monkeypatch.setattr(main.requests, "get", fake_get) + with pytest.raises(HTTPException) as excinfo: + main_module.get_greader_token() - result = main.freshrss_unread(n=3, category="Tech") + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "Auth token not found in FreshRSS response" - assert captured["url"].endswith("/stream/contents/user/-/label/Tech") - assert captured["params"]["n"] == 3 - assert result[0]["feed"] is None - assert result[0]["url"] == "" +def test_get_greader_token_wraps_request_failures(monkeypatch, main_module): + def fake_post(*args, **kwargs): + raise requests.Timeout("slow upstream") -def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch): - main = import_app(monkeypatch) - monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") - upstream_error = requests.HTTPError("500 Server Error") - monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(raise_error=upstream_error)) + monkeypatch.setattr(main_module.requests, "post", fake_post) with pytest.raises(HTTPException) as excinfo: - main.freshrss_unread() + main_module.get_greader_token() assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "FreshRSS unread request failed" + assert excinfo.value.detail == "FreshRSS login request failed" + + +# ── Reauthentication ────────────────────────────────────────────────── -def test_freshrss_unread_reauthenticates_and_retries_once(monkeypatch): - main = import_app(monkeypatch) - main.AUTH_TOKEN = "expired-token" +def test_unread_reauthenticates_and_retries_once( + test_client, monkeypatch, main_module +): + main_module.AUTH_TOKEN = "expired-token" login_calls = [] get_calls = [] def fake_post(*args, **kwargs): - login_calls.append((args, kwargs)) + login_calls.append(1) return FakeResponse(text="Auth=fresh-token") def fake_get(url, headers, params, timeout): @@ -262,43 +327,52 @@ def fake_get(url, headers, params, timeout): return FakeResponse(status_code=401) return FakeResponse(payload={"items": []}) - monkeypatch.setattr(main.requests, "post", fake_post) - monkeypatch.setattr(main.requests, "get", fake_get) + monkeypatch.setattr(main_module.requests, "post", fake_post) + monkeypatch.setattr(main_module.requests, "get", fake_get) - assert main.freshrss_unread() == [] + response = test_client.get("/freshrss/unread") + + assert response.status_code == 200 + assert response.json() == [] assert get_calls == [ "GoogleLogin auth=expired-token", "GoogleLogin auth=fresh-token", ] assert len(login_calls) == 1 - assert main.AUTH_TOKEN == "fresh-token" + assert main_module.AUTH_TOKEN == "fresh-token" -def test_freshrss_unread_fails_after_single_reauthentication_retry(monkeypatch): - main = import_app(monkeypatch) - main.AUTH_TOKEN = "expired-token" +def test_unread_fails_after_single_reauthentication_retry( + test_client, monkeypatch, main_module +): + main_module.AUTH_TOKEN = "expired-token" get_calls = [] - monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(text="Auth=fresh-token")) + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=fresh-token"), + ) - def fake_get(*args, **kwargs): - get_calls.append(kwargs["headers"]["Authorization"]) + def fake_get(url, headers, params, timeout): + get_calls.append(headers["Authorization"]) return FakeResponse(status_code=403) - monkeypatch.setattr(main.requests, "get", fake_get) + monkeypatch.setattr(main_module.requests, "get", fake_get) - with pytest.raises(HTTPException) as excinfo: - main.freshrss_unread() + response = test_client.get("/freshrss/unread") - assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "FreshRSS unread request failed" + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} assert get_calls == [ "GoogleLogin auth=expired-token", "GoogleLogin auth=fresh-token", ] -def test_get_greader_token_is_concurrent_safe(monkeypatch): - main = import_app(monkeypatch) +# ── Concurrent safety ───────────────────────────────────────────────── + + +def test_get_greader_token_is_concurrent_safe(monkeypatch, main_module): login_calls = 0 calls_lock = threading.Lock() workers_ready = threading.Barrier(5) @@ -312,11 +386,156 @@ def fake_post(*args, **kwargs): def acquire_token(): workers_ready.wait() - return main.get_greader_token() + return main_module.get_greader_token() - monkeypatch.setattr(main.requests, "post", fake_post) + monkeypatch.setattr(main_module.requests, "post", fake_post) with ThreadPoolExecutor(max_workers=5) as executor: tokens = list(executor.map(lambda _: acquire_token(), range(5))) assert tokens == ["shared-token"] * 5 assert login_calls == 1 + + +# ── Pydantic response validation ────────────────────────────────────── + + +@pytest.mark.parametrize("payload", [[], "not an object", None]) +def test_unread_rejects_malformed_top_level_payload( + test_client, monkeypatch, main_module, payload +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + response_obj = FakeResponse(payload={}) + response_obj._payload = payload + monkeypatch.setattr( + main_module.requests, "get", lambda *args, **kwargs: response_obj + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} + + +@pytest.mark.parametrize("items", [None, {}, "not a list", ["not an object"]]) +def test_unread_rejects_invalid_items( + test_client, monkeypatch, main_module, items +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse(payload={"items": items}), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} + + +@pytest.mark.parametrize( + "invalid_field", + [ + {"origin": "not an object"}, + {"origin": []}, + {"alternate": "not a list"}, + {"alternate": {}}, + {"alternate": ["not an object"]}, + ], +) +def test_unread_rejects_malformed_item_containers( + test_client, monkeypatch, main_module, invalid_field +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + item = {"title": "Bad item", "published": 1700000000, **invalid_field} + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse(payload={"items": [item]}), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} + + +@pytest.mark.parametrize( + "timestamp", ["1700000000", True, float("nan"), float("inf")] +) +def test_unread_rejects_nonnumeric_timestamps( + test_client, monkeypatch, main_module, timestamp +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse(payload=payload), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} + + +@pytest.mark.parametrize("timestamp", [10**30, -(10**30)]) +def test_unread_rejects_out_of_range_timestamps( + test_client, monkeypatch, main_module, timestamp +): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse(payload=payload), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} + + +def test_unread_rejects_timestamp_overflowing_isfinite( + test_client, monkeypatch, main_module +): + """A huge int that overflows math.isfinite() must still produce a 502, not 500.""" + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(text="Auth=token\n"), + ) + huge = 10**1000 # far beyond float range — isfinite() raises OverflowError + payload = {"items": [{"title": "Overflow timestamp", "published": huge}]} + monkeypatch.setattr( + main_module.requests, + "get", + lambda *args, **kwargs: FakeResponse(payload=payload), + ) + + response = test_client.get("/freshrss/unread") + + assert response.status_code == 502 + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} diff --git a/uv.lock b/uv.lock index ed9b650..6249ad2 100644 --- a/uv.lock +++ b/uv.lock @@ -206,6 +206,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, +] + +[[package]] +name = "httpx2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, +] + [[package]] name = "humanize" version = "4.15.0" @@ -217,11 +246,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -431,12 +460,14 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "humanize" }, + { name = "pydantic" }, { name = "requests" }, { name = "uvicorn" }, ] [package.dev-dependencies] dev = [ + { name = "httpx2" }, { name = "pytest" }, ] @@ -444,12 +475,16 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "humanize", specifier = ">=4.12.3" }, + { name = "pydantic", specifier = ">=2.0" }, { name = "requests", specifier = ">=2.32.4" }, { name = "uvicorn", specifier = ">=0.32.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0.0" }] +dev = [ + { name = "httpx2", specifier = ">=2.0.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] [[package]] name = "starlette" @@ -518,6 +553,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"