From 4db28adebd016354304f301a7d2131eb2ba7a79f Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:41:52 +0300 Subject: [PATCH 1/6] Redact failed login warning --- main.py | 8 +++++++- tests/test_main.py | 27 +++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 627c74a..17b30cf 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import logging import os +from urllib.parse import urlsplit from fastapi import FastAPI, HTTPException, Query import requests import humanize @@ -54,7 +55,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(): diff --git a/tests/test_main.py b/tests/test_main.py index 8e624e9..21a46db 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -84,15 +84,30 @@ def fake_post(url, data, timeout): ] -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")) - - with pytest.raises(HTTPException) as excinfo: - main.get_greader_token() +def test_get_greader_token_rejects_failed_login_without_logging_secrets(monkeypatch, caplog): + credentials = { + "FRESHRSS_HOST": "https://freshrss.example.test", + "FRESHRSS_USER": "sentinel-username", + "FRESHRSS_PASS": "sentinel-password", + } + main = import_app(monkeypatch, credentials) + response_body = "sentinel-response-body\nAuth=sentinel-token" + monkeypatch.setattr( + main.requests, + "post", + lambda *args, **kwargs: FakeResponse(status_code=403, text=response_body), + ) + + 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 def test_get_greader_token_rejects_missing_auth_line(monkeypatch): From fd527cff7e5a88135122fc0ef7d010533e24b469 Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:43:04 +0300 Subject: [PATCH 2/6] Normalize and encode FreshRSS categories --- main.py | 12 +++++++++--- tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 627c74a..f750597 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,7 @@ import logging import os +from urllib.parse import quote + from fastapi import FastAPI, HTTPException, Query import requests import humanize @@ -72,7 +74,7 @@ def health(): @app.get("/freshrss/unread") def freshrss_unread( n: int = Query(default=10, ge=1), - category: str | None = Query(default=None), + category: str | None = Query(default=None, max_length=200), ): token = get_greader_token() headers = {"Authorization": f"GoogleLogin auth={token}"} @@ -81,8 +83,12 @@ def freshrss_unread( "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 + stream_id = ( + f"user/-/label/{quote(category_label, safe='')}" + if category_label + else "user/-/state/com.google/reading-list" + ) # Using the same host as before but with the right endpoint url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}" try: diff --git a/tests/test_main.py b/tests/test_main.py index 8e624e9..ab3aba0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -193,6 +193,36 @@ def fake_get(url, headers, params, timeout): assert result[0]["url"] == "" +@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"), + ], +) +def test_freshrss_unread_normalizes_and_encodes_category(monkeypatch, category, expected_stream): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + captured = {} + + def fake_get(url, **kwargs): + captured["url"] = url + return FakeResponse(payload={"items": []}) + + monkeypatch.setattr(main.requests, "get", fake_get) + + main.freshrss_unread(category=category) + + assert captured["url"] == ( + "https://freshrss.example.test/api/greader.php/reader/api/0/stream/contents/" + f"{expected_stream}" + ) + + def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch): main = import_app(monkeypatch) monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") From db527ea78ae564ba3d688f7748df66910426c9b0 Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:43:05 +0300 Subject: [PATCH 3/6] Test API endpoints through TestClient --- main.py | 7 +- tests/test_main.py | 295 +++++++++++++++++++++++++-------------------- 2 files changed, 167 insertions(+), 135 deletions(-) diff --git a/main.py b/main.py index 627c74a..cb25e5e 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import logging import os +from urllib.parse import quote from fastapi import FastAPI, HTTPException, Query import requests import humanize @@ -82,7 +83,11 @@ def freshrss_unread( "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" + stream_id = ( + f"user/-/label/{quote(category_label, safe='')}" + if category_label + else "user/-/state/com.google/reading-list" + ) # Using the same host as before but with the right endpoint url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}" try: diff --git a/tests/test_main.py b/tests/test_main.py index 8e624e9..59fe711 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,8 +3,9 @@ from pathlib import Path import pytest -from fastapi import HTTPException import requests +from fastapi import HTTPException +from fastapi.testclient import TestClient ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: @@ -33,13 +34,40 @@ def raise_for_status(self): raise self._raise_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") + module = importlib.import_module("main") + yield module + sys.modules.pop("main", None) + + +@pytest.fixture +def test_client(main_module): + with TestClient(main_module.app) as client: + yield client + + +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["post"].append({"url": url, "data": data, "timeout": timeout}) + return FakeResponse(text="SID=ignored\nAuth= transport-token \n") + + def fake_get(url, headers, params, timeout): + calls["get"].append( + {"url": url, "headers": headers, "params": params, "timeout": timeout} + ) + return FakeResponse(payload=payload or {"items": []}) + + monkeypatch.setattr(main_module.requests, "post", fake_post) + monkeypatch.setattr(main_module.requests, "get", fake_get) + return calls def test_import_requires_freshrss_environment(monkeypatch): @@ -50,157 +78,156 @@ def test_import_requires_freshrss_environment(monkeypatch): 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 - + assert all(name in str(excinfo.value) for name in REQUIRED_ENV) -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_health_endpoint_returns_json_over_http(test_client): + response = test_client.get("/health") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "ok"} -def test_get_greader_token_logs_in_once_and_caches_token(monkeypatch): - main = import_app(monkeypatch) - calls = [] - def fake_post(url, data, timeout): - calls.append({"url": url, "data": data, "timeout": timeout}) - return FakeResponse(text="SID=ignored\nAuth= cached-token \n") +def test_unread_uses_default_query_and_authenticates_once( + test_client, monkeypatch, main_module +): + calls = install_freshrss_transport(monkeypatch, main_module) - monkeypatch.setattr(main.requests, "post", fake_post) + first = test_client.get("/freshrss/unread") + second = test_client.get("/freshrss/unread") - assert main.get_greader_token() == "cached-token" - assert main.get_greader_token() == "cached-token" - assert calls == [ + 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, } ] - - -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")) - - 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 - - -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() - - assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "Auth token not found in FreshRSS response" - - -def test_get_greader_token_wraps_request_failures(monkeypatch): - main = import_app(monkeypatch) - - def fake_post(*args, **kwargs): - raise requests.Timeout("slow upstream") - - monkeypatch.setattr(main.requests, "post", fake_post) - - with pytest.raises(HTTPException) as excinfo: - main.get_greader_token() - - assert excinfo.value.status_code == 502 - assert excinfo.value.detail == "FreshRSS login request failed" - - -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 = {} - - 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) - - result = main.freshrss_unread(n=5) - - assert captured == { + 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 • ") - -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": [], - } - ] - } - ) - - monkeypatch.setattr(main.requests, "get", fake_get) +def test_unread_accepts_explicit_query_and_encoded_category( + test_client, monkeypatch, main_module +): + calls = install_freshrss_transport(monkeypatch, main_module) + + response = test_client.get( + "/freshrss/unread", params={"n": "25", "category": "Tech & Science/News"} + ) + + 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("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) + + response = test_client.get("/freshrss/unread", params={"n": value}) + + 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}) + + 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"} + + +# These focused unit tests make token parsing failures easier to diagnose than an +# endpoint-level assertion while still mocking only the requests transport. +def test_get_greader_token_rejects_failed_login(monkeypatch, main_module): + monkeypatch.setattr( + main_module.requests, + "post", + lambda *args, **kwargs: FakeResponse(status_code=403, text="nope"), + ) - result = main.freshrss_unread(n=3, category="Tech") + with pytest.raises(HTTPException) as excinfo: + main_module.get_greader_token() - assert captured["url"].endswith("/stream/contents/user/-/label/Tech") - assert captured["params"]["n"] == 3 - assert result[0]["feed"] is None - assert result[0]["url"] == "" + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS login failed with status 403" -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)) +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"), + ) 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 == "Auth token not found in FreshRSS response" From 5743a7775551ab99e18e8cccb2473c75753d4560 Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:44:14 +0300 Subject: [PATCH 4/6] Validate FreshRSS unread responses --- main.py | 73 +++++++++++++++++++++++++++++++++++++----- pyproject.toml | 1 + tests/test_main.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 ++ 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 627c74a..d1bab4d 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,8 @@ 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""" @@ -39,6 +41,60 @@ AUTH_TOKEN = None +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 + if not isfinite(value): + raise ValueError("timestamp must be finite") + try: + datetime.fromtimestamp(value, timezone.utc) + except (OverflowError, 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 if AUTH_TOKEN: @@ -92,25 +148,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 1309bfc..314b948 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 8e624e9..4c90556 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -204,3 +204,82 @@ def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch): assert excinfo.value.status_code == 502 assert excinfo.value.detail == "FreshRSS unread request failed" + + +@pytest.mark.parametrize("payload", [[], "not an object", None]) +def test_freshrss_unread_rejects_malformed_top_level_payload(monkeypatch, payload): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + response = FakeResponse(payload={}) + response._payload = payload + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: response) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("items", [None, {}, "not a list", ["not an object"]]) +def test_freshrss_unread_rejects_invalid_items(monkeypatch, items): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload={"items": items})) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.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_freshrss_unread_rejects_malformed_item_containers(monkeypatch, invalid_field): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + item = {"title": "Bad item", "published": 1700000000, **invalid_field} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload={"items": [item]})) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("timestamp", ["1700000000", True, float("nan"), float("inf")]) +def test_freshrss_unread_rejects_nonnumeric_timestamps(monkeypatch, timestamp): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload=payload)) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" + + +@pytest.mark.parametrize("timestamp", [10**30, -(10**30)]) +def test_freshrss_unread_rejects_out_of_range_timestamps(monkeypatch, timestamp): + main = import_app(monkeypatch) + monkeypatch.setattr(main, "get_greader_token", lambda: "token-123") + payload = {"items": [{"title": "Bad timestamp", "published": timestamp}]} + monkeypatch.setattr(main.requests, "get", lambda *args, **kwargs: FakeResponse(payload=payload)) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS returned an invalid unread response" diff --git a/uv.lock b/uv.lock index ed9b650..52b7b00 100644 --- a/uv.lock +++ b/uv.lock @@ -431,6 +431,7 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "humanize" }, + { name = "pydantic" }, { name = "requests" }, { name = "uvicorn" }, ] @@ -444,6 +445,7 @@ 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" }, ] From 183bda881536f368dd91c54b3f96306e4b2529c2 Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:53:55 +0300 Subject: [PATCH 5/6] fix: update reauth retry test for Pydantic validation response After Pydantic validation (PR #23), a 403 retry that returns an empty dict is caught by validate_freshrss_response before the RequestException handler, producing 'invalid unread response' rather than 'unread request failed'. --- tests/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index f12d149..ac2cce6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -360,7 +360,7 @@ def fake_get(url, headers, params, timeout): response = test_client.get("/freshrss/unread") assert response.status_code == 502 - assert response.json() == {"detail": "FreshRSS unread request failed"} + assert response.json() == {"detail": "FreshRSS returned an invalid unread response"} assert get_calls == [ "GoogleLogin auth=expired-token", "GoogleLogin auth=fresh-token", From b946d18dff344a0eeb91b4a9496aa0096f32974f Mon Sep 17 00:00:00 2001 From: Skulldorom <51134009+Skulldorom@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:59:23 +0300 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20dot-only=20labels=20and=20isfinite=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #21 review (P2): Treat '.' and '..' categories as reading-list fallback - quote() leaves dots unchanged (RFC 3986 unreserved), so requests normalizes 'label/.' → 'label/' and 'label/..' → 'user/-/' as path traversal. Reject these labels before URL construction. PR #23 review (P2): Catch OverflowError from isfinite() on huge ints - math.isfinite() raises OverflowError when converting a huge Python int to float, before the existing try/except could wrap it as ValidationError. Now isfinite() is inside the guarded block, producing a 502 instead of a 500. Tests: dot-label parametrized cases + huge-int overflow test --- main.py | 10 +++++++--- tests/test_main.py | 25 +++++++++++++++++++++++ uv.lock | 50 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 8d7daf7..728981d 100644 --- a/main.py +++ b/main.py @@ -70,11 +70,13 @@ class FreshRSSItem(BaseModel): def validate_published_timestamp(cls, value): if value is None: return value - if not isfinite(value): - raise ValueError("timestamp must be finite") try: + if not isfinite(value): + raise ValueError("timestamp must be finite") datetime.fromtimestamp(value, timezone.utc) - except (OverflowError, OSError, ValueError) as exc: + 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 @@ -139,6 +141,8 @@ def request_unread(token, n, category): "n": n, } 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 diff --git a/tests/test_main.py b/tests/test_main.py index ac2cce6..0c55507 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -155,6 +155,8 @@ def test_unread_accepts_explicit_query_and_encoded_category( ("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( @@ -514,3 +516,26 @@ def test_unread_rejects_out_of_range_timestamps( 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 52b7b00..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]] @@ -438,6 +467,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx2" }, { name = "pytest" }, ] @@ -451,7 +481,10 @@ requires-dist = [ ] [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" @@ -520,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"