test: cover routers, auth, rate limit, config_manager, imgur extractor

- tests/test_auth.py: enable/disable via env, valid/invalid creds
- tests/test_rate_limit.py: bucket math + window expiry
- tests/test_router_media.py: path traversal (file/thumb), paginated subreddits/authors, blacklist preview shape, DELETE flow, cleanup-by-type 400 paths
- tests/test_router_stats.py: cache TTL, .json exclusion, missing dir
- tests/test_router_favorites.py: paginated favorites/authors, no N+1 (query count), favorites_only filter
- tests/test_router_config.py: CRUD subreddits/users/blacklist
- tests/test_config_manager.py: case-insensitive dedupe, blacklist removes from collection, domain normalization
- tests/test_extractors_imgur.py: i.imgur direct, gifv->mp4, albums/galleries -> None

src/database.py: resolve RMC_DB_PATH lazily in Database() so tests can monkeypatch the env per case (no more module-import capture)

Result: 135 passed (was 77). Coverage 55% global; 100% on auth/imgur, 96% rate_limit, 84% stats router, 80% config_manager.
This commit is contained in:
authentik Default Admin 2026-05-17 11:54:40 +01:00
parent 5f9c6023c1
commit 34c2b16e19
9 changed files with 651 additions and 2 deletions

View file

@ -10,6 +10,11 @@ from pathlib import Path
DEFAULT_DB_PATH = os.environ.get("RMC_DB_PATH", "media.db")
def _resolve_db_path() -> str:
"""Read RMC_DB_PATH lazily so tests can monkeypatch the env per case."""
return os.environ.get("RMC_DB_PATH", "media.db")
@dataclass
class PostRecord:
id: str
@ -32,8 +37,8 @@ class PostRecord:
class Database:
"""SQLite database wrapper for tracking downloaded posts."""
def __init__(self, db_path: str = DEFAULT_DB_PATH):
self.db_path = Path(db_path)
def __init__(self, db_path: str | None = None):
self.db_path = Path(db_path if db_path is not None else _resolve_db_path())
self._init_db()
def _init_db(self):

64
tests/test_auth.py Normal file
View file

@ -0,0 +1,64 @@
"""Tests for optional HTTP Basic auth (src.web.auth)."""
from __future__ import annotations
import base64
import pytest
from fastapi.testclient import TestClient
from src.web.app import app
@pytest.fixture
def client():
return TestClient(app)
def _basic(user: str, password: str) -> dict[str, str]:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {token}"}
def test_no_auth_when_env_unset(client, monkeypatch):
"""Without RMC_AUTH_USER/RMC_AUTH_PASS, all routes are public."""
monkeypatch.delenv("RMC_AUTH_USER", raising=False)
monkeypatch.delenv("RMC_AUTH_PASS", raising=False)
response = client.get("/api/stats")
assert response.status_code == 200
def test_partial_env_does_not_enable_auth(client, monkeypatch):
monkeypatch.setenv("RMC_AUTH_USER", "alice")
monkeypatch.delenv("RMC_AUTH_PASS", raising=False)
response = client.get("/api/stats")
assert response.status_code == 200
def test_auth_required_when_env_set(client, monkeypatch):
monkeypatch.setenv("RMC_AUTH_USER", "alice")
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
response = client.get("/api/stats")
assert response.status_code == 401
assert response.headers.get("www-authenticate", "").lower().startswith("basic")
def test_auth_accepts_valid_credentials(client, monkeypatch):
monkeypatch.setenv("RMC_AUTH_USER", "alice")
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
response = client.get("/api/stats", headers=_basic("alice", "s3cret"))
assert response.status_code == 200
def test_auth_rejects_invalid_credentials(client, monkeypatch):
monkeypatch.setenv("RMC_AUTH_USER", "alice")
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
response = client.get("/api/stats", headers=_basic("alice", "wrong"))
assert response.status_code == 401
def test_auth_rejects_wrong_username(client, monkeypatch):
monkeypatch.setenv("RMC_AUTH_USER", "alice")
monkeypatch.setenv("RMC_AUTH_PASS", "s3cret")
response = client.get("/api/stats", headers=_basic("bob", "s3cret"))
assert response.status_code == 401

View file

@ -0,0 +1,92 @@
"""Tests for src.web.config_manager (CRUD on YAML config)."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from src.web import config_manager
@pytest.fixture
def isolated_config(tmp_path, monkeypatch):
"""Point config_manager at a fresh YAML file inside tmp_path."""
path = tmp_path / "config.yaml"
monkeypatch.setattr(config_manager, "CONFIG_PATH", path)
return path
def _read(path: Path) -> dict:
return yaml.safe_load(path.read_text()) or {}
class TestSubreddits:
def test_get_empty_when_missing_file(self, isolated_config):
assert config_manager.get_subreddits() == []
def test_add_subreddit_creates_file(self, isolated_config):
added = config_manager.add_subreddit("pics", limit=25, sort="hot")
assert added is True
data = _read(isolated_config)
assert data["targets"]["subreddits"][0]["name"] == "pics"
def test_add_subreddit_duplicate_case_insensitive(self, isolated_config):
config_manager.add_subreddit("Pics")
assert config_manager.add_subreddit("pics") is False
assert len(_read(isolated_config)["targets"]["subreddits"]) == 1
def test_remove_subreddit_when_missing_returns_false(self, isolated_config):
assert config_manager.remove_subreddit("ghost") is False
def test_remove_subreddit(self, isolated_config):
config_manager.add_subreddit("pics")
assert config_manager.remove_subreddit("pics") is True
assert config_manager.get_subreddits() == []
class TestUsers:
def test_add_user(self, isolated_config):
assert config_manager.add_user("alice") is True
assert config_manager.get_users()[0]["name"] == "alice"
def test_remove_user(self, isolated_config):
config_manager.add_user("alice")
assert config_manager.remove_user("alice") is True
class TestBlacklist:
def test_get_blacklist_has_all_keys(self, isolated_config):
bl = config_manager.get_blacklist()
assert set(bl.keys()) == {"authors", "subreddits", "title_keywords", "domains"}
def test_blacklist_author_removes_from_users(self, isolated_config):
config_manager.add_user("spammer")
assert config_manager.add_blacklist_author("spammer") is True
# User should be removed from collection
assert all(u["name"] != "spammer" for u in config_manager.get_users())
# Blacklist now contains the author
assert "spammer" in config_manager.get_blacklist()["authors"]
def test_blacklist_author_duplicate_case_insensitive(self, isolated_config):
config_manager.add_blacklist_author("Bob")
assert config_manager.add_blacklist_author("bob") is False
def test_blacklist_subreddit_removes_from_targets(self, isolated_config):
config_manager.add_subreddit("spam_sub")
assert config_manager.add_blacklist_subreddit("spam_sub") is True
assert all(s["name"] != "spam_sub" for s in config_manager.get_subreddits())
assert "spam_sub" in config_manager.get_blacklist()["subreddits"]
def test_blacklist_keyword_roundtrip(self, isolated_config):
assert config_manager.add_blacklist_keyword("buy now") is True
assert "buy now" in config_manager.get_blacklist()["title_keywords"]
assert config_manager.remove_blacklist_keyword("buy now") is True
assert "buy now" not in config_manager.get_blacklist()["title_keywords"]
def test_blacklist_domain_normalizes_scheme(self, isolated_config):
assert config_manager.add_blacklist_domain("https://Malware.com") is True
assert "malware.com" in config_manager.get_blacklist()["domains"]
# Adding again (different scheme) should be a no-op
assert config_manager.add_blacklist_domain("http://malware.com/") is False

View file

@ -0,0 +1,42 @@
"""Tests for the Imgur URL extractor."""
from __future__ import annotations
import pytest
from src.extractors.imgur import extract_imgur_url
@pytest.mark.parametrize(
"url,expected_url,expected_type",
[
("https://i.imgur.com/abc123.jpg", "https://i.imgur.com/abc123.jpg", "image"),
("https://i.imgur.com/abc123.png", "https://i.imgur.com/abc123.png", "image"),
(
"https://i.imgur.com/abc123.gifv",
"https://i.imgur.com/abc123.mp4",
"video",
),
("https://imgur.com/abc123", "https://i.imgur.com/abc123.jpg", "image"),
("https://imgur.com/abc123.jpg", "https://i.imgur.com/abc123.jpg", "image"),
],
)
def test_extract_imgur_url_variants(url, expected_url, expected_type):
result, media_type = extract_imgur_url(url)
assert result == expected_url
assert media_type == expected_type
def test_album_returns_none():
result, _ = extract_imgur_url("https://imgur.com/a/abc123")
assert result is None
def test_gallery_returns_none():
result, _ = extract_imgur_url("https://imgur.com/gallery/abc123")
assert result is None
def test_path_with_no_id_returns_none():
result, _ = extract_imgur_url("https://imgur.com/")
assert result is None

61
tests/test_rate_limit.py Normal file
View file

@ -0,0 +1,61 @@
"""Tests for the in-memory rate limiter."""
from __future__ import annotations
import asyncio
import time
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.web.rate_limit import _reset_buckets, rate_limit
@pytest.fixture(autouse=True)
def reset_buckets():
_reset_buckets()
yield
_reset_buckets()
def _make_app(max_calls: int, window: float) -> FastAPI:
app = FastAPI()
@app.get("/limited", dependencies=[pytest.importorskip("fastapi").Depends(rate_limit(max_calls, window))])
async def _route():
return {"ok": True}
return app
def test_allows_within_window():
app = _make_app(3, 60.0)
client = TestClient(app)
for _ in range(3):
assert client.get("/limited").status_code == 200
def test_blocks_when_exceeded():
app = _make_app(2, 60.0)
client = TestClient(app)
assert client.get("/limited").status_code == 200
assert client.get("/limited").status_code == 200
response = client.get("/limited")
assert response.status_code == 429
assert response.headers.get("retry-after") is not None
def test_resets_after_window():
app = _make_app(1, 0.1)
client = TestClient(app)
assert client.get("/limited").status_code == 200
assert client.get("/limited").status_code == 429
time.sleep(0.15)
assert client.get("/limited").status_code == 200
def test_returns_dependency_callable():
"""Direct unit test on the closure (no FastAPI dependency injection)."""
dep = rate_limit(2, 60.0)
assert asyncio.iscoroutinefunction(dep)

View file

@ -0,0 +1,57 @@
"""Tests for /api/subreddits, /api/users, /api/blacklist CRUD endpoints."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from src.web import config_manager
from src.web.app import app
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(config_manager, "CONFIG_PATH", tmp_path / "config.yaml")
return TestClient(app)
class TestSubredditsCRUD:
def test_post_then_delete(self, client):
response = client.post("/api/subreddits", json={"name": "pics", "limit": 50, "sort": "hot"})
assert response.status_code in (200, 201)
response = client.get("/api/subreddits")
assert any(s["name"] == "pics" for s in response.json())
response = client.delete("/api/subreddits/pics")
assert response.status_code == 200
def test_duplicate_returns_409(self, client):
client.post("/api/subreddits", json={"name": "pics"})
response = client.post("/api/subreddits", json={"name": "pics"})
assert response.status_code == 409
def test_delete_missing_returns_404(self, client):
response = client.delete("/api/subreddits/ghost")
assert response.status_code == 404
class TestUsersCRUD:
def test_post_then_delete(self, client):
response = client.post("/api/users", json={"name": "alice"})
assert response.status_code in (200, 201)
response = client.delete("/api/users/alice")
assert response.status_code == 200
class TestBlacklistCRUD:
def test_authors_add_remove(self, client):
client.post("/api/blacklist/authors", json={"value": "spammer"})
response = client.get("/api/blacklist")
assert "spammer" in response.json().get("authors", [])
response = client.delete("/api/blacklist/authors/spammer")
assert response.status_code == 200
def test_keywords_add(self, client):
response = client.post("/api/blacklist/keywords", json={"value": "buy now"})
assert response.status_code in (200, 201)
response = client.get("/api/blacklist")
assert "buy now" in response.json().get("title_keywords", [])

View file

@ -0,0 +1,98 @@
"""Tests for /api/favorites and /api/authors routers."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from src.database import Database, PostRecord
from src.web.app import app
def _make_post(pid: str, **overrides) -> PostRecord:
defaults = dict(
id=pid,
subreddit="testsub",
author="alice",
title=f"Post {pid}",
url=f"https://reddit.com/{pid}",
media_url=f"https://i.example.com/{pid}.jpg",
media_type="image",
score=42,
created_utc=1_700_000_000.0,
downloaded_at=datetime.now(timezone.utc),
local_path=f"./downloads/{pid}.jpg",
file_hash=f"hash{pid}",
permalink=f"/r/testsub/comments/{pid}",
source_type="subreddit",
flair=None,
)
defaults.update(overrides)
return PostRecord(**defaults)
@pytest.fixture
def db_with_data(tmp_path, monkeypatch):
db_path = tmp_path / "media.db"
monkeypatch.setenv("RMC_DB_PATH", str(db_path))
db = Database(db_path=str(db_path))
for i, author in enumerate(["alice", "alice", "bob", "carol"]):
post = _make_post(f"p{i}", author=author, score=10 + i)
db.add_post(post)
db.add_favorite("p0")
db.add_favorite("p2")
yield db_path
@pytest.fixture
def client(db_with_data):
return TestClient(app)
def test_favorites_authors_paginated_shape(client):
response = client.get("/api/favorites/authors")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
# 2 favorited posts → 2 distinct authors (alice, bob)
assert set(data["items"]) == {"alice", "bob"}
def test_authors_endpoint_uses_single_query_no_n_plus_1(client, db_with_data):
"""get_authors_with_stats should run a single SELECT — no per-author lookup."""
seen_queries: list[str] = []
real_connect = sqlite3.connect
def tracking_connect(*args, **kwargs):
conn = real_connect(*args, **kwargs)
conn.set_trace_callback(lambda sql: seen_queries.append(sql))
return conn
import unittest.mock as mock
with mock.patch("sqlite3.connect", side_effect=tracking_connect):
response = client.get("/api/authors?limit=50")
assert response.status_code == 200
select_queries = [q for q in seen_queries if q.strip().lower().startswith("select")]
# Expect a small constant: get_authors_with_stats + count_authors. The old N+1
# path issued one extra SELECT per author (3 authors → 5+ selects).
assert len(select_queries) <= 4, f"Too many SELECTs: {select_queries}"
def test_remove_favorite_404_when_missing(client):
response = client.delete("/api/favorites/does-not-exist")
assert response.status_code == 404
def test_authors_with_favorites_only_filters(client):
response = client.get("/api/authors?favorites_only=true")
assert response.status_code == 200
data = response.json()
# Only authors with at least one favorited post: alice (p0) + bob (p2)
names = {a["author"] for a in data["authors"]}
assert names == {"alice", "bob"}
assert all(a["is_favorite"] for a in data["authors"])

159
tests/test_router_media.py Normal file
View file

@ -0,0 +1,159 @@
"""Tests for /api/media router (security, pagination, delete, cleanup)."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from src.database import Database, PostRecord
from src.web import deps
from src.web.app import app
def _make_post(post_id: str, **overrides) -> PostRecord:
defaults = dict(
id=post_id,
subreddit="testsub",
author="alice",
title=f"Post {post_id}",
url=f"https://reddit.com/{post_id}",
media_url=f"https://i.example.com/{post_id}.jpg",
media_type="image",
score=42,
created_utc=1_700_000_000.0,
downloaded_at=datetime.now(timezone.utc),
local_path=None,
file_hash="hash" + post_id,
permalink=f"/r/testsub/comments/{post_id}",
source_type="subreddit",
flair=None,
)
defaults.update(overrides)
return PostRecord(**defaults)
@pytest.fixture
def isolated_db_and_dir(tmp_path, monkeypatch):
"""Point Database, DOWNLOADS_DIR, and THUMBS_DIR at tmp_path."""
downloads = tmp_path / "downloads"
downloads.mkdir()
thumbs = downloads / ".thumbs"
thumbs.mkdir()
db_path = tmp_path / "media.db"
monkeypatch.setattr(deps, "DOWNLOADS_DIR", downloads)
monkeypatch.setattr(deps, "THUMBS_DIR", thumbs)
# The media router reads these names directly at call time via `from ..deps import …`
from src.web.routers import media as media_router
monkeypatch.setattr(media_router, "DOWNLOADS_DIR", downloads)
monkeypatch.setattr(media_router, "THUMBS_DIR", thumbs)
monkeypatch.setenv("RMC_DB_PATH", str(db_path))
# Force a fresh Database() everywhere
Database(db_path=str(db_path))
yield downloads, db_path
@pytest.fixture
def client(isolated_db_and_dir):
return TestClient(app)
class TestPathTraversal:
def test_file_relative_path_blocked(self, client):
response = client.get("/api/media/file/..%2Fsomefile")
assert response.status_code == 400
def test_thumb_relative_path_blocked(self, client):
response = client.get("/api/media/thumb/..%2Fsomefile.mp4")
assert response.status_code == 400
def test_file_with_null_byte_blocked(self, client):
response = client.get("/api/media/file/abc%00def.jpg")
# FastAPI may already reject this at the framework level (400 or 404)
assert response.status_code in (400, 404, 422)
def test_file_legitimate_filename_404_for_missing(self, client):
response = client.get("/api/media/file/legit.jpg")
assert response.status_code == 404
class TestSubredditsAndAuthors:
def test_subreddits_returns_paginated_shape(self, client):
response = client.get("/api/media/subreddits")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert isinstance(data["items"], list)
def test_authors_returns_paginated_shape(self, client):
response = client.get("/api/media/authors")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
def test_subreddits_respects_limit(self, client, isolated_db_and_dir):
_, db_path = isolated_db_and_dir
db = Database(db_path=str(db_path))
for i in range(5):
db.add_post(_make_post(f"p{i}", subreddit=f"sub{i}"))
db.mark_downloaded(f"p{i}", f"./downloads/p{i}.jpg", f"hash{i}")
response = client.get("/api/media/subreddits?limit=2&offset=0")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["total"] == 5
class TestBlacklistPreview:
def test_returns_total_count_key(self, client):
response = client.get("/api/media/blacklist-preview")
assert response.status_code == 200
data = response.json()
assert "total_count" in data
assert "author_count" in data
assert "subreddit_count" in data
assert "authors" in data
assert "subreddits" in data
class TestDeleteMedia:
def test_delete_missing_returns_404(self, client):
response = client.delete("/api/media/nonexistent")
assert response.status_code == 404
def test_delete_existing_returns_ok(self, client, isolated_db_and_dir):
_, db_path = isolated_db_and_dir
db = Database(db_path=str(db_path))
db.add_post(_make_post("p1"))
response = client.delete("/api/media/p1")
assert response.status_code == 200
assert "deleted" in response.json().get("message", "")
assert db.get_post("p1") is None
def test_delete_with_blacklist_author(self, client, isolated_db_and_dir):
_, db_path = isolated_db_and_dir
db = Database(db_path=str(db_path))
db.add_post(_make_post("p1", author="bob"))
response = client.delete("/api/media/p1?blacklist_author=true")
assert response.status_code == 200
body = response.json()
# blacklisted only included when something was actually blacklisted
assert "blacklisted" in body
assert any("bob" in b for b in body["blacklisted"])
class TestCleanupByType:
def test_invalid_type_returns_400(self, client):
response = client.post("/api/media/cleanup-by-type?media_type=banana")
assert response.status_code == 400
def test_preview_invalid_type_returns_400(self, client):
response = client.get("/api/media/cleanup-preview?media_type=banana")
assert response.status_code == 400

View file

@ -0,0 +1,71 @@
"""Tests for /api/stats router (cache, async scan)."""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from src.web import deps
from src.web.app import app
from src.web.routers import stats as stats_router
@pytest.fixture
def client(tmp_path, monkeypatch):
downloads = tmp_path / "downloads"
downloads.mkdir()
monkeypatch.setattr(deps, "DOWNLOADS_DIR", downloads)
monkeypatch.setattr(stats_router, "DOWNLOADS_DIR", downloads)
stats_router._reset_stats_cache()
monkeypatch.setenv("RMC_DB_PATH", str(tmp_path / "media.db"))
return TestClient(app)
def test_stats_endpoint_returns_expected_keys(client):
response = client.get("/api/stats")
assert response.status_code == 200
data = response.json()
for key in ("file_count", "disk_size_bytes", "disk_size_mb", "disk_size_gb", "disk_free_gb"):
assert key in data
def test_stats_counts_files_excluding_json(client, tmp_path):
# Create some media + a sidecar
downloads = tmp_path / "downloads"
(downloads / "a.jpg").write_bytes(b"x" * 100)
(downloads / "b.mp4").write_bytes(b"y" * 200)
(downloads / "a.jpg.json").write_bytes(b"{}") # sidecar should not count
stats_router._reset_stats_cache()
response = client.get("/api/stats")
data = response.json()
assert data["file_count"] == 2
assert data["disk_size_bytes"] == 300
def test_stats_cache_serves_stale_within_ttl(client, tmp_path):
downloads = tmp_path / "downloads"
(downloads / "a.jpg").write_bytes(b"x" * 100)
stats_router._reset_stats_cache()
first = client.get("/api/stats").json()
assert first["file_count"] == 1
# Add another file but don't reset cache; expect cached value
(downloads / "b.jpg").write_bytes(b"y" * 50)
second = client.get("/api/stats").json()
assert second["file_count"] == 1 # cached
# After cache reset, the new file is reflected
stats_router._reset_stats_cache()
third = client.get("/api/stats").json()
assert third["file_count"] == 2
def test_scan_downloads_handles_missing_dir(tmp_path):
stats_router._reset_stats_cache()
missing = tmp_path / "nope"
size, count = asyncio.run(stats_router._scan_downloads_cached(missing))
assert size == 0
assert count == 0