feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.app.api_models import PasswordResetRequest, SignupRequest
|
||||
|
||||
|
||||
class ApiRequestModelTests(unittest.TestCase):
|
||||
def test_signup_rejects_unknown_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
SignupRequest(
|
||||
invite_code="invite",
|
||||
username="viewer",
|
||||
password="strong password",
|
||||
unexpected="value",
|
||||
)
|
||||
|
||||
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
|
||||
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
|
||||
self.assertEqual(request.new_password, " leading and trailing ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||
|
||||
|
||||
class _ArrClient:
|
||||
async def get_root_folders(self):
|
||||
return [{"id": 7, "path": "/media/tv"}]
|
||||
|
||||
|
||||
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_resolves_numeric_root_folder_id(self) -> None:
|
||||
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
|
||||
|
||||
async def test_preserves_configured_path(self) -> None:
|
||||
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
|
||||
|
||||
async def test_rejects_missing_root_folder_id(self) -> None:
|
||||
with self.assertRaises(RootFolderNotFoundError):
|
||||
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
from contextlib import closing
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import backups as backup_router
|
||||
from backend.app.services import backups
|
||||
|
||||
|
||||
PASSPHRASE = "test backup passphrase with spaces"
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.database = self.root / "magent.db"
|
||||
for key, value in {
|
||||
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
|
||||
"settings_encryption_key": Fernet.generate_key().decode(),
|
||||
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
|
||||
"admin_username": "backup-admin", "admin_password": "a secure initial password",
|
||||
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
|
||||
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
|
||||
}.items():
|
||||
context = patch.object(settings, key, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
db.init_db()
|
||||
db.set_setting("sonarr_api_key", "database-integration-secret")
|
||||
db.set_setting("site_login_message", "Restored configuration")
|
||||
db.set_setting("installation_setup", "complete")
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
|
||||
conn.execute(
|
||||
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
|
||||
)
|
||||
self.assets = self.root / "assets"
|
||||
(self.assets / "branding").mkdir(parents=True)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
|
||||
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
|
||||
|
||||
def export(self, include_cache=True):
|
||||
content, filename = backups.create_backup(PASSPHRASE, include_cache)
|
||||
self.assertTrue(filename.endswith(".magent-backup"))
|
||||
return content
|
||||
|
||||
def rewrite_archive(self, content, change):
|
||||
decrypted = backups._decrypt(content, PASSPHRASE)
|
||||
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
|
||||
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
|
||||
change(files)
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
for name, value in files.items():
|
||||
archive.writestr(name, value)
|
||||
return backups._encrypt(output.getvalue(), PASSPHRASE)
|
||||
|
||||
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
|
||||
content = self.export()
|
||||
self.assertNotIn(b"database-integration-secret", content)
|
||||
self.assertNotIn(b"environment-integration-secret", content)
|
||||
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
|
||||
db.set_setting("site_login_message", "Live data before restart")
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
|
||||
# Simulate a different host with different env-backed integration settings.
|
||||
settings.jellyfin_api_key = "destination-env-value"
|
||||
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(metadata["include_cache"])
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
|
||||
self.assertNotIn(b"database-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"environment-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"legacy-private-token", staged_bytes)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
|
||||
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
|
||||
self.assertEqual(db.get_setting("installation_setup"), "complete")
|
||||
self.assertIsNone(db.get_setting("setup_token"))
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
|
||||
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
|
||||
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
|
||||
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
|
||||
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
|
||||
status = backups.backup_status()
|
||||
self.assertIsNone(status["pending_restore"])
|
||||
self.assertEqual(status["last_restore"]["status"], "restored")
|
||||
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
|
||||
with closing(sqlite3.connect(self.database)) as writer:
|
||||
writer.execute("PRAGMA journal_mode=WAL")
|
||||
writer.execute("PRAGMA wal_autocheckpoint=0")
|
||||
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
|
||||
writer.commit()
|
||||
self.assertTrue(Path(str(self.database) + "-wal").exists())
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
with closing(sqlite3.connect(self.database)) as restored:
|
||||
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
|
||||
|
||||
def test_managed_restore_preserves_destination_application_origin(self):
|
||||
db.set_setting("magent_application_url", "https://source.example.test")
|
||||
content = self.export()
|
||||
db.set_setting("magent_application_url", "https://destination.example.test")
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test")
|
||||
|
||||
def test_manual_restore_retains_legacy_application_url_behavior(self):
|
||||
db.set_setting("magent_application_url", "https://source.example.test")
|
||||
content = self.export()
|
||||
db.set_setting("magent_application_url", "https://destination.example.test")
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test")
|
||||
|
||||
def test_managed_restore_without_destination_origin_does_not_stage(self):
|
||||
content = self.export()
|
||||
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \
|
||||
patch("backend.app.services.public_urls.magent_public_url", return_value=""):
|
||||
with self.assertRaisesRegex(backups.BackupError, "destination application address"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_process_interruption_is_recovered_on_next_startup(self):
|
||||
class ProcessStopped(BaseException):
|
||||
pass
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value before interrupted restart")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
|
||||
class ProcessStopped(BaseException):
|
||||
pass
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value to retain")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
replace_assets = backups._replace_assets
|
||||
remove_tree = backups.shutil.rmtree
|
||||
calls = 0
|
||||
|
||||
def fail_first_copy(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("failed apply")
|
||||
return replace_assets(source, target)
|
||||
|
||||
def interrupt_cleanup(path, *args, **kwargs):
|
||||
if Path(path).name == "pending":
|
||||
raise ProcessStopped()
|
||||
return remove_tree(path, *args, **kwargs)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
|
||||
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
|
||||
self.assertEqual(journal["phase"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
|
||||
directory = self.root / "schema-test"
|
||||
directory.mkdir()
|
||||
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
|
||||
source = directory / "database.sqlite3"
|
||||
with closing(sqlite3.connect(source)) as conn, conn:
|
||||
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
|
||||
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
|
||||
backups._validate_database(source)
|
||||
|
||||
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Current data")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Current data")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
|
||||
self.assertIn("database.sqlite3", archive.namelist())
|
||||
self.assertIn("files/branding/logo.png", archive.namelist())
|
||||
self.assertFalse(any("artwork" in name for name in archive.namelist()))
|
||||
|
||||
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
|
||||
content = self.export()
|
||||
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
|
||||
with self.subTest(password=password):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
|
||||
backups.stage_restore(io.BytesIO(bad_content), password)
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
|
||||
content = self.export()
|
||||
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
|
||||
with self.subTest(name=name):
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
self.assertFalse((self.root / "outside.txt").exists())
|
||||
|
||||
def test_size_limit_and_unsupported_schema_rejected(self):
|
||||
content = self.export()
|
||||
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
|
||||
# Validate the original fixture to avoid executing the malicious trigger in export.
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
|
||||
backups._validate_database(self.database)
|
||||
|
||||
def test_unsupported_compression_is_rejected_before_expansion(self):
|
||||
content = self.export()
|
||||
rewritten = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
|
||||
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
|
||||
for entry in original.infolist():
|
||||
target.writestr(entry.filename, original.read(entry))
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
|
||||
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
|
||||
|
||||
def test_cancel_is_idempotent_and_does_not_change_database(self):
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with self.assertRaisesRegex(backups.BackupError, "already staged"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
backups.cancel_restore()
|
||||
backups.cancel_restore()
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Keep this current value")
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
original = backups._replace_assets
|
||||
calls = 0
|
||||
|
||||
def fail_once(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("simulated interrupted copy")
|
||||
return original(source, target)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_once):
|
||||
with self.assertRaisesRegex(OSError, "interrupted copy"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_api_requires_admin_and_restore_confirmation(self):
|
||||
app = FastAPI()
|
||||
app.include_router(backup_router.router)
|
||||
with TestClient(app) as client:
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 401)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 403)
|
||||
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
|
||||
status = client.get("/admin/backups")
|
||||
self.assertEqual(status.status_code, 200)
|
||||
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
|
||||
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
|
||||
self.assertEqual(rejected.status_code, 422)
|
||||
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
|
||||
self.assertEqual(restored.status_code, 202)
|
||||
self.assertTrue(restored.json()["restart_required"])
|
||||
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app.config import settings
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
||||
from backend.app.services import snapshot as snapshot_service
|
||||
from backend.app.services.collector_search import read_search_status, search_status
|
||||
|
||||
|
||||
def command(name="MoviesSearch", status="started", **body):
|
||||
return {"name": name, "status": status, "body": body}
|
||||
|
||||
|
||||
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_movie_search_is_scoped_to_the_movie(self):
|
||||
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_queued_search_and_running_search_priority(self):
|
||||
queued = command(status="queued", movieIds=[12])
|
||||
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
|
||||
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
|
||||
def test_terminal_commands_are_not_searching(self):
|
||||
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
|
||||
with self.subTest(state=state):
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
|
||||
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
|
||||
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_numeric_statuses(self):
|
||||
for state, expected in [(0, "queued"), (1, "searching")]:
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
|
||||
|
||||
def test_series_and_season_searches(self):
|
||||
for name in ["SeriesSearch", "SeasonSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
|
||||
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
|
||||
|
||||
def test_episode_search_uses_episode_ids_not_numbers(self):
|
||||
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
|
||||
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
|
||||
|
||||
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
|
||||
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
|
||||
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
|
||||
for payload in [None, {}, {"error": "unavailable"}]:
|
||||
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
|
||||
|
||||
async def test_check_is_read_only_with_a_short_timeout(self):
|
||||
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
|
||||
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
|
||||
async def test_service_failure_is_unknown_not_idle(self):
|
||||
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
|
||||
|
||||
|
||||
class LibrarySearchPresentationTests(unittest.TestCase):
|
||||
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
|
||||
arr_state="added", download_state="not_started", jellyfin=False):
|
||||
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
|
||||
state=NormalizedState.added_to_arr)
|
||||
return snapshot_service._build_presentation(
|
||||
snapshot, approved=True, arr_state=arr_state,
|
||||
arr_details={"search": {"state": search}, "availability": {
|
||||
"available": available, "missing": missing, "total": available + missing,
|
||||
}}, prowlarr_state="ok",
|
||||
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
|
||||
jellyfin_found=jellyfin, jellyfin_link=None,
|
||||
)
|
||||
|
||||
def stage(self, presentation, stage_id="library"):
|
||||
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
|
||||
|
||||
def test_card_uses_actual_search_state(self):
|
||||
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
|
||||
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["stateLabel"], badge)
|
||||
self.assertEqual(library["state"], style)
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
|
||||
self.assertEqual(library["available"], 0)
|
||||
self.assertEqual(library["missing"], 1)
|
||||
|
||||
def test_partial_tv_retains_counts_and_search_activity(self):
|
||||
for state in ["idle", "searching", "queued", "unavailable"]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["state"], "partial")
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertIn("22 of 24 episodes collected", library["summary"])
|
||||
self.assertNotIn("is still looking", presentation["status"]["meaning"])
|
||||
|
||||
def test_collected_titles_dont_look_stuck_searching(self):
|
||||
for jellyfin in [True, False]:
|
||||
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
|
||||
self.assertEqual(library["state"], "complete")
|
||||
self.assertIn("no search needed", library["summary"])
|
||||
|
||||
def test_download_has_its_own_state_without_claiming_searching(self):
|
||||
library = self.stage(self.presentation("idle", download_state="downloading"))
|
||||
self.assertEqual(library["stateLabel"], "Downloading")
|
||||
self.assertIn("Not currently searching", library["summary"])
|
||||
|
||||
def test_an_old_missing_download_does_not_mark_search_complete(self):
|
||||
search = self.stage(self.presentation("idle", download_state="missing"), "search")
|
||||
self.assertEqual(search["state"], "waiting")
|
||||
|
||||
|
||||
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
|
||||
for media_type in [RequestType.movie, RequestType.tv]:
|
||||
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
|
||||
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
|
||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
|
||||
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
|
||||
collector = SimpleNamespace(
|
||||
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
|
||||
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
|
||||
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
|
||||
get_queue=AsyncMock(return_value={"records": []}),
|
||||
get=AsyncMock(return_value=commands),
|
||||
)
|
||||
mocks = {
|
||||
"get_runtime_settings": runtime,
|
||||
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
|
||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
||||
"get_request_cache_by_id": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
|
||||
"JellyfinClient": SimpleNamespace(configured=lambda: False),
|
||||
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
|
||||
"SonarrClient": collector, "RadarrClient": collector,
|
||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
||||
"get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
|
||||
}
|
||||
for name, value in mocks.items():
|
||||
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
|
||||
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
||||
snapshot = await snapshot_service.build_snapshot("12")
|
||||
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
|
||||
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
|
||||
self.assertEqual(library["searchStatus"], expected)
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Managed installation regression tests; use only disposable local files."""
|
||||
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app import container_bootstrap as bootstrap
|
||||
|
||||
|
||||
class ContainerBootstrapTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.data = self.root / "data"
|
||||
self.data.mkdir(mode=0o700)
|
||||
self.state_path = self.data / bootstrap.STATE_FILENAME
|
||||
self.database = self.data / "magent.db"
|
||||
self.environment = {
|
||||
"MAGENT_MANAGED_SECRETS": "true",
|
||||
"MAGENT_APPLICATION_URL": "https://magent.example.test",
|
||||
}
|
||||
|
||||
def prepare(self, **changes):
|
||||
return bootstrap.prepare_environment({**self.environment, **changes}, self.data)
|
||||
|
||||
def state(self):
|
||||
return json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
|
||||
def create_database(self, *, completed=0, admin=False):
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE installation_setup (id INTEGER PRIMARY KEY, completed INTEGER)")
|
||||
connection.execute("INSERT INTO installation_setup VALUES (1, ?)", (completed,))
|
||||
connection.execute("CREATE TABLE users (role TEXT)")
|
||||
if admin:
|
||||
connection.execute("INSERT INTO users VALUES ('ADMIN')")
|
||||
|
||||
def create_symlink(self, path, target, *, directory=False):
|
||||
try:
|
||||
path.symlink_to(target, target_is_directory=directory)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
self.skipTest(f"This platform cannot create test symlinks: {type(exc).__name__}")
|
||||
|
||||
def test_fresh_install_generates_independent_valid_random_secrets(self):
|
||||
before = dict(self.environment)
|
||||
prepared = self.prepare()
|
||||
state = self.state()
|
||||
self.assertEqual(self.environment, before)
|
||||
self.assertEqual(set(state), {"version", *bootstrap.SECRET_NAMES})
|
||||
self.assertEqual(state["version"], 1)
|
||||
for name in ("JWT_SECRET", "SETUP_TOKEN"):
|
||||
self.assertRegex(state[name], r"^[A-Za-z0-9_-]{64}$")
|
||||
self.assertNotEqual(state["JWT_SECRET"], state["SETUP_TOKEN"])
|
||||
self.assertEqual(len(base64.urlsafe_b64decode(state["SETTINGS_ENCRYPTION_KEY"])), 32)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(prepared[name], state[name])
|
||||
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
|
||||
self.assertFalse(self.database.exists())
|
||||
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem ownership/permissions")
|
||||
def test_state_has_private_permissions_and_runtime_ownership(self):
|
||||
self.prepare()
|
||||
metadata = self.state_path.stat()
|
||||
self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600)
|
||||
self.assertEqual(metadata.st_uid, os.geteuid())
|
||||
|
||||
def test_separate_installations_get_different_secrets(self):
|
||||
first = self.prepare()
|
||||
other = self.root / "other"
|
||||
other.mkdir(mode=0o700)
|
||||
second = bootstrap.prepare_environment(self.environment, other)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertNotEqual(first[name], second[name])
|
||||
|
||||
def test_restart_and_existing_database_reuse_exact_file_and_values(self):
|
||||
first = self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
original_modified = self.state_path.stat().st_mtime_ns
|
||||
self.create_database(admin=True)
|
||||
with patch.object(bootstrap.secrets, "token_bytes", side_effect=AssertionError("Must not regenerate")), \
|
||||
patch.object(bootstrap.secrets, "token_urlsafe", side_effect=AssertionError("Must not regenerate")):
|
||||
second = self.prepare()
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
self.assertEqual(self.state_path.stat().st_mtime_ns, original_modified)
|
||||
|
||||
def test_disabled_mode_is_an_unchanged_copy_without_filesystem_access(self):
|
||||
for value in (None, "false", "0", "no", "", " FALSE "):
|
||||
with self.subTest(mode=value):
|
||||
environment = {"JWT_SECRET": "legacy-key", "MAGENT_APPLICATION_URL": "invalid"}
|
||||
if value is not None:
|
||||
environment["MAGENT_MANAGED_SECRETS"] = value
|
||||
result = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
|
||||
self.assertEqual(result, environment)
|
||||
self.assertIsNot(result, environment)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_invalid_managed_mode_fails_before_writing(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(MAGENT_MANAGED_SECRETS="perhaps")
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_auto_mode_generates_fresh_install_keys_without_explicit_jwt(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertTrue(self.state_path.exists())
|
||||
self.assertEqual(prepared["MAGENT_MANAGED_SECRETS"], "true")
|
||||
self.assertEqual(prepared["MAGENT_RUNTIME_MANAGED"], "1")
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(prepared[name], self.state()[name])
|
||||
|
||||
def test_auto_mode_preserves_explicit_jwt_manual_install_without_filesystem_access(self):
|
||||
environment = {
|
||||
"MAGENT_MANAGED_SECRETS": "auto",
|
||||
"JWT_SECRET": "legacy-explicit-signing-key",
|
||||
"SQLITE_PATH": "/existing/custom-database.db",
|
||||
"API_DOCS_ENABLED": "true",
|
||||
"MAGENT_APPLICATION_URL": "https://legacy.example.test",
|
||||
"CORS_ALLOW_ORIGIN": "https://legacy.example.test",
|
||||
}
|
||||
prepared = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
|
||||
self.assertEqual(prepared, environment)
|
||||
self.assertIsNot(prepared, environment)
|
||||
self.assertNotIn("SETTINGS_ENCRYPTION_KEY", prepared)
|
||||
self.assertNotIn("MAGENT_RUNTIME_MANAGED", prepared)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_auto_mode_whitespace_jwt_is_treated_as_unset(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto", "JWT_SECRET": " "}, self.data)
|
||||
self.assertEqual(prepared["JWT_SECRET"], self.state()["JWT_SECRET"])
|
||||
|
||||
def test_absent_application_url_uses_fixed_defaults_without_claiming_an_origin(self):
|
||||
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertFalse(prepared.get("MAGENT_APPLICATION_URL"))
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
|
||||
self.assertEqual(prepared["API_DOCS_ENABLED"], "false")
|
||||
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
|
||||
|
||||
def test_empty_application_url_is_deferred_to_setup(self):
|
||||
prepared = self.prepare(MAGENT_APPLICATION_URL="")
|
||||
self.assertEqual(prepared["MAGENT_APPLICATION_URL"], "")
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
|
||||
self.assertTrue(self.state_path.exists())
|
||||
|
||||
def test_managed_api_docs_cannot_be_enabled(self):
|
||||
for value in ("true", "1", "yes", "on", "invalid"):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(bootstrap.BootstrapError, "API_DOCS_ENABLED"):
|
||||
self.prepare(API_DOCS_ENABLED=value)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_saved_public_url_controls_restart_without_key_regeneration(self):
|
||||
original = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
state_bytes = self.state_path.read_bytes()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://saved.example.test')")
|
||||
restarted = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
|
||||
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "https://saved.example.test")
|
||||
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "https://saved.example.test")
|
||||
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "true")
|
||||
self.assertEqual(self.state_path.read_bytes(), state_bytes)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(restarted[name], original[name])
|
||||
|
||||
def test_saved_public_url_wins_over_stale_deployment_url_on_restart(self):
|
||||
self.prepare()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'http://magent.lan:3000')")
|
||||
restarted = self.prepare(CORS_ALLOW_ORIGIN="https://magent.example.test")
|
||||
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "http://magent.lan:3000")
|
||||
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "http://magent.lan:3000")
|
||||
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "false")
|
||||
|
||||
def test_invalid_saved_url_fails_closed_without_changing_keys(self):
|
||||
self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
self.create_database(admin=True)
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://user:secret@evil.test')")
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
def test_existing_database_or_recovery_sidecar_never_generates_replacement_keys(self):
|
||||
for suffix in ("", "-wal", "-shm", "-journal"):
|
||||
with self.subTest(suffix=suffix):
|
||||
path = Path(str(self.database) + suffix)
|
||||
path.write_bytes(b"existing installation data")
|
||||
try:
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(path.read_bytes(), b"existing installation data")
|
||||
self.assertFalse(self.state_path.exists())
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_lost_keys_after_initialization_are_not_recreated(self):
|
||||
self.prepare()
|
||||
self.create_database()
|
||||
self.state_path.unlink()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_fresh_manual_secrets_conflict_without_writing_state(self):
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(**{name: "synthetic-manual-secret"})
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_matching_environment_values_are_accepted_but_conflicts_never_replace_file(self):
|
||||
first = self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
keys = {name: first[name] for name in bootstrap.SECRET_NAMES}
|
||||
self.assertEqual(self.prepare(**keys), first)
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError) as raised:
|
||||
self.prepare(**{name: "conflicting-private-value"})
|
||||
self.assertNotIn("conflicting-private-value", str(raised.exception))
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
def test_custom_database_location_is_rejected_without_touching_it(self):
|
||||
custom = self.root / "other.db"
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(SQLITE_PATH=str(custom))
|
||||
self.assertFalse(custom.exists())
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_missing_or_symlink_data_directory_is_rejected(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.prepare_environment(self.environment, self.root / "missing")
|
||||
linked = self.root / "linked-data"
|
||||
self.create_symlink(linked, self.data, directory=True)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.prepare_environment(self.environment, linked)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
|
||||
def test_shared_writable_data_directory_is_rejected(self):
|
||||
self.data.chmod(0o777)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_malformed_json_oversized_and_invalid_schema_never_get_replaced(self):
|
||||
self.prepare()
|
||||
valid = self.state()
|
||||
invalid_states = [
|
||||
b"not-json", b"\xff", b"x" * (bootstrap.MAX_STATE_BYTES + 1), b"[]", b"{}",
|
||||
json.dumps({**valid, "version": True}).encode(),
|
||||
json.dumps({**valid, "version": 2}).encode(),
|
||||
json.dumps({**valid, "unexpected": "value"}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": None}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": "a" * 64}).encode(),
|
||||
json.dumps({**valid, "JWT_SECRET": "short"}).encode(),
|
||||
json.dumps({**valid, "SETUP_TOKEN": valid["JWT_SECRET"]}).encode(),
|
||||
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": "invalid-key"}).encode(),
|
||||
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(b"short").decode()}).encode(),
|
||||
]
|
||||
for index, payload in enumerate(invalid_states):
|
||||
with self.subTest(case=index):
|
||||
self.state_path.write_bytes(payload)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(self.state_path.read_bytes(), payload)
|
||||
|
||||
def test_state_directory_is_not_replaced(self):
|
||||
self.state_path.mkdir()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertTrue(self.state_path.is_dir())
|
||||
|
||||
def test_state_symlink_is_not_followed_or_replaced(self):
|
||||
self.prepare()
|
||||
target = self.root / "original-secrets.json"
|
||||
self.state_path.rename(target)
|
||||
original = target.read_bytes()
|
||||
self.create_symlink(self.state_path, target)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(target.read_bytes(), original)
|
||||
self.assertTrue(self.state_path.is_symlink())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
|
||||
def test_publicly_readable_secrets_are_rejected_without_fixing_or_overwriting_them(self):
|
||||
self.prepare()
|
||||
original = self.state_path.read_bytes()
|
||||
self.state_path.chmod(0o644)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o644)
|
||||
self.assertEqual(self.state_path.read_bytes(), original)
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX named pipes")
|
||||
def test_named_pipe_state_is_rejected_without_blocking(self):
|
||||
os.mkfifo(self.state_path, 0o600)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
self.assertTrue(stat.S_ISFIFO(self.state_path.stat().st_mode))
|
||||
|
||||
def test_https_sets_matching_cors_and_secure_cookies(self):
|
||||
prepared = self.prepare()
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], self.environment["MAGENT_APPLICATION_URL"])
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "true")
|
||||
|
||||
def test_explicit_http_lan_origin_disables_secure_cookie_flag_only(self):
|
||||
prepared = self.prepare(MAGENT_APPLICATION_URL="http://192.0.2.10:3000")
|
||||
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://192.0.2.10:3000")
|
||||
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
|
||||
|
||||
def test_invalid_origin_fails_without_creating_keys(self):
|
||||
origins = (
|
||||
"not-a-url", "https://magent.example.test/", "https://magent.example.test/path",
|
||||
"//magent.example.test", "ftp://magent.example.test", "http:/magent.example.test",
|
||||
"https://user:password@magent.example.test", "https://@magent.example.test",
|
||||
"https://magent.example.test?", "https://magent.example.test#",
|
||||
"https://magent.example.test:0", "https://magent.example.test:65536",
|
||||
"https://*.example.test", "https://magent.\ttest", "https://magent.example.test\\path",
|
||||
" https://magent.example.test", "https://magent.example.test\x00",
|
||||
)
|
||||
for origin in origins:
|
||||
with self.subTest(origin=repr(origin)), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(MAGENT_APPLICATION_URL=origin)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_cors_mismatch_or_cookie_scheme_conflict_fails_without_keys(self):
|
||||
cases = (
|
||||
{"CORS_ALLOW_ORIGIN": "https://elsewhere.example.test"},
|
||||
{"AUTH_COOKIE_SECURE": "false"},
|
||||
{"AUTH_COOKIE_SECURE": "0"},
|
||||
{"AUTH_COOKIE_SECURE": "maybe"},
|
||||
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "true"},
|
||||
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "1"},
|
||||
)
|
||||
for changes in cases:
|
||||
with self.subTest(changes=changes), self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare(**changes)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
|
||||
def test_racing_initializers_publish_and_return_one_complete_state(self):
|
||||
barrier = Barrier(8)
|
||||
|
||||
def initialize(_):
|
||||
barrier.wait(timeout=10)
|
||||
return self.prepare()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(executor.map(initialize, range(8)))
|
||||
for result in results:
|
||||
self.assertEqual(result, results[0])
|
||||
state = self.state()
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertEqual(state[name], results[0][name])
|
||||
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
|
||||
|
||||
def test_token_command_requires_managed_mode_and_does_not_create_state(self):
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token({}, self.data)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
with self.assertRaises((bootstrap.BootstrapError, FileNotFoundError)):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertFalse(self.state_path.exists())
|
||||
self.assertFalse(self.database.exists())
|
||||
|
||||
def test_token_command_does_not_create_an_uninitialized_database(self):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertFalse(self.database.exists())
|
||||
|
||||
def test_token_command_returns_only_initial_token_using_readonly_closed_connection(self):
|
||||
prepared = self.prepare()
|
||||
self.create_database()
|
||||
before = {path.name: path.read_bytes() for path in self.data.iterdir()}
|
||||
connections = []
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
self.assertTrue(kwargs.get("uri"))
|
||||
self.assertTrue(args[0].endswith("?mode=ro"))
|
||||
connection = real_connect(*args, **kwargs)
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
connection.execute("INSERT INTO users VALUES ('admin')")
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
with patch.object(bootstrap.sqlite3, "connect", side_effect=connect):
|
||||
token = bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(token, prepared["SETUP_TOKEN"])
|
||||
self.assertEqual({path.name: path.read_bytes() for path in self.data.iterdir()}, before)
|
||||
for connection in connections:
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
connection.execute("SELECT 1")
|
||||
|
||||
def test_token_command_refuses_once_any_admin_exists_even_before_setup_completion(self):
|
||||
self.prepare()
|
||||
self.create_database(admin=True)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_token_command_refuses_completed_setup_even_without_admin(self):
|
||||
self.prepare()
|
||||
self.create_database(completed=1)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_token_command_refuses_unknown_or_invalid_database_state(self):
|
||||
self.prepare()
|
||||
for payload in (b"", b"not a SQLite database"):
|
||||
with self.subTest(payload=payload):
|
||||
self.database.write_bytes(payload)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(self.database.read_bytes(), payload)
|
||||
self.database.unlink()
|
||||
self.create_database()
|
||||
with closing(sqlite3.connect(self.database)) as connection:
|
||||
with connection:
|
||||
connection.execute("DELETE FROM installation_setup")
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
|
||||
def test_existing_database_symlink_is_rejected_even_with_valid_state(self):
|
||||
self.prepare()
|
||||
self.create_database()
|
||||
target = self.root / "other.db"
|
||||
self.database.rename(target)
|
||||
original = target.read_bytes()
|
||||
self.create_symlink(self.database, target)
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertEqual(target.read_bytes(), original)
|
||||
|
||||
def test_existing_database_directory_is_rejected_even_with_valid_state(self):
|
||||
self.prepare()
|
||||
self.database.mkdir()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
self.prepare()
|
||||
with self.assertRaises(bootstrap.BootstrapError):
|
||||
bootstrap.setup_token(self.environment, self.data)
|
||||
self.assertTrue(self.database.is_dir())
|
||||
|
||||
def test_startup_passes_keys_to_runtime_without_printing_them(self):
|
||||
prepared = self.prepare()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.dict(os.environ, self.environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord", "-c", "config"]), \
|
||||
patch.object(bootstrap, "prepare_environment", return_value=prepared), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_called_once_with("supervisord", ["supervisord", "-c", "config"], prepared)
|
||||
self.assertIn("setup-token", stdout.getvalue())
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
for name in bootstrap.SECRET_NAMES:
|
||||
self.assertNotIn(prepared[name], stdout.getvalue() + stderr.getvalue())
|
||||
|
||||
def test_disabled_startup_does_not_print_managed_install_instructions(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
environment = {"JWT_SECRET": "manual-test-value"}
|
||||
with patch.dict(os.environ, environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_called_once_with("supervisord", ["supervisord"], environment)
|
||||
self.assertEqual(stdout.getvalue() + stderr.getvalue(), "")
|
||||
|
||||
def test_cli_explicit_token_command_prints_only_token_not_other_keys(self):
|
||||
prepared = self.prepare()
|
||||
self.create_database()
|
||||
retrieve = bootstrap.setup_token
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.dict(os.environ, self.environment, clear=True), \
|
||||
patch.object(bootstrap.sys, "argv", ["bootstrap", "setup-token"]), \
|
||||
patch.object(bootstrap, "setup_token", side_effect=lambda env: retrieve(env, self.data)), \
|
||||
patch.object(bootstrap.os, "execvpe") as execute, \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 0)
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(stdout.getvalue(), prepared["SETUP_TOKEN"] + "\n")
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
self.assertNotIn(prepared["JWT_SECRET"], stdout.getvalue())
|
||||
self.assertNotIn(prepared["SETTINGS_ENCRYPTION_KEY"], stdout.getvalue())
|
||||
|
||||
def test_cli_unexpected_io_failure_never_logs_sensitive_exception_details(self):
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
|
||||
patch.object(bootstrap, "prepare_environment", side_effect=OSError("private-secret-material")), \
|
||||
redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
self.assertEqual(bootstrap.main(), 1)
|
||||
self.assertEqual(stdout.getvalue(), "")
|
||||
self.assertNotIn("private-secret-material", stderr.getvalue())
|
||||
self.assertIn("Check volume permissions", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Unit checks for the release smoke harness; no Docker or network required."""
|
||||
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
HELPER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "container_smoke.py"
|
||||
SPEC = importlib.util.spec_from_file_location("magent_container_smoke", HELPER_PATH)
|
||||
smoke = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(smoke)
|
||||
|
||||
|
||||
def response_headers(**changes):
|
||||
headers = Message()
|
||||
for key, value in {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-test-nonce' 'strict-dynamic'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
**changes,
|
||||
}.items():
|
||||
headers[key] = value
|
||||
return headers
|
||||
|
||||
|
||||
class ContainerPackagingHarnessTests(unittest.TestCase):
|
||||
def test_backup_multipart_preserves_binary_content_and_required_fields(self):
|
||||
content = b"MAGENT-BACKUP\x00\x01\xff\r\n\x00encrypted"
|
||||
body, content_type = smoke.backup_restore_upload(content, "synthetic backup passphrase")
|
||||
parsed = BytesParser(policy=default).parsebytes(
|
||||
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + body,
|
||||
)
|
||||
fields = {part.get_param("name", header="content-disposition"): part
|
||||
for part in parsed.iter_parts()}
|
||||
self.assertEqual(set(fields), {"passphrase", "confirmation", "file"})
|
||||
self.assertEqual(fields["passphrase"].get_payload(decode=True), b"synthetic backup passphrase")
|
||||
self.assertEqual(fields["confirmation"].get_payload(decode=True), b"RESTORE")
|
||||
self.assertEqual(fields["file"].get_payload(decode=True), content)
|
||||
self.assertEqual(fields["file"].get_filename(), "smoke.magent-backup")
|
||||
|
||||
def test_http_rejects_conflicting_body_encodings_without_network(self):
|
||||
with patch.object(smoke.request, "urlopen") as urlopen:
|
||||
with self.assertRaisesRegex(AssertionError, "only one encoding"):
|
||||
smoke.http("/test", payload={}, raw=b"binary")
|
||||
urlopen.assert_not_called()
|
||||
|
||||
def page(self, *, nonce="test-nonce", source="/_next/static/app.js", extra=""):
|
||||
return (
|
||||
f'<script nonce="{nonce}" src="{source}"></script>'
|
||||
f'<script nonce="{nonce}">self.__next_f.push([])</script>'
|
||||
'<link rel="stylesheet" href="/_next/static/app.css">'
|
||||
f"{extra}"
|
||||
).encode()
|
||||
|
||||
def test_static_assets_and_every_bootstrap_script_are_validated(self):
|
||||
seen = []
|
||||
|
||||
def fake_http(path):
|
||||
seen.append(path)
|
||||
if path == "/login":
|
||||
return self.page(), response_headers()
|
||||
return b"static content", response_headers(**{"Content-Type": "application/javascript"})
|
||||
|
||||
with patch.object(smoke, "http", side_effect=fake_http):
|
||||
assets = set()
|
||||
self.assertEqual(smoke.check_page("/login", assets), "test-nonce")
|
||||
self.assertEqual(assets, {"/_next/static/app.js", "/_next/static/app.css"})
|
||||
self.assertEqual(seen, ["/login", "/_next/static/app.css", "/_next/static/app.js"])
|
||||
smoke.check_page("/login", assets)
|
||||
self.assertEqual(seen[-1], "/login")
|
||||
self.assertEqual(len(seen), 4)
|
||||
|
||||
def test_nonce_mismatch_fails_before_fetching_assets(self):
|
||||
with patch.object(smoke, "http", return_value=(self.page(nonce="wrong"), response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_missing_nonce_policy_is_rejected(self):
|
||||
headers = response_headers(**{"Content-Security-Policy": "script-src 'self'"})
|
||||
with patch.object(smoke, "http", return_value=(self.page(), headers)):
|
||||
with self.assertRaisesRegex(AssertionError, "missing script nonce policy"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_development_eval_policy_is_rejected(self):
|
||||
headers = response_headers(**{
|
||||
"Content-Security-Policy": "script-src 'nonce-test-nonce' 'strict-dynamic' 'unsafe-eval'",
|
||||
})
|
||||
with patch.object(smoke, "http", return_value=(self.page(), headers)):
|
||||
with self.assertRaisesRegex(AssertionError, "development eval"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_html_fallback_for_static_asset_is_rejected(self):
|
||||
with patch.object(smoke, "http", return_value=(self.page(), response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "Asset returned HTML"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_missing_executable_script_nonce_is_rejected(self):
|
||||
page = self.page(extra='<script src="/_next/static/missing-nonce.js"></script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
|
||||
smoke.check_page("/login", set())
|
||||
|
||||
def test_inert_json_scripts_do_not_require_executable_nonce(self):
|
||||
page = self.page(extra='<script type="application/ld+json">{"name":"Magent"}</script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
cache = {"/_next/static/app.js", "/_next/static/app.css"}
|
||||
self.assertEqual(smoke.check_page("/login", cache), "test-nonce")
|
||||
|
||||
def test_external_scripts_are_not_followed_by_smoke_harness(self):
|
||||
page = self.page(extra='<script nonce="test-nonce" src="https://external.invalid/app.js"></script>')
|
||||
with patch.object(smoke, "http", return_value=(page, response_headers())):
|
||||
with self.assertRaisesRegex(AssertionError, "Unexpected external executable asset"):
|
||||
smoke.check_page("/login", {"/_next/static/app.js", "/_next/static/app.css"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.feature_access import permissions, update_permissions
|
||||
from backend.app.routers import identities
|
||||
from backend.app.services import duplicate_accounts as duplicates, identity_review as review
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
JF, SERVER = 'a' * 32, 'b' * 32
|
||||
|
||||
|
||||
class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.keep = db.get_user_by_username('Viewer')['id']
|
||||
with db._connect() as conn:
|
||||
self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
|
||||
jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
|
||||
jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
|
||||
link_user('Viewer', JF, 'http://jf')
|
||||
self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
|
||||
self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
|
||||
for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
|
||||
mocked = patch.object(review, name, return_value=value)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
|
||||
return_value={JF: {'state': 'matched', 'id': JF}})
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
|
||||
async def test_consolidation_preserves_history_and_restrictive_access(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
|
||||
conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
|
||||
db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
|
||||
db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
|
||||
item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
|
||||
before = review.read_snapshot()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
|
||||
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||
self.assertEqual(preview['keep_id'], self.keep)
|
||||
self.assertNotIn('old-hash', json.dumps(preview))
|
||||
result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(result['consolidated'], 1)
|
||||
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||
user = db.get_user_by_username('Viewer')
|
||||
self.assertEqual(user['id'], self.keep)
|
||||
self.assertFalse(user['auto_search_enabled'])
|
||||
self.assertFalse(permissions(user)['issues'])
|
||||
self.assertTrue(user['is_expired'])
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
|
||||
self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
|
||||
archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
|
||||
self.assertEqual(len(archive['users']), 2)
|
||||
self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
|
||||
report, _, _ = await review.review_identities()
|
||||
self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
|
||||
self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
|
||||
|
||||
async def test_choose_other_row_retains_its_settings_and_moves_link(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
|
||||
preview = await duplicates.repair_duplicates(self.keep, self.extra)
|
||||
self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
|
||||
await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
|
||||
self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
|
||||
|
||||
async def test_changed_permission_or_identity_rejects_stale_preview(self):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
update_permissions({'stats': False}, 'Viewer')
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
|
||||
with self.assertRaises(HTTPException):
|
||||
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||
|
||||
async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
||||
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
|
||||
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
||||
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
|
||||
with db._connect() as conn:
|
||||
conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
|
||||
|
||||
async def test_creation_rejects_case_and_whitespace_variants(self):
|
||||
for name in ('viewer', 'VIEWER', ' Viewer '):
|
||||
self.assertFalse(db.create_user_if_missing(name, 'unused'))
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.create_user(name, 'unused')
|
||||
|
||||
async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
|
||||
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
|
||||
self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
|
||||
self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
|
||||
|
||||
async def test_concurrent_imports_create_only_one_normalized_account(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
|
||||
self.assertEqual(sorted(results), [False, True])
|
||||
|
||||
def seed_delivery(self, state='queued'):
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
for identity in (self.keep, self.extra):
|
||||
conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
|
||||
version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
|
||||
(identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
|
||||
period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
|
||||
values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
|
||||
'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
|
||||
'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
|
||||
conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
|
||||
|
||||
async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
|
||||
self.seed_delivery()
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
with db._connect() as conn:
|
||||
for prefix in ('email_recap', 'newsletter'):
|
||||
delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
|
||||
self.assertEqual(delivery, (self.keep, 'cancelled'))
|
||||
subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
|
||||
self.assertEqual(subs, [(self.keep, 'enabled')])
|
||||
|
||||
async def test_sending_email_blocks_repair_without_removing_accounts(self):
|
||||
self.seed_delivery('sending')
|
||||
preview = await duplicates.repair_duplicates(self.extra)
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertIsNotNone(db.get_user_by_id(self.extra))
|
||||
|
||||
async def test_duplicate_endpoints_are_admin_only(self):
|
||||
app = FastAPI(); app.include_router(identities.router)
|
||||
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
|
||||
with TestClient(app) as client:
|
||||
for path in ('check', 'confirm'):
|
||||
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
||||
|
||||
|
||||
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
|
||||
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
|
||||
preview = await duplicates.repair_duplicates(self.keep)
|
||||
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
|
||||
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
|
||||
@@ -0,0 +1,575 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatError
|
||||
from backend.app.routers import recaps as router
|
||||
from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
|
||||
from backend.app.services.jellyfin_identity import link_user, source_key
|
||||
from backend.app.services.monthly_reports import change, month_periods, shift_month
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
def fixture_report():
|
||||
periods = month_periods(None, datetime.now(timezone.utc))
|
||||
summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
|
||||
changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
|
||||
changes['requests'] = change(3, 2)
|
||||
return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
|
||||
'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
|
||||
{'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
|
||||
'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
|
||||
|
||||
|
||||
def runtime():
|
||||
return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
|
||||
jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
|
||||
magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
|
||||
magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
|
||||
magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
|
||||
magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
|
||||
|
||||
|
||||
class RecapFixture(TempDatabaseMixin):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
|
||||
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||
self.user = db.get_user_by_username('viewer')
|
||||
self.runtime = runtime()
|
||||
for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
|
||||
(recaps, 'smtp_email_config_ready', (True, 'ok'))]:
|
||||
mocked = patch.object(target, name, return_value=value)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
|
||||
env.start(); self.addCleanup(env.stop)
|
||||
self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
|
||||
store.save_settings(self.config, datetime.now(timezone.utc))
|
||||
self.report = fixture_report()
|
||||
|
||||
def subscribe(self, timestamp=None):
|
||||
now = time.time() if timestamp is None else timestamp
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
|
||||
sub = store.subscription(self.user['id'])
|
||||
self.assertTrue(store.confirm(sub, now + 1))
|
||||
return store.subscription(self.user['id']), token
|
||||
|
||||
def queue(self, sub=None, request_id='request-1'):
|
||||
if sub is None:
|
||||
sub, _ = self.subscribe()
|
||||
return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
|
||||
|
||||
def delivery(self, delivery_id):
|
||||
return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
|
||||
|
||||
|
||||
class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
|
||||
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||
result = await recaps.subscribe(self.user)
|
||||
self.assertEqual(result['state'], 'pending')
|
||||
report.assert_not_called()
|
||||
recipient, rendered, _ = sender.call_args.args
|
||||
self.assertEqual(recipient, 'viewer@example.test')
|
||||
self.assertNotIn('Severance', rendered['body_html'])
|
||||
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
|
||||
token = parse_qs(urlsplit(url).fragment)['token'][0]
|
||||
self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
|
||||
self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
|
||||
self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'confirm', apply=True)
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||
|
||||
async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
|
||||
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
|
||||
with self.assertRaises(recaps.RecapError) as exc:
|
||||
await recaps.subscribe(self.user)
|
||||
self.assertEqual(exc.exception.status, 502)
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
with self.assertRaises(recaps.RecapError) as exc:
|
||||
await recaps.subscribe(self.user)
|
||||
self.assertEqual(exc.exception.status, 429)
|
||||
sender.assert_not_called()
|
||||
|
||||
def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
|
||||
sub, _ = self.subscribe()
|
||||
delivery_id = self.queue(sub)
|
||||
token = sub['unsubscribe_token']
|
||||
self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
|
||||
recaps.token_action(token, 'unsubscribe', apply=True)
|
||||
self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
def test_expired_confirmation_does_not_subscribe(self):
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.token_action(token, 'confirm', apply=True)
|
||||
|
||||
def test_email_change_back_does_not_restore_consent(self):
|
||||
self.subscribe()
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
db.set_user_email('viewer', 'viewer@example.test')
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
|
||||
def test_changed_link_or_source_requires_new_consent(self):
|
||||
self.subscribe()
|
||||
with store.transaction() as conn:
|
||||
conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
with store.transaction() as conn:
|
||||
conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
|
||||
self.runtime.jellyfin_base_url = 'http://other-jellyfin'
|
||||
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
|
||||
|
||||
def test_missing_email_or_stored_identity_cannot_subscribe(self):
|
||||
db.set_user_email('viewer', None)
|
||||
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||
db.set_user_email('viewer', 'viewer@example.test')
|
||||
with store.transaction() as conn:
|
||||
conn.execute('DELETE FROM jellyfin_user_links')
|
||||
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
|
||||
|
||||
def test_confirmation_rechecks_email_atomically(self):
|
||||
store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
|
||||
old = store.subscription(self.user['id'])
|
||||
db.set_user_email('viewer', 'different@example.test')
|
||||
self.assertFalse(store.confirm(old, time.time()))
|
||||
|
||||
|
||||
class RecapScheduleTests(RecapFixture, unittest.TestCase):
|
||||
def test_defaults_are_paused_and_no_users_are_opted_in(self):
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertEqual(store.history()['subscribers'], 0)
|
||||
self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
|
||||
|
||||
def test_utc_next_send_month_end_leap_year_and_new_year(self):
|
||||
for now, expected in [
|
||||
(datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
|
||||
(datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
|
||||
(datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
|
||||
(datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
|
||||
self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
|
||||
|
||||
def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
|
||||
before = datetime(2026, 8, 30, tzinfo=timezone.utc)
|
||||
self.subscribe(before.timestamp())
|
||||
config = store.save_settings({**self.config, 'enabled': True}, before)
|
||||
self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||
db.create_user('late', 'Example-Password123!', email='late@example.test')
|
||||
late = db.get_user_by_username('late')
|
||||
store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
|
||||
store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
|
||||
now = datetime(2026, 9, 5, tzinfo=timezone.utc)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
counts = list(pool.map(store.enqueue_due, [now] * 4))
|
||||
self.assertEqual(sum(counts), 1)
|
||||
rows = store.history()['deliveries']
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]['month'], '2026-08')
|
||||
self.assertEqual(rows[0]['email'], 'viewer@example.test')
|
||||
# Revisit the same due date after a restart: the durable unique key still wins.
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
|
||||
self.assertEqual(store.enqueue_due(now), 0)
|
||||
|
||||
def test_long_downtime_does_not_backfill_multiple_months(self):
|
||||
before = datetime(2026, 5, 1, tzinfo=timezone.utc)
|
||||
self.subscribe(before.timestamp())
|
||||
store.save_settings({**self.config, 'enabled': True}, before)
|
||||
self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
|
||||
self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
|
||||
|
||||
def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
|
||||
now = datetime(2026, 9, 9, tzinfo=timezone.utc)
|
||||
self.subscribe(now.timestamp())
|
||||
result = store.save_settings({**self.config, 'enabled': True}, now)
|
||||
self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
|
||||
self.assertEqual(store.enqueue_due(now), 0)
|
||||
store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
|
||||
store.save_settings(self.config, now)
|
||||
self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
|
||||
self.assertIsNone(store.settings()['next_send_at'])
|
||||
|
||||
|
||||
class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def run_claim(self):
|
||||
delivery = store.claim_delivery(time.time())
|
||||
self.assertIsNotNone(delivery)
|
||||
await recaps.process_delivery(delivery)
|
||||
|
||||
async def test_private_report_is_delivered_once_using_confirmed_account(self):
|
||||
delivery_id = self.queue()
|
||||
sent = []
|
||||
def capture(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
|
||||
sent.append((recipient, rendered, message_id))
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
|
||||
await recaps.run_once()
|
||||
await recaps.run_once()
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertEqual(sent[0][0], 'viewer@example.test')
|
||||
self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
|
||||
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
|
||||
self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
|
||||
|
||||
def test_concurrent_claim_and_test_deduplication(self):
|
||||
sub, _ = self.subscribe()
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
ids = list(pool.map(lambda _: self.queue(sub), range(4)))
|
||||
rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
|
||||
self.assertEqual(len(set(ids)), 1)
|
||||
self.assertEqual(sum(row is not None for row in rows), 1)
|
||||
with self.assertRaises(ValueError):
|
||||
self.queue(sub, 'another-click')
|
||||
|
||||
async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
|
||||
delivery_id = self.queue()
|
||||
async def report(*args):
|
||||
db.set_user_email('viewer', 'other@example.test')
|
||||
return self.report
|
||||
def transport(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.fail('Private data must not reach SMTP DATA after an address change')
|
||||
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_stats_permission_revoked_during_report_cancels_email(self):
|
||||
from backend.app.feature_access import update_permissions
|
||||
delivery_id = self.queue()
|
||||
db.set_user_role('viewer', 'user')
|
||||
async def report(*args):
|
||||
update_permissions({'stats': False}, 'viewer')
|
||||
return self.report
|
||||
def transport(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.fail('Report must not be sent after stats permission is revoked')
|
||||
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
|
||||
for kind in ['blocked', 'expired', 'deleted']:
|
||||
with self.subTest(kind=kind):
|
||||
# Each subcase starts with a fresh account and confirmed subscription.
|
||||
db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
|
||||
account = db.get_user_by_username(kind)
|
||||
link_user(kind, f'jf-{kind}', 'http://jellyfin')
|
||||
store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
|
||||
store.confirm(store.subscription(account['id']), time.time())
|
||||
delivery_id = self.queue(store.subscription(account['id']), kind)
|
||||
with store.transaction() as conn:
|
||||
if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
|
||||
elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
|
||||
else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
|
||||
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
|
||||
await recaps.run_once()
|
||||
sender.assert_not_called(); report.assert_not_called()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
|
||||
|
||||
async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
|
||||
for attempt in range(1, 4):
|
||||
await self.run_claim()
|
||||
row = self.delivery(delivery_id)
|
||||
self.assertEqual(row['attempts'], attempt)
|
||||
self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
|
||||
if attempt < 3:
|
||||
self.assertGreater(row['next_attempt_at'], time.time() + 250)
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
|
||||
self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
|
||||
self.assertIsNone(store.claim_delivery(time.time()))
|
||||
|
||||
async def test_ambiguous_smtp_failure_never_automatically_retries(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
|
||||
await self.run_claim()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
self.assertIsNone(store.claim_delivery(time.time() + 86400))
|
||||
|
||||
def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
|
||||
delivery_id = self.queue()
|
||||
first = store.claim_delivery(time.time())
|
||||
second = store.claim_delivery(time.time() + 1801)
|
||||
self.assertNotEqual(first['claim'], second['claim'])
|
||||
self.assertFalse(store.begin_sending(first, time.time()))
|
||||
self.assertTrue(store.begin_sending(second, time.time()))
|
||||
store.claim_delivery(time.time() + 1801)
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
store.finish(first, 'sent', 'Old worker', time.time())
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
|
||||
|
||||
async def test_partial_or_over_limit_report_is_not_emailed(self):
|
||||
delivery_id = self.queue()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
|
||||
await self.run_claim()
|
||||
sender.assert_not_called()
|
||||
self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
|
||||
|
||||
|
||||
class RecapApiTests(RecapFixture, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
self.app = app
|
||||
self.client = TestClient(app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def login(self, role='admin'):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
|
||||
|
||||
def test_authentication_roles_and_recipient_override(self):
|
||||
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
|
||||
self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
|
||||
self.login('user')
|
||||
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
|
||||
self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
|
||||
self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
|
||||
self.login()
|
||||
result = self.client.get('/admin/email-recaps')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||
self.assertNotIn('PRIVATE-STATS-KEY', result.text)
|
||||
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
|
||||
def test_url_and_schedule_validation_do_not_write_partial_settings(self):
|
||||
self.login()
|
||||
for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
|
||||
result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
|
||||
self.assertEqual(result.status_code, 422, value)
|
||||
for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
|
||||
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
|
||||
with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
|
||||
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
|
||||
self.assertEqual(store.settings()['public_url'], self.config['public_url'])
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
|
||||
def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
|
||||
self.login()
|
||||
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
|
||||
result = self.client.get('/admin/email-recaps/preview')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', result.text)
|
||||
sender.assert_not_called()
|
||||
payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
|
||||
self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
|
||||
self.subscribe()
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
first = self.client.post('/admin/email-recaps/test', json=payload)
|
||||
second = self.client.post('/admin/email-recaps/test', json=payload)
|
||||
self.assertEqual(first.status_code, 202)
|
||||
self.assertEqual(first.json()['id'], second.json()['id'])
|
||||
sender.assert_not_called()
|
||||
|
||||
def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
|
||||
self.login(); sub, token = self.subscribe()
|
||||
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
|
||||
self.assertEqual(result.status_code, 422)
|
||||
self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
|
||||
result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
|
||||
|
||||
|
||||
class RecapEmailTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runtime = runtime()
|
||||
patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
|
||||
patched.start(); self.addCleanup(patched.stop)
|
||||
self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
|
||||
|
||||
def fake_smtp(self):
|
||||
smtp = MagicMock()
|
||||
smtp.mail.return_value = (250, b'OK')
|
||||
smtp.rcpt.return_value = (250, b'OK')
|
||||
smtp.data.return_value = (250, b'Accepted')
|
||||
return smtp
|
||||
|
||||
def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
|
||||
report = fixture_report()
|
||||
report['top_titles'][0]['title'] = '<img src=x onerror=alert(1)>'
|
||||
rendered = mail.render_recap(report, '<script>alert(1)</script>', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
|
||||
self.assertNotIn('<script>', rendered['body_html'])
|
||||
self.assertNotIn('<img src=x', rendered['body_html'])
|
||||
self.assertIn('<script>', rendered['body_html'])
|
||||
self.assertNotIn('PRIVATE-TOKEN', str(rendered))
|
||||
self.assertIn('Unsubscribe', rendered['body_text'])
|
||||
self.assertIn('UTC', rendered['body_text'])
|
||||
self.assertIn('1,500', rendered['body_html'])
|
||||
|
||||
def test_mailbox_validation_rejects_injection_and_multiple_recipients(self):
|
||||
for value in ['a@example.test\r\nBcc:b@example.test', 'a@example.test,b@example.test', 'Name <a@example.test>', 'x@', 'a;b@example.test']:
|
||||
self.assertIsNone(mail.valid_email(value))
|
||||
|
||||
def test_smtp_acceptance_survives_quit_error_and_preserves_mime_message_id(self):
|
||||
smtp = self.fake_smtp()
|
||||
smtp.quit.side_effect = smtplib.SMTPServerDisconnected('after acceptance')
|
||||
before = MagicMock()
|
||||
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', before)
|
||||
before.assert_called_once()
|
||||
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
|
||||
self.assertEqual(message['Message-ID'], '<stable@example.test>')
|
||||
self.assertEqual(message['To'], 'viewer@example.test')
|
||||
self.assertIsNone(message['Bcc'])
|
||||
self.assertIn('1,500', message.get_body(('plain',)).get_content())
|
||||
self.assertIn('<!doctype html>', message.get_body(('html',)).get_content())
|
||||
|
||||
def test_temporary_permanent_and_ambiguous_delivery_failures(self):
|
||||
for operation, failure, expected in [
|
||||
('mail', (451, b'temporary PRIVATE-KEY'), 'retry'), ('rcpt', (550, b'bad recipient'), 'failed'),
|
||||
('data', (451, b'retry'), 'retry'), ('data', smtplib.SMTPServerDisconnected('lost after DATA'), 'unknown'),
|
||||
('rcpt', smtplib.SMTPServerDisconnected('lost before DATA'), 'retry')]:
|
||||
smtp = self.fake_smtp()
|
||||
if isinstance(failure, Exception): getattr(smtp, operation).side_effect = failure
|
||||
else: getattr(smtp, operation).return_value = failure
|
||||
with self.subTest(operation=operation, expected=expected), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
with self.assertRaises(mail.DeliveryError) as exc:
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>')
|
||||
self.assertEqual(exc.exception.state, expected)
|
||||
self.assertNotIn('PRIVATE-KEY', exc.exception.detail)
|
||||
|
||||
def test_consent_cancellation_happens_before_smtp_data(self):
|
||||
smtp = self.fake_smtp()
|
||||
with patch.object(mail.smtplib, 'SMTP', return_value=smtp), self.assertRaises(mail.DeliveryCancelled):
|
||||
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', MagicMock(side_effect=mail.DeliveryCancelled))
|
||||
smtp.data.assert_not_called()
|
||||
|
||||
def test_real_smtp_is_captured_locally_without_external_delivery(self):
|
||||
messages = []
|
||||
class Capture(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
self.wfile.write(b'220 local capture\r\n')
|
||||
while line := self.rfile.readline():
|
||||
command = line.split(b' ', 1)[0].strip().upper()
|
||||
if command in (b'EHLO', b'HELO'):
|
||||
self.wfile.write(b'250-localhost\r\n250 SIZE 1000000\r\n')
|
||||
elif command == b'DATA':
|
||||
self.wfile.write(b'354 Send content\r\n')
|
||||
data = []
|
||||
while (part := self.rfile.readline()) != b'.\r\n':
|
||||
if not part: return
|
||||
data.append(part[1:] if part.startswith(b'..') else part)
|
||||
messages.append(b''.join(data))
|
||||
self.wfile.write(b'250 Captured\r\n')
|
||||
elif command == b'QUIT':
|
||||
self.wfile.write(b'221 Bye\r\n'); return
|
||||
else:
|
||||
self.wfile.write(b'250 OK\r\n')
|
||||
with socketserver.TCPServer(('127.0.0.1', 0), Capture) as server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.runtime.magent_notify_email_smtp_port = server.server_address[1]
|
||||
try:
|
||||
mail.send_email('viewer@example.test', self.rendered, '<local-capture@example.test>')
|
||||
finally:
|
||||
server.shutdown(); thread.join(timeout=5)
|
||||
self.assertEqual(len(messages), 1)
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(messages[0])
|
||||
self.assertEqual(parsed['Message-ID'], '<local-capture@example.test>')
|
||||
self.assertIn('Severance', parsed.get_body(('html',)).get_content())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_confirmation_defaults_to_manual_without_changing_schedule(self):
|
||||
with patch.object(mail, 'send_email'):
|
||||
result = await recaps.subscribe(self.user)
|
||||
self.assertFalse(result['automatic_monthly'])
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertEqual(result['state'], 'pending')
|
||||
with self.assertRaises(recaps.RecapError):
|
||||
recaps.queue_personal(self.user, None, 'pending')
|
||||
|
||||
async def test_manual_current_month_delivers_with_monthly_schedule_off(self):
|
||||
sub, _ = self.subscribe()
|
||||
store.set_automatic(self.user['id'], False)
|
||||
month = datetime.now(timezone.utc).strftime('%Y-%m')
|
||||
queued = recaps.queue_personal(self.user, month, 'manual-1')
|
||||
self.assertEqual(recaps.queue_personal(self.user, month, 'manual-1')['id'], queued['id'])
|
||||
report = {**self.report, **month_periods(month, datetime.now(timezone.utc))}
|
||||
def send(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
self.assertEqual(recipient, self.user['email'])
|
||||
self.assertIn('so far', rendered['subject'])
|
||||
self.assertNotIn('[Test]', rendered['subject'])
|
||||
with patch.object(recaps, 'get_monthly_report', new_callable=AsyncMock, return_value=report), patch.object(mail, 'send_email', side_effect=send):
|
||||
await recaps.process_delivery(store.claim_delivery(time.time()))
|
||||
self.assertEqual(self.delivery(queued['id'])['state'], 'sent')
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertFalse(store.subscription(self.user['id'])['automatic_monthly'])
|
||||
with self.assertRaises(recaps.RecapError) as error:
|
||||
recaps.queue_personal(self.user, month, 'manual-2')
|
||||
self.assertEqual(error.exception.status, 429)
|
||||
|
||||
async def test_automatic_opt_out_cancels_scheduled_but_keeps_manual(self):
|
||||
sub, _ = self.subscribe()
|
||||
with store.transaction() as conn:
|
||||
scheduled = store._enqueue(conn, sub, self.report['month'], 'scheduled', 'scheduled-fixture', self.config['public_url'], time.time())
|
||||
manual = recaps.queue_personal(self.user, None, 'manual')
|
||||
store.set_automatic(self.user['id'], False)
|
||||
self.assertEqual(self.delivery(scheduled)['state'], 'cancelled')
|
||||
self.assertEqual(self.delivery(manual['id'])['state'], 'queued')
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
|
||||
now = datetime.now(timezone.utc)
|
||||
store.save_settings({**self.config, 'enabled': True}, now)
|
||||
self.assertEqual(store.enqueue_due(now + timedelta(days=40)), 0)
|
||||
|
||||
async def test_changed_identity_cancels_manual_delivery(self):
|
||||
self.subscribe()
|
||||
queued = recaps.queue_personal(self.user, None, 'manual')
|
||||
delivery = store.claim_delivery(time.time())
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
with patch.object(mail, 'send_email') as send:
|
||||
await recaps.process_delivery(delivery)
|
||||
send.assert_not_called()
|
||||
self.assertEqual(self.delivery(queued['id'])['state'], 'cancelled')
|
||||
|
||||
async def test_regular_user_can_only_send_to_self(self):
|
||||
self.subscribe()
|
||||
app = FastAPI(); app.include_router(router.router)
|
||||
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'stats': True}}
|
||||
client = TestClient(app)
|
||||
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
|
||||
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
|
||||
self.assertEqual(client.post('/profile/email-recaps/send', json={**body, **extra}).status_code, 422)
|
||||
self.assertEqual(client.post('/profile/email-recaps/send', json=body).status_code, 202)
|
||||
response = client.get('/profile/email-recaps')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(len(response.json()['deliveries']), 1)
|
||||
@@ -0,0 +1,65 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts.check_environment_docs import (
|
||||
Setting,
|
||||
check_documentation,
|
||||
python_environment_names,
|
||||
settings_inventory,
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentDocumentationTests(unittest.TestCase):
|
||||
def test_reference_covers_repository_variables_and_defaults(self):
|
||||
errors, count = check_documentation()
|
||||
self.assertGreater(count, 100)
|
||||
self.assertEqual(errors, [], "\n".join(errors))
|
||||
|
||||
def test_settings_parser_preserves_implicit_names_alias_order_and_defaults(self):
|
||||
source = '''
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="")
|
||||
app_name: str = "Example"
|
||||
service_url: str = Field(default=None, validation_alias=AliasChoices("SERVICE_URL", "OLD_URL"))
|
||||
enabled: bool = Field(default=False, validation_alias="ENABLED")
|
||||
interval: int = Field(default=60)
|
||||
build_number: str = Field(default=BUILD_NUMBER)
|
||||
'''
|
||||
self.assertEqual(settings_inventory(source), [
|
||||
Setting(("APP_NAME",), '"Example"'),
|
||||
Setting(("SERVICE_URL", "OLD_URL"), "null"),
|
||||
Setting(("ENABLED",), "false"),
|
||||
Setting(("INTERVAL",), "60"),
|
||||
Setting(("BUILD_NUMBER",), "@BUILD_NUMBER"),
|
||||
])
|
||||
|
||||
def test_python_scanner_handles_reads_writes_and_bootstrap_mapping(self):
|
||||
source = '''
|
||||
os.getenv("METRICS_ENABLED", "false")
|
||||
os.environ.get("WORKERS_ENABLED", "true")
|
||||
environment.get("MANAGED_SECRETS", "auto")
|
||||
prepared["GENERATED_KEY"] = "not-a-real-key"
|
||||
other.get("NOT_AN_ENVIRONMENT_VARIABLE")
|
||||
environment.get("lowercase-internal-key")
|
||||
'''
|
||||
self.assertEqual(python_environment_names(source), {
|
||||
"METRICS_ENABLED", "WORKERS_ENABLED", "MANAGED_SECRETS", "GENERATED_KEY",
|
||||
})
|
||||
|
||||
def test_scanning_never_executes_source_or_imports_settings(self):
|
||||
source = '\ufeffraise RuntimeError("must not execute")\nos.getenv("SAFE_TO_SCAN")\n'
|
||||
self.assertEqual(python_environment_names(source), {"SAFE_TO_SCAN"})
|
||||
|
||||
def test_reference_guard_reports_missing_variables_and_stale_defaults(self):
|
||||
document = '| `RETRY_SECONDS` | `30` | Retry interval |'
|
||||
source = 'class Settings(BaseSettings):\n retry_seconds: int = 60\n'
|
||||
with patch("scripts.check_environment_docs.Path.read_text", side_effect=[document, source]), \
|
||||
patch("scripts.check_environment_docs.runtime_environment_names", return_value={"NEW_FLAG"}):
|
||||
errors, count = check_documentation()
|
||||
self.assertEqual(count, 2)
|
||||
self.assertIn("Undocumented environment variable: NEW_FLAG", errors)
|
||||
self.assertTrue(any("Stale source default for RETRY_SECONDS" in error for error in errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.config import settings
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app import db
|
||||
from backend.app.feature_access import FEATURES, permissions, update_permissions
|
||||
from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
|
||||
from backend.app.security import create_access_token
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
access = patch.object(
|
||||
requests,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
||||
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
||||
self.user = db.get_user_by_username('feature-viewer')
|
||||
app = FastAPI()
|
||||
for module in (admin, auth, events, insights, portal, recaps, requests):
|
||||
app.include_router(module.router)
|
||||
self.client = TestClient(app)
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||
|
||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||
db.init_db()
|
||||
fresh = db.get_user_by_username(self.user['username'])
|
||||
self.assertTrue(fresh['invite_management_enabled'])
|
||||
self.assertFalse(permissions(fresh)['stats'])
|
||||
db.set_user_invite_management_enabled(self.user['username'], False)
|
||||
self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
|
||||
|
||||
def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
|
||||
update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
|
||||
endpoints = [
|
||||
('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
|
||||
('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
|
||||
('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
|
||||
('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
|
||||
('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
|
||||
('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
|
||||
('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
|
||||
('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
|
||||
('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
|
||||
('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
|
||||
('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
|
||||
('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
|
||||
('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
|
||||
('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
|
||||
]
|
||||
for method, path, payload in endpoints:
|
||||
with self.subTest(path=path, method=method):
|
||||
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
|
||||
self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
|
||||
self.assertEqual(self.client.get('/auth/profile').status_code, 200)
|
||||
|
||||
def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
|
||||
self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
|
||||
for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
|
||||
self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
|
||||
response = self.client.put('/admin/users/features/bulk', json={'issues': False})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()['updated'], 1)
|
||||
self.assertFalse(permissions(self.user)['issues'])
|
||||
self.assertTrue(permissions(self.user)['requests'])
|
||||
self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
|
||||
self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
|
||||
self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
|
||||
|
||||
def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
|
||||
issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
|
||||
update_permissions({'issues': False}, self.user['username'])
|
||||
for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
self.assertEqual(self.client.get('/portal/requests').status_code, 200)
|
||||
self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
|
||||
update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
|
||||
self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
|
||||
self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
|
||||
overview = self.client.get('/portal/overview?kind=issue')
|
||||
self.assertEqual(overview.status_code, 200)
|
||||
self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
|
||||
self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
|
||||
|
||||
def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
|
||||
update_permissions({'stats': False}, self.user['username'])
|
||||
db.delete_user_by_username(self.user['username'])
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
|
||||
|
||||
|
||||
def test_open_request_stream_closes_after_permission_revocation(self):
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace
|
||||
async def scenario():
|
||||
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
||||
response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
|
||||
iterator = response.body_iterator
|
||||
self.assertIn('retry', await anext(iterator))
|
||||
update_permissions({'requests': False}, self.user['username'])
|
||||
with self.assertRaises(StopAsyncIteration):
|
||||
await anext(iterator)
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_legacy_portal_kind_normalization_cannot_bypass_permissions(self):
|
||||
update_permissions({'requests': False, 'new_requests': False, 'issues': True}, self.user['username'])
|
||||
for kind in ['request', 'REQUEST', ' Request ', ' ', '']:
|
||||
with self.subTest(kind=kind):
|
||||
self.assertEqual(self.client.get('/portal/items', params={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.get('/portal/overview', params={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||
|
||||
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.services import manual_releases
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||
collector.grab_release.assert_not_awaited()
|
||||
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||
collector.grab_release.assert_awaited_once()
|
||||
@@ -0,0 +1,472 @@
|
||||
import json
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.clients.jellystat import JellystatClient
|
||||
from backend.app.routers import identities
|
||||
from backend.app.services import identity_review as review
|
||||
from backend.app.services.jellyfin_identity import link_user, linked_user_id
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
JF = "a" * 32
|
||||
OTHER = "b" * 32
|
||||
SERVER = "c" * 32
|
||||
ADMIN = {"username": "admin", "role": "admin"}
|
||||
|
||||
|
||||
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
|
||||
self.user_id = db.get_user_by_username("Georgia")["id"]
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
|
||||
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
|
||||
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
|
||||
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
|
||||
runtime_patch.start()
|
||||
self.addCleanup(runtime_patch.stop)
|
||||
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
|
||||
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
|
||||
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
|
||||
|
||||
def build(self):
|
||||
local = review.read_snapshot()
|
||||
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
|
||||
|
||||
def row(self, report):
|
||||
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
|
||||
|
||||
async def test_manual_selection_resolves_different_username_without_guessing(self):
|
||||
self.jf['users'][0]['name'] = 'Different Jellyfin name'
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)['state'], 'unlinked')
|
||||
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertTrue(self.row(report)['can_confirm'])
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
|
||||
|
||||
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
|
||||
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
|
||||
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
|
||||
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(review.read_snapshot(), local)
|
||||
|
||||
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
|
||||
self.jf['users'][0]['name'] = 'Different'
|
||||
for state in ['missing', 'unavailable', 'not_configured']:
|
||||
self.js[JF] = {'state': state}
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
self.js[JF] = {'state': 'matched', 'id': JF}
|
||||
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
|
||||
self.assertEqual(self.row(report)['state'], 'conflict')
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
|
||||
self.assertEqual(error.exception.status_code, 404)
|
||||
|
||||
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
|
||||
before = review.read_snapshot()
|
||||
preview = await review.resolve_identity(self.user_id, JF)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
|
||||
self.assertEqual(error.exception.status_code, 409)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
|
||||
self.assertEqual(result['confirmed'], 1)
|
||||
|
||||
async def test_repair_replaces_wrong_local_link_and_records_before_after(self):
|
||||
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
|
||||
db.set_user_jellyseerr_id('Georgia', 999)
|
||||
before = review.read_snapshot()
|
||||
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertTrue(self.row(report)['can_confirm'])
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20)
|
||||
with closing(db._connect()) as conn:
|
||||
audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone()
|
||||
self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999)
|
||||
self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF)
|
||||
self.assertEqual(audit[2], 'admin')
|
||||
|
||||
async def test_repair_preserves_duplicate_ownership_and_server_guards(self):
|
||||
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute('DELETE FROM users WHERE username=?', ('Owner',))
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf['server_id'] = OTHER
|
||||
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertFalse(self.row(report)['can_confirm'])
|
||||
|
||||
async def test_repair_does_not_invent_missing_seerr_identity(self):
|
||||
self.seerr['users'][0]['jellyfin_id'] = OTHER
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
self.assertEqual(self.row(report)['state'], 'unlinked')
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(local, review.read_snapshot())
|
||||
|
||||
async def test_repair_audit_failure_rolls_back_links(self):
|
||||
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END")
|
||||
local = review.read_snapshot()
|
||||
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
|
||||
self.assertEqual(local, review.read_snapshot())
|
||||
|
||||
async def test_repair_rechecks_revision_and_updates_confirmed_ids(self):
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf['users'][0]['id'] = OTHER
|
||||
self.seerr['users'][0]['jellyfin_id'] = OTHER
|
||||
self.js = {OTHER: {'state': 'matched', 'id': OTHER}}
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
|
||||
preview = await review.repair_identity(self.user_id, OTHER)
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN)
|
||||
await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN)
|
||||
self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER)
|
||||
|
||||
async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self):
|
||||
self.seerr['users'] = []
|
||||
async def imported(*args, **kwargs):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
|
||||
return []
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post:
|
||||
before = review.read_snapshot()
|
||||
blocked = await review.repair_identity(self.user_id, JF)
|
||||
self.assertFalse(blocked['row']['can_confirm'])
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
self.assertEqual(preview['action'], 'import_seerr')
|
||||
self.assertTrue(preview['row']['can_confirm'])
|
||||
post.assert_not_called()
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False)
|
||||
post.assert_not_called()
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]})
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25)
|
||||
|
||||
async def test_failed_import_never_writes_local_links_or_retries(self):
|
||||
self.seerr['users'] = []
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post:
|
||||
before = review.read_snapshot()
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
self.assertEqual(error.exception.status_code, 502)
|
||||
self.assertEqual(post.await_count, 1)
|
||||
self.assertEqual(review.read_snapshot(), before)
|
||||
|
||||
async def test_import_preserves_upstream_account_when_local_save_is_blocked(self):
|
||||
self.seerr['users'] = []
|
||||
async def imported(*args, **kwargs):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
|
||||
db.set_user_jellyseerr_id('Georgia', 999)
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \
|
||||
patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete:
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
self.assertIn('Seerr import completed', error.exception.detail)
|
||||
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999)
|
||||
self.assertEqual(review.read_snapshot()['confirmations'], [])
|
||||
delete.assert_not_called()
|
||||
|
||||
async def test_import_blocks_existing_name_with_different_jellyfin_id(self):
|
||||
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}]
|
||||
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
|
||||
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
|
||||
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
|
||||
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post:
|
||||
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
|
||||
self.assertFalse(preview['row']['can_confirm'])
|
||||
with self.assertRaises(HTTPException):
|
||||
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
|
||||
post.assert_not_called()
|
||||
|
||||
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
|
||||
before = review.read_snapshot()
|
||||
report, _ = self.build()
|
||||
row = self.row(report)
|
||||
self.assertEqual(row["basis"], "suggested_username")
|
||||
self.assertEqual(row["state"], "ready")
|
||||
self.assertEqual(row["seerr"][0]["id"], 20)
|
||||
self.assertEqual(before, review.read_snapshot())
|
||||
serialized = json.dumps(report)
|
||||
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
|
||||
self.assertNotIn(private, serialized)
|
||||
|
||||
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
|
||||
report, local = self.build()
|
||||
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(result["confirmed"], 1)
|
||||
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
|
||||
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
|
||||
saved = review.read_snapshot()["confirmations"][0]
|
||||
self.assertEqual(saved["jellyfin_server_id"], SERVER)
|
||||
self.assertEqual(saved["confirmed_by"], "admin")
|
||||
db.set_user_jellyseerr_id("Georgia", 999)
|
||||
link_user("Georgia", OTHER, "http://other-server")
|
||||
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
|
||||
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
|
||||
refreshed, _ = self.build()
|
||||
self.assertEqual(self.row(refreshed)["state"], "confirmed")
|
||||
self.assertFalse(self.row(refreshed)["can_confirm"])
|
||||
|
||||
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
|
||||
db.set_user_jellyseerr_id("Georgia", 20)
|
||||
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
|
||||
report, local = self.build()
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
self.assertEqual(len(report["rows"]), 2)
|
||||
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
|
||||
with self.assertRaises(HTTPException):
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(review.read_snapshot()["confirmations"], [])
|
||||
|
||||
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
|
||||
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
|
||||
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
|
||||
db.set_user_jellyseerr_id("Georgia", 21)
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.assertFalse(self.row(report)["can_confirm"])
|
||||
|
||||
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
|
||||
# Legacy duplicate predates the normalized-name creation guard.
|
||||
with db._connect() as conn:
|
||||
conn.execute("INSERT INTO users(username,password_hash,role,auth_provider,created_at) VALUES('georgia','unused','user','jellyfin','2026-01-01')")
|
||||
report, _ = self.build()
|
||||
self.assertEqual(report["counts"]["conflict"], 2)
|
||||
self.assertEqual(report["counts"]["ready"], 0)
|
||||
|
||||
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
|
||||
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
|
||||
self.jf["users"].append({"id": OTHER, "name": "local"})
|
||||
db.create_user("local", "password", auth_provider="local")
|
||||
report, _ = self.build()
|
||||
for row in report["rows"]:
|
||||
if row["user"]["id"] != self.user_id:
|
||||
self.assertIsNone(row["candidate_jellyfin_id"])
|
||||
self.assertFalse(row["can_confirm"])
|
||||
|
||||
async def test_missing_or_unavailable_services_never_confirm(self):
|
||||
for state in ["missing", "unavailable", "not_configured"]:
|
||||
self.js[JF] = {"state": state}
|
||||
report, _ = self.build()
|
||||
self.assertFalse(self.row(report)["can_confirm"])
|
||||
self.js = {JF: {"state": "matched", "id": JF}}
|
||||
self.seerr = {"state": "unavailable", "users": []}
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "unavailable")
|
||||
|
||||
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
|
||||
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.seerr["users"].pop()
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
|
||||
async def test_wrong_stored_id_is_not_silently_replaced(self):
|
||||
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
|
||||
|
||||
async def test_account_changes_reject_whole_save(self):
|
||||
report, local = self.build()
|
||||
db.set_user_jellyseerr_id("Georgia", 99)
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
self.assertEqual(review.read_snapshot()["confirmations"], [])
|
||||
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
|
||||
|
||||
async def test_settings_changes_reject_save(self):
|
||||
report, local = self.build()
|
||||
db.set_setting("jellyfin_base_url", "http://changed")
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
|
||||
async def test_save_reads_real_runtime_settings_inside_transaction(self):
|
||||
from backend.app.runtime import get_runtime_settings
|
||||
for key in review.CONFIG_KEYS:
|
||||
db.set_setting(key, getattr(self.runtime, key))
|
||||
report, local = self.build()
|
||||
with patch.object(review, "get_runtime_settings", get_runtime_settings):
|
||||
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.assertEqual(result["confirmed"], 1)
|
||||
|
||||
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
|
||||
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
|
||||
second_id = db.get_user_by_username("Second")["id"]
|
||||
self.jf["users"].append({"id": OTHER, "name": "Second"})
|
||||
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
|
||||
self.js[OTHER] = {"state": "matched", "id": OTHER}
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
|
||||
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
|
||||
report, local = self.build()
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
after = review.read_snapshot()
|
||||
self.assertEqual(after["confirmations"], [])
|
||||
self.assertEqual(after["links"], [])
|
||||
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
|
||||
|
||||
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
|
||||
report, local = self.build()
|
||||
changed = {**report, "revision": "f" * 64}
|
||||
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
|
||||
patch.object(review, "save_confirmations") as save:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
save.assert_not_called()
|
||||
|
||||
async def test_different_server_cannot_reuse_confirmed_id(self):
|
||||
report, local = self.build()
|
||||
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
|
||||
self.jf["server_id"] = OTHER
|
||||
report, _ = self.build()
|
||||
self.assertEqual(self.row(report)["state"], "conflict")
|
||||
|
||||
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
|
||||
a, _ = self.build()
|
||||
b, _ = self.build()
|
||||
self.assertEqual(a["revision"], b["revision"])
|
||||
self.seerr["users"][0]["id"] = 99
|
||||
c, _ = self.build()
|
||||
self.assertNotEqual(a["revision"], c["revision"])
|
||||
|
||||
async def test_seerr_directory_requires_complete_unique_pages(self):
|
||||
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
|
||||
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
|
||||
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
|
||||
result = await review.seerr_directory(self.runtime)
|
||||
self.assertEqual(result["state"], "available")
|
||||
self.assertEqual(len(result["users"]), 101)
|
||||
self.assertEqual(get.await_args.kwargs["skip"], 100)
|
||||
for broken in [[], users[:2], users[:1] * 100]:
|
||||
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
|
||||
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
|
||||
|
||||
async def test_jellyfin_server_and_directory_must_agree(self):
|
||||
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
|
||||
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
|
||||
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
|
||||
|
||||
|
||||
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unconfigured_client_never_calls_upstream(self):
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
|
||||
result = await JellystatClient(None, None).check_user_ids([JF])
|
||||
http.assert_not_called()
|
||||
self.assertEqual(result[JF]["state"], "not_configured")
|
||||
|
||||
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
|
||||
ids = [f"{i:032x}" for i in range(1, 6)]
|
||||
def handler(request):
|
||||
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
|
||||
user_id = json.loads(request.content)["userid"]
|
||||
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
|
||||
if user_id == ids[1]: return httpx.Response(200, content=b"")
|
||||
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
|
||||
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
|
||||
return httpx.Response(503, text="PRIVATE error")
|
||||
real = httpx.AsyncClient
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
|
||||
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
|
||||
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
|
||||
self.assertNotIn("PRIVATE", json.dumps(data))
|
||||
|
||||
|
||||
class IdentityRouteTests(unittest.TestCase):
|
||||
def client(self, role=None):
|
||||
app = FastAPI()
|
||||
app.include_router(identities.router)
|
||||
if role:
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
|
||||
return TestClient(app)
|
||||
|
||||
def test_admin_only_read_and_write(self):
|
||||
for role, status in [(None, 401), ("user", 403)]:
|
||||
client = self.client(role)
|
||||
self.assertEqual(client.get("/admin/identities").status_code, status)
|
||||
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
|
||||
|
||||
def test_resolution_requires_admin_and_strict_ids(self):
|
||||
for endpoint in ['check', 'confirm']:
|
||||
body = {'user_id': 1, 'jellyfin_user_id': JF}
|
||||
if endpoint == 'confirm': body['revision'] = 'a' * 64
|
||||
for role, status in [(None, 401), ('user', 403)]:
|
||||
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
|
||||
self.assertEqual(self.client(role).post('/admin/identities/repair/' + endpoint, json=body).status_code, status)
|
||||
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
|
||||
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
|
||||
self.assertEqual(self.client('admin').post('/admin/identities/repair/' + endpoint, json={**body, **invalid}).status_code, 422)
|
||||
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
|
||||
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
|
||||
self.assertEqual(result.headers['cache-control'], 'no-store')
|
||||
|
||||
def test_no_store_and_no_browser_supplied_identity(self):
|
||||
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
|
||||
response = self.client("admin").get("/admin/identities")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
|
||||
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
|
||||
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import jellyfin_sync
|
||||
from backend.app.services.jellyfin_identity import link_user, user_for_identity
|
||||
from backend.app.routers import admin
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
|
||||
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
|
||||
auto_search_enabled=False, email='kept@example.test')
|
||||
original = db.get_user_by_username('old@example.test')
|
||||
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
|
||||
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
|
||||
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
|
||||
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
|
||||
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
kept = user_for_identity('a' * 32, 'http://jf')
|
||||
self.assertEqual(kept['id'], original['id'])
|
||||
self.assertFalse(kept['auto_search_enabled'])
|
||||
self.assertEqual(kept['email'], 'kept@example.test')
|
||||
self.assertIsNone(db.get_user_by_username('NewName'))
|
||||
self.assertEqual(kept['auth_provider'], 'jellyfin')
|
||||
|
||||
async def test_resync_no_longer_deletes_accounts(self):
|
||||
db.create_user('Keep', 'Password-123456!')
|
||||
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
|
||||
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
|
||||
patch.object(admin, 'delete_non_admin_users') as delete:
|
||||
result = await admin.jellyseerr_users_resync()
|
||||
self.assertEqual(result['cleared'], 0)
|
||||
delete.assert_not_called()
|
||||
self.assertIsNotNone(db.get_user_by_username('Keep'))
|
||||
|
||||
def test_jellyfin_lookup_is_scoped_to_server(self):
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
|
||||
link_user('Viewer', 'a' * 32, 'http://jf')
|
||||
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
|
||||
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
|
||||
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
|
||||
from backend.app.routers import admin, insights as router
|
||||
from backend.app.services import insights
|
||||
from backend.app.services.jellyfin_identity import link_user, linked_user_id
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
|
||||
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
|
||||
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
|
||||
|
||||
|
||||
def play(id="play-1", **extra):
|
||||
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
|
||||
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
|
||||
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
|
||||
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
|
||||
|
||||
|
||||
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def history(self, handler, **kwargs):
|
||||
original = httpx.AsyncClient
|
||||
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
|
||||
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
|
||||
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
|
||||
|
||||
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
|
||||
self.assertNotIn("secret-api-key", str(request.url))
|
||||
if request.url.path == "/base/api/getLibraries":
|
||||
return httpx.Response(200, json=LIBRARIES)
|
||||
self.assertEqual(request.method, "POST")
|
||||
self.assertEqual(request.url.path, "/base/api/getUserHistory")
|
||||
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
|
||||
self.assertNotIn("search", request.url.params)
|
||||
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
|
||||
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
|
||||
history, libraries = await self.history(handler)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertEqual(libraries, LIBRARIES)
|
||||
|
||||
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
|
||||
for payload, exception in [
|
||||
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
|
||||
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
|
||||
({"results": []}, JellystatError),
|
||||
({"pages": 51, "results": []}, HistoryLimitError),
|
||||
({"pages": 2, "results": []}, JellystatError),
|
||||
({"pages": 0, "results": [play()]}, JellystatError),
|
||||
]:
|
||||
with self.subTest(payload=payload):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
|
||||
with self.assertRaises(exception):
|
||||
await self.history(handler)
|
||||
|
||||
async def test_empty_history_is_valid(self):
|
||||
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
async def test_upstream_failure_is_sanitized(self):
|
||||
with self.assertRaises(JellystatError) as error:
|
||||
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
|
||||
self.assertNotIn("private", str(error.exception))
|
||||
self.assertNotIn("secret-api-key", str(error.exception))
|
||||
|
||||
|
||||
class SummaryTests(unittest.TestCase):
|
||||
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
|
||||
rows = [play(), play(), play("rewatch"),
|
||||
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
|
||||
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
|
||||
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
|
||||
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
|
||||
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
|
||||
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
|
||||
play("zero", PlaybackDuration=0)]
|
||||
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
|
||||
"active_days": 3, "current_streak": 3, "longest_streak": 3})
|
||||
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
|
||||
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
|
||||
self.assertEqual(len(data["recent"]), 5)
|
||||
self.assertNotIn("PRIVATE", json.dumps(data))
|
||||
|
||||
def test_invalid_durations_do_not_become_zero_or_nan(self):
|
||||
for value in [-1, "NaN", "Infinity", "nonsense"]:
|
||||
with self.subTest(value=value), self.assertRaises(JellystatError):
|
||||
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
|
||||
def test_empty_history_has_zero_filled_days(self):
|
||||
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(result["summary"]["minutes"], 0)
|
||||
self.assertEqual(len(result["daily"]), 8)
|
||||
self.assertEqual(result["summary"]["current_streak"], 0)
|
||||
|
||||
|
||||
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
insights._cache.clear()
|
||||
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
|
||||
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
|
||||
|
||||
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
|
||||
link_user("viewer", "jf-original", "http://jellyfin/")
|
||||
link_user("viewer", "jf-replacement", "http://jellyfin")
|
||||
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
|
||||
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
|
||||
|
||||
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
|
||||
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
|
||||
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
|
||||
remote.assert_not_called()
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
|
||||
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
|
||||
for request_id, seerr_id in [(1, 42), (2, 99)]:
|
||||
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
|
||||
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
|
||||
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
|
||||
self.assertEqual(report["total"], 1)
|
||||
self.assertEqual(report["recent"][0]["request_id"], 1)
|
||||
|
||||
async def test_cache_isolated_by_identity_period_and_configuration(self):
|
||||
link_user("viewer", "jf-viewer", "http://jellyfin")
|
||||
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
|
||||
link_user("second", "jf-second", "http://jellyfin")
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await insights.get_insights(USER, 7)
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await insights.get_insights({**USER, "username": "second"}, 7)
|
||||
await insights.get_insights(USER, 30)
|
||||
self.runtime.jellystat_api_key = "rotated-key"
|
||||
await insights.get_insights(USER, 7)
|
||||
self.assertEqual(remote.await_count, 4)
|
||||
|
||||
async def test_disabled_integration_never_calls_upstream(self):
|
||||
self.runtime.jellystat_api_key = None
|
||||
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
|
||||
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
|
||||
result = await insights.get_insights(USER, 30)
|
||||
self.assertEqual(result["state"], "not_configured")
|
||||
self.assertIsNone(result["summary"])
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_settings_mask_jellystat_credential(self):
|
||||
db.set_setting("jellystat_api_key", "private-stats-key")
|
||||
result = await admin.list_settings()
|
||||
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
|
||||
self.assertTrue(setting["sensitive"])
|
||||
self.assertTrue(setting["isSet"])
|
||||
self.assertNotIn("private-stats-key", json.dumps(result))
|
||||
|
||||
|
||||
class InsightsRouteTests(unittest.TestCase):
|
||||
def app(self, authenticated=True):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
|
||||
return TestClient(app)
|
||||
|
||||
def test_requires_authentication(self):
|
||||
self.assertEqual(self.app(False).get("/insights").status_code, 401)
|
||||
|
||||
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
|
||||
client = self.app()
|
||||
for days in [7, 30, 90, 365]:
|
||||
response = client.get(f"/insights?days={days}")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
report.assert_awaited_with({**USER, "features": {"stats": True}}, 365)
|
||||
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
|
||||
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
|
||||
|
||||
def test_errors_do_not_leak_upstream_details(self):
|
||||
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
|
||||
response = self.app().get("/insights")
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertNotIn("PRIVATE", response.text)
|
||||
@@ -0,0 +1,165 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.routers import insights as router
|
||||
from backend.app.services import insights, insights_artwork as artwork
|
||||
from backend.tests.test_insights import NOW, LIBRARIES, USER, play
|
||||
|
||||
ITEM = "a" * 32
|
||||
OTHER = "b" * 32
|
||||
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=")
|
||||
|
||||
|
||||
def transcode(activity, minutes, video_direct=False, audio_direct=False, hardware="nvenc", **kwargs):
|
||||
return play(activity, NowPlayingItemId=ITEM, PlaybackDuration=minutes * 60, PlayMethod="Transcode",
|
||||
TranscodingInfo={"IsVideoDirect": video_direct, "IsAudioDirect": audio_direct,
|
||||
"HardwareAccelerationType": hardware, "VideoCodec": "h264", "AudioCodec": "aac"}, **kwargs)
|
||||
|
||||
|
||||
class TranscodingSummaryTests(unittest.TestCase):
|
||||
def test_gpu_audio_software_and_unknown_time_are_separate_and_deduplicated(self):
|
||||
gpu = transcode("gpu", 10)
|
||||
rows = [gpu, dict(gpu), transcode("audio-only", 5, video_direct=True),
|
||||
transcode("software", 3, audio_direct=True, hardware="none"),
|
||||
transcode("remux", 2, video_direct=True, audio_direct=True),
|
||||
{**transcode("stale", 1), "PlayMethod": "DirectPlay"},
|
||||
transcode("unknown-hardware", 2, audio_direct=True, hardware=None),
|
||||
{**transcode("unknown-streams", 1), "TranscodingInfo": None},
|
||||
transcode("old", 100, ActivityDateInserted=(NOW - timedelta(days=31)).isoformat())]
|
||||
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=30), NOW)
|
||||
stats = result["transcoding"]
|
||||
self.assertEqual(stats["hardware_video_minutes"], 10)
|
||||
self.assertEqual(stats["audio_minutes"], 15)
|
||||
self.assertEqual(stats["video_minutes"], 15)
|
||||
self.assertEqual(stats["software_video_minutes"], 3)
|
||||
self.assertEqual(stats["unknown_hardware_minutes"], 2)
|
||||
self.assertEqual(stats["unknown_video_minutes"], 1)
|
||||
self.assertEqual(stats["unknown_audio_minutes"], 1)
|
||||
self.assertEqual(stats["hardware"], [{"name": "NVIDIA NVENC", "minutes": 10}])
|
||||
self.assertIsNone(stats["gpu_busy_minutes"])
|
||||
|
||||
def test_audio_media_cannot_accumulate_video_gpu_time(self):
|
||||
row = transcode("music", 4, ParentId="music")
|
||||
row["TranscodingInfo"]["VideoCodec"] = None
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["audio_minutes"], 4)
|
||||
self.assertEqual(result["video_minutes"], 0)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_direct_stream_counts_audio_but_does_not_claim_video_encoding(self):
|
||||
row = {**transcode("stream", 5), "PlayMethod": "DirectStream"}
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["audio_minutes"], 5)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_numeric_hardware_enum_and_legacy_json_are_supported(self):
|
||||
import json
|
||||
row = transcode("enum", 5, hardware=3)
|
||||
row["TranscodingInfo"] = json.dumps(row["TranscodingInfo"])
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["hardware_video_minutes"], 5)
|
||||
row["TranscodingInfo"] = "invalid JSON"
|
||||
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
|
||||
self.assertEqual(result["unknown_video_minutes"], 5)
|
||||
self.assertEqual(result["hardware_video_minutes"], 0)
|
||||
|
||||
def test_episode_artwork_uses_series_id_and_invalid_ids_are_ignored(self):
|
||||
rows = [play("episode", EpisodeId=OTHER, NowPlayingItemId=ITEM),
|
||||
play("invalid", NowPlayingItemId="../../secret")]
|
||||
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
|
||||
indexed = {row["id"]: row for row in result["recent"]}
|
||||
self.assertEqual(indexed["episode"]["artwork_item_id"], ITEM)
|
||||
self.assertIsNone(indexed["invalid"]["artwork_item_id"])
|
||||
|
||||
|
||||
class ArtworkTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="PRIVATE-API-KEY")
|
||||
self.secret = patch.object(artwork.settings, "jwt_secret", "test-artwork-signing-secret")
|
||||
self.secret.start()
|
||||
self.addCleanup(self.secret.stop)
|
||||
artwork._cache.clear()
|
||||
artwork._downloads = asyncio.Semaphore(6)
|
||||
|
||||
def url_and_token(self, user=USER):
|
||||
data = {"recent": [{"id": "play-1", "artwork_item_id": ITEM}]}
|
||||
before = copy.deepcopy(data)
|
||||
result = artwork.with_artwork(data, user, self.runtime)
|
||||
self.assertEqual(data, before)
|
||||
row = result["recent"][0]
|
||||
self.assertNotIn("artwork_item_id", row)
|
||||
self.assertNotIn("PRIVATE-API-KEY", row["artwork_url"])
|
||||
return row["artwork_url"], parse_qs(urlsplit(row["artwork_url"]).query)["token"][0]
|
||||
|
||||
async def test_ticket_is_bound_to_user_item_server_credentials_and_time(self):
|
||||
with patch.object(artwork.time, "time", return_value=1000):
|
||||
_, token = self.url_and_token()
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
|
||||
for user, runtime, media_id in [({**USER, "username": "different"}, self.runtime, ITEM),
|
||||
(USER, self.runtime, OTHER), (USER, SimpleNamespace(jellyfin_base_url="http://other", jellyfin_api_key="PRIVATE-API-KEY"), ITEM),
|
||||
(USER, SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="changed"), ITEM)]:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
artwork.verify_artwork_token(user, runtime, media_id, token)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
with patch.object(artwork.time, "time", return_value=5000), self.assertRaises(HTTPException):
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
|
||||
for invalid in ["invalid", "1.", "1." + "\u2603" * 64]:
|
||||
with self.assertRaises(HTTPException):
|
||||
artwork.verify_artwork_token(USER, self.runtime, ITEM, invalid)
|
||||
|
||||
async def test_private_proxy_returns_image_and_validates_before_cache_access(self):
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
self.assertEqual(request.url.path, f"/Items/{ITEM}/Images/Primary")
|
||||
self.assertNotIn("PRIVATE", str(request.url))
|
||||
self.assertEqual(request.headers["X-Emby-Token"], "PRIVATE-API-KEY")
|
||||
return httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
|
||||
real = httpx.AsyncClient
|
||||
_, token = self.url_and_token()
|
||||
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
|
||||
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
|
||||
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
|
||||
with self.assertRaises(HTTPException):
|
||||
await artwork.get_artwork({**USER, "username": "someone-else"}, self.runtime, ITEM, token)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
async def test_non_images_missing_images_and_large_images_fail_closed(self):
|
||||
_, token = self.url_and_token()
|
||||
real = httpx.AsyncClient
|
||||
for status, body, mime in [(404, b"PRIVATE", "text/plain"), (200, b"<svg>PRIVATE</svg>", "image/svg+xml"),
|
||||
(200, b"x" * (artwork.MAX_IMAGE_BYTES + 1), "image/png")]:
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(status, content=body, headers={"Content-Type": mime}))
|
||||
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=transport, **kwargs)):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await artwork.get_artwork(USER, self.runtime, ITEM, token)
|
||||
self.assertEqual(raised.exception.status_code, 404)
|
||||
self.assertNotIn("PRIVATE", raised.exception.detail)
|
||||
self.assertEqual(len(artwork._cache), 0)
|
||||
|
||||
|
||||
class ArtworkRouteTests(unittest.TestCase):
|
||||
def test_authentication_and_private_response_headers(self):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
client = TestClient(app)
|
||||
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
|
||||
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
|
||||
with patch.object(router, "get_runtime_settings", return_value=None), \
|
||||
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
|
||||
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.content, PNG)
|
||||
self.assertEqual(result.headers["cache-control"], "private, max-age=600")
|
||||
self.assertEqual(result.headers["vary"], "Cookie, Authorization")
|
||||
self.assertEqual(result.headers["x-content-type-options"], "nosniff")
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Real application HTTP checks for installation, cookies, and backup controls.
|
||||
|
||||
All persistence and artwork paths are isolated in temporary directories; workers,
|
||||
logging file handlers, and the metrics listener are disabled for these tests.
|
||||
"""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.services import backups
|
||||
|
||||
|
||||
OPERATOR_TOKEN = "installation-http-operator-token-test-123456789"
|
||||
OWNER_PASSWORD = "installation-http-owner-password-123456789"
|
||||
BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789"
|
||||
|
||||
|
||||
class InstallationHttpTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
for key, value in {
|
||||
"sqlite_path": str(self.root / "magent.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"jwt_secret": "installation-http-test-jwt-secret-1234567890",
|
||||
"settings_encryption_key": None,
|
||||
"admin_username": "unused-environment-admin",
|
||||
"admin_password": "",
|
||||
"setup_token": OPERATOR_TOKEN,
|
||||
"auth_cookie_secure": True,
|
||||
"auth_cookie_domain": None,
|
||||
"auth_cookie_samesite": "strict",
|
||||
}.items():
|
||||
context = patch.object(settings, key, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
for context in (
|
||||
patch.object(main, "configure_logging"),
|
||||
patch.object(main, "start_metrics"),
|
||||
patch.object(main, "_background_tasks", []),
|
||||
patch.object(main, "_background_started", False),
|
||||
patch.object(backups, "_assets_root", return_value=self.root / "assets"),
|
||||
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}),
|
||||
):
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
self.origin = str(settings.cors_allow_origin).rstrip("/")
|
||||
self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test"))
|
||||
self.client.headers["Origin"] = self.origin
|
||||
|
||||
def create_owner(self):
|
||||
response = self.client.post("/setup/bootstrap", json={
|
||||
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
return response
|
||||
|
||||
def sign_in(self):
|
||||
response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||
auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "="))
|
||||
self.assertIn("HttpOnly", auth_cookie)
|
||||
self.assertIn("Secure", auth_cookie)
|
||||
self.assertIn("SameSite=strict", auth_cookie)
|
||||
self.assertNotIn("Authorization", self.client.headers)
|
||||
|
||||
def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self):
|
||||
status = self.client.get("/setup/status")
|
||||
self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True})
|
||||
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||
self.assertIn("default-src 'none'", status.headers["content-security-policy"])
|
||||
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||
self.assertEqual(self.client.get("/admin/backups").status_code, 401)
|
||||
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||
response = self.client.put("/admin/settings", json={
|
||||
"jellyfin_base_url": "http://jellyfin.test:8096",
|
||||
"jellyfin_api_key": "test-integration-key-for-setup",
|
||||
"site_login_message": "Welcome to this installation",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()["updated"], 3)
|
||||
values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]}
|
||||
self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096")
|
||||
self.assertIsNone(values["jellyfin_api_key"]["value"])
|
||||
self.assertTrue(values["jellyfin_api_key"]["isSet"])
|
||||
response = self.client.put("/setup/state", json={"step": "review"})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
completed = self.client.post("/setup/complete")
|
||||
self.assertEqual(completed.status_code, 200, completed.text)
|
||||
self.assertTrue(completed.json()["completed"])
|
||||
self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False})
|
||||
self.assertEqual(main._background_tasks, [])
|
||||
|
||||
exported = self.client.post("/admin/backups/export", json={
|
||||
"passphrase": BACKUP_PASSPHRASE, "include_cache": False,
|
||||
})
|
||||
self.assertEqual(exported.status_code, 200, exported.text[:100])
|
||||
self.assertTrue(exported.content.startswith(backups.MAGIC))
|
||||
self.assertEqual(exported.headers["cache-control"], "no-store")
|
||||
self.assertNotIn(b"test-integration-key-for-setup", exported.content)
|
||||
restored = self.client.post("/admin/backups/restore", files={
|
||||
"file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"),
|
||||
}, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"})
|
||||
self.assertEqual(restored.status_code, 202, restored.text)
|
||||
self.assertTrue(restored.json()["restart_required"])
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation")
|
||||
self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
cancelled = self.client.delete("/admin/backups/restore")
|
||||
self.assertEqual(cancelled.status_code, 200, cancelled.text)
|
||||
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
|
||||
def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self):
|
||||
response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={
|
||||
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||
|
||||
def test_setup_validation_errors_do_not_echo_password_or_token(self):
|
||||
secret_password = "private-password-marker-" + "p" * 1024
|
||||
secret_token = "private-token-marker-" + "t" * 1024
|
||||
for payload, secret in (
|
||||
({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password),
|
||||
({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token),
|
||||
({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD),
|
||||
):
|
||||
with self.subTest(secret=secret[:22]):
|
||||
response = self.client.post("/setup/bootstrap", json=payload)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertNotIn(secret, response.text)
|
||||
self.assertNotIn(OPERATOR_TOKEN, response.text)
|
||||
for error in response.json()["detail"]:
|
||||
self.assertNotIn("input", error)
|
||||
|
||||
def test_backup_validation_errors_do_not_echo_passphrases(self):
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
passphrase = "private-backup-passphrase-marker-" + "p" * 1024
|
||||
response = self.client.post("/admin/backups/export", json={"passphrase": passphrase})
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertNotIn(passphrase, response.text)
|
||||
response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={
|
||||
"passphrase": passphrase, "confirmation": "RESTORE",
|
||||
})
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertNotIn(passphrase, response.text)
|
||||
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
|
||||
def test_real_middleware_rejects_oversized_bootstrap_before_creation(self):
|
||||
response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"})
|
||||
self.assertEqual(response.status_code, 413, response.text)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,162 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
|
||||
from backend.app import db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.request_limits import InstallationBodyLimitMiddleware
|
||||
from backend.app.services import setup
|
||||
|
||||
|
||||
class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temporary.cleanup)
|
||||
patches = [
|
||||
patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")),
|
||||
patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"),
|
||||
patch.object(settings, "settings_encryption_key", None),
|
||||
patch.object(settings, "admin_password", ""),
|
||||
patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"),
|
||||
patch.object(main, "_background_started", False),
|
||||
patch.object(main, "_background_tasks", []),
|
||||
patch.object(main, "start_metrics"),
|
||||
patch.object(main, "configure_logging"),
|
||||
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}),
|
||||
]
|
||||
for item in patches:
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self):
|
||||
with patch.object(main, "_launch_background_task") as launch:
|
||||
await main.startup()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True})
|
||||
launch.assert_not_called()
|
||||
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||
await main._start_background_tasks()
|
||||
launch.assert_not_called()
|
||||
setup.complete_setup()
|
||||
await main.app.state.on_setup_complete()
|
||||
await main.app.state.on_setup_complete()
|
||||
self.assertEqual(launch.call_count, 9)
|
||||
|
||||
async def test_upgraded_install_starts_normally_without_setup_token(self):
|
||||
db.init_db()
|
||||
db.create_user("owner", "existing-password-12345", role="admin")
|
||||
settings.setup_token = ""
|
||||
with patch.object(main, "_launch_background_task") as launch:
|
||||
await main.startup()
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
self.assertEqual(launch.call_count, 9)
|
||||
|
||||
async def test_disabled_workers_stay_disabled_after_setup(self):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||
setup.complete_setup()
|
||||
with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch:
|
||||
await main._start_background_tasks()
|
||||
launch.assert_not_called()
|
||||
|
||||
async def test_bad_secret_stops_before_restore_or_database_initialization(self):
|
||||
settings.jwt_secret = "short"
|
||||
with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize:
|
||||
with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"):
|
||||
await main.startup()
|
||||
restore.assert_not_called()
|
||||
initialize.assert_not_called()
|
||||
|
||||
async def test_restore_failure_stops_before_initialization_and_workers(self):
|
||||
with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch:
|
||||
with self.assertRaisesRegex(RuntimeError, "restore failed"):
|
||||
await main.startup()
|
||||
initialize.assert_not_called()
|
||||
launch.assert_not_called()
|
||||
|
||||
async def test_startup_order_is_restore_then_setup_marker_then_schema(self):
|
||||
calls = Mock()
|
||||
calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore")
|
||||
calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup")
|
||||
calls.attach_mock(Mock(wraps=main.init_db), "schema")
|
||||
with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema):
|
||||
await main.startup()
|
||||
self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"])
|
||||
|
||||
def test_missing_token_does_not_allow_fresh_bootstrap(self):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
settings.setup_token = ""
|
||||
with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"):
|
||||
main._enforce_secure_startup_configuration()
|
||||
|
||||
def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self):
|
||||
db.init_db()
|
||||
db.create_user("restored-owner", "existing-password-12345", role="admin")
|
||||
with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"):
|
||||
db.init_db()
|
||||
self.assertIsNone(db.get_user_by_username("host-bootstrap"))
|
||||
|
||||
async def test_shutdown_cancels_workers_and_allows_next_start(self):
|
||||
task = asyncio.create_task(asyncio.Event().wait())
|
||||
main._background_tasks.append(task)
|
||||
main._background_started = True
|
||||
await main.shutdown()
|
||||
self.assertTrue(task.cancelled())
|
||||
self.assertEqual(main._background_tasks, [])
|
||||
self.assertFalse(main._background_started)
|
||||
|
||||
|
||||
class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_rejects_oversized_declared_body_before_parser(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/setup/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
self.fail("Body must be rejected before the endpoint")
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"})
|
||||
self.assertEqual(response.status_code, 413)
|
||||
|
||||
async def test_counts_chunks_with_missing_or_forged_content_length(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/setup/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
return await request.json()
|
||||
|
||||
async def chunks():
|
||||
yield b'{"token":"'
|
||||
yield b"a" * 17000
|
||||
yield b'"}'
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
for headers in ({}, {"Content-Length": "1"}):
|
||||
response = await client.post("/setup/bootstrap", content=chunks(), headers=headers)
|
||||
self.assertEqual(response.status_code, 413)
|
||||
|
||||
async def test_multipart_stream_limit_is_413_not_parser_500(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/admin/backups/restore")
|
||||
async def restore(file: UploadFile = File(...)):
|
||||
return {"size": file.size}
|
||||
|
||||
async def chunks():
|
||||
yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n'
|
||||
yield b"a" * 2048
|
||||
yield b"\r\n--boundary--\r\n"
|
||||
|
||||
with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"})
|
||||
self.assertEqual(response.status_code, 413)
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException, Response
|
||||
from backend.app import db
|
||||
from backend.app.routers import auth
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def signup(self, code, username, **extra):
|
||||
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
|
||||
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
|
||||
|
||||
async def test_email_invite_binds_account_and_cannot_be_reused(self):
|
||||
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
|
||||
self.assertEqual(invite['max_uses'], 1)
|
||||
public = auth._public_invite_payload(invite)
|
||||
self.assertTrue(public['email_bound'])
|
||||
self.assertNotIn('recipient@example.com', str(public))
|
||||
await self.signup('EMAILTEST', 'first-user')
|
||||
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('EMAILTEST', 'second-user')
|
||||
|
||||
async def test_email_invite_rejects_recipient_override(self):
|
||||
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
|
||||
|
||||
async def test_manual_invite_requires_and_saves_email(self):
|
||||
db.create_signup_invite(code='MANUALTEST', max_uses=3)
|
||||
for email in ['', 'invalid']:
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('MANUALTEST', 'manual-user', email=email)
|
||||
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
|
||||
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
|
||||
|
||||
async def test_failed_creation_releases_reservation(self):
|
||||
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
|
||||
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('FAILTEST', 'failed-user')
|
||||
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
|
||||
|
||||
async def test_single_use_reservation_is_atomic(self):
|
||||
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
|
||||
self.assertEqual(sum(results), 1)
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import issue_resolution as service
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def issue(self):
|
||||
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
|
||||
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
|
||||
self.start(item["id"])
|
||||
return item
|
||||
|
||||
def start(self, item_id):
|
||||
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
|
||||
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
|
||||
|
||||
async def test_importing_or_unverified_media_does_not_email_reporter(self):
|
||||
self.issue()
|
||||
for phase in ["collecting", "indexing", "unavailable"]:
|
||||
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
|
||||
await service.process_active_media_repairs()
|
||||
begin.assert_not_awaited()
|
||||
|
||||
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
|
||||
item = self.issue()
|
||||
with (
|
||||
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
|
||||
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
|
||||
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
|
||||
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
|
||||
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
|
||||
):
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
|
||||
content = email.await_args.kwargs
|
||||
self.assertEqual(content["subject"], f"Ready to try again? Magent issue #{item['id']}")
|
||||
self.assertIn("affected content in Jellyfin", content["body_text"])
|
||||
self.assertNotIn("grizzlyflix", content["body_html"].lower())
|
||||
self.assertIn("YES — it works", content["body_html"])
|
||||
self.assertIn("NO — still broken", content["body_html"])
|
||||
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
|
||||
self.assertIn("Broken <movie>", content["body_html"])
|
||||
self.assertNotIn("<movie>", content["body_html"])
|
||||
self.assertIn("Confirm your answer in Magent", content["body_text"])
|
||||
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_due_issue_confirmations()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
|
||||
self.start(item["id"])
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 2)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import httpx
|
||||
from backend.app.clients.jellyfin import JellyfinClient
|
||||
from backend.app.services.snapshot import jellyfin_item_matches_request
|
||||
from backend.app.models import RequestType
|
||||
|
||||
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
|
||||
calls=[]
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
|
||||
return httpx.Response(200,json={'Items':items})
|
||||
original=httpx.AsyncClient
|
||||
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
|
||||
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
|
||||
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
|
||||
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
|
||||
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
|
||||
self.assertEqual([i['Id'] for i in matches],['live'])
|
||||
|
||||
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
|
||||
def match(item,payload=None):
|
||||
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
|
||||
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
|
||||
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
|
||||
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
|
||||
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
|
||||
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id
|
||||
|
||||
|
||||
class JsonLoggingTests(unittest.TestCase):
|
||||
def test_json_formatter_includes_request_context(self) -> None:
|
||||
token = bind_request_id("request-123")
|
||||
try:
|
||||
record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||
RequestContextFilter().filter(record)
|
||||
payload = json.loads(JsonLogFormatter().format(record))
|
||||
finally:
|
||||
reset_request_id(token)
|
||||
|
||||
self.assertEqual(payload["level"], "INFO")
|
||||
self.assertEqual(payload["logger"], "magent.test")
|
||||
self.assertEqual(payload["request_id"], "request-123")
|
||||
self.assertEqual(payload["message"], "hello world")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Managed first-install origin claims use a token, not request routing headers."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import auth, db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.installation_origin import normalize_application_origin
|
||||
from backend.app.security import create_access_token
|
||||
from backend.app.services import setup
|
||||
from backend.app.services.public_urls import magent_public_url
|
||||
from backend.app.services.request_origins import can_claim_initial_origin, is_allowed_request_origin
|
||||
|
||||
|
||||
PUBLIC_ORIGIN = "https://watch.example.test"
|
||||
LOCAL_ORIGIN = "http://localhost:3000"
|
||||
SETUP_TOKEN = "managed-origin-operator-token-for-tests-only-1234567890"
|
||||
ADMIN_PASSWORD = "Managed-origin-password-for-tests!123"
|
||||
|
||||
|
||||
class ApplicationOriginNormalizationTests(unittest.TestCase):
|
||||
def test_exact_origins_are_canonicalized(self):
|
||||
for value, expected in (
|
||||
(PUBLIC_ORIGIN, PUBLIC_ORIGIN),
|
||||
("HTTPS://WATCH.EXAMPLE.TEST:443/", PUBLIC_ORIGIN),
|
||||
("http://magent.lan:80/", "http://magent.lan"),
|
||||
("http://192.0.2.10:3000", "http://192.0.2.10:3000"),
|
||||
("http://[fd00::10]:3000/", "http://[fd00::10]:3000"),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(normalize_application_origin(value), expected)
|
||||
|
||||
def test_non_origins_and_ambiguous_values_are_rejected(self):
|
||||
for value in (
|
||||
"", "null", "*", "magent.lan", "//magent.lan", "https:/magent.lan",
|
||||
"ftp://magent.lan", "https://user@magent.lan", "https://user:secret@magent.lan",
|
||||
"https://magent.lan/path", "https://magent.lan/../", "https://magent.lan?",
|
||||
"https://magent.lan#", "https://magent.lan?token=1", "https://magent.lan#fragment",
|
||||
"https://*.magent.lan", "https://magent.lan:0", "https://magent.lan:65536",
|
||||
"https://magent.lan:", "https://magent.lan\\path", " https://magent.lan",
|
||||
"https://magent.\tlan", "https://magent.lan\x00",
|
||||
):
|
||||
with self.subTest(value=repr(value)), self.assertRaises(ValueError):
|
||||
normalize_application_origin(value)
|
||||
|
||||
|
||||
class ManagedSetupOriginTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.enterContext(patch.dict(os.environ, {"MAGENT_RUNTIME_MANAGED": "1"}))
|
||||
for name, value in {
|
||||
"sqlite_path": str(Path(temporary.name) / "managed-setup.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"jwt_secret": "managed-origin-test-signing-key-at-least-32-characters",
|
||||
"settings_encryption_key": None,
|
||||
"setup_token": SETUP_TOKEN,
|
||||
"admin_username": "unused-environment-admin",
|
||||
"admin_password": "",
|
||||
"cors_allow_origin": LOCAL_ORIGIN,
|
||||
"magent_application_url": None,
|
||||
"magent_proxy_enabled": False,
|
||||
"magent_proxy_base_url": None,
|
||||
"auth_cookie_domain": None,
|
||||
"auth_cookie_secure": False,
|
||||
}.items():
|
||||
self.enterContext(patch.object(settings, name, value))
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
# No production startup: no external workers, listeners or real data.
|
||||
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def bootstrap(self, *, origin=PUBLIC_ORIGIN, headers=None, **changes):
|
||||
request_headers = {} if origin is None else {"Origin": origin}
|
||||
request_headers.update(headers or {})
|
||||
return self.client.post("/setup/bootstrap", headers=request_headers, json={
|
||||
"setup_token": SETUP_TOKEN,
|
||||
"username": "first-admin",
|
||||
"password": ADMIN_PASSWORD,
|
||||
"application_url": PUBLIC_ORIGIN,
|
||||
**changes,
|
||||
})
|
||||
|
||||
def assert_unclaimed(self):
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.assertIsNone(db.get_setting("magent_application_url"))
|
||||
self.assertEqual(setup.get_setup_state()["step"], "administrator")
|
||||
|
||||
def preflight(self, origin):
|
||||
return self.client.options("/auth/login", headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
})
|
||||
|
||||
def admin_headers(self, origin=PUBLIC_ORIGIN):
|
||||
return {
|
||||
"Origin": origin,
|
||||
"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}",
|
||||
}
|
||||
|
||||
def test_fresh_managed_install_does_not_trust_any_origin_before_claim(self):
|
||||
self.assertTrue(can_claim_initial_origin())
|
||||
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
|
||||
with self.subTest(origin=origin):
|
||||
self.assertFalse(is_allowed_request_origin(origin))
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_valid_operator_claim_creates_admin_and_persists_url_atomically(self):
|
||||
response = self.bootstrap(application_url="HTTPS://WATCH.EXAMPLE.TEST:443/")
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
|
||||
self.assertIsNotNone(db.verify_user_password("first-admin", ADMIN_PASSWORD))
|
||||
self.assertEqual(setup.get_setup_state()["step"], "apps")
|
||||
self.assertFalse(can_claim_initial_origin())
|
||||
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
|
||||
def test_missing_or_null_application_url_does_not_claim(self):
|
||||
response = self.bootstrap(application_url=None)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
response = self.client.post("/setup/bootstrap", headers={"Origin": PUBLIC_ORIGIN}, json={
|
||||
"setup_token": SETUP_TOKEN, "username": "first-admin", "password": ADMIN_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_missing_browser_origin_does_not_claim(self):
|
||||
response = self.bootstrap(origin=None)
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_wrong_token_cannot_claim_even_when_url_matches_evil_origin(self):
|
||||
for origin in (PUBLIC_ORIGIN, "https://evil.example.test"):
|
||||
with self.subTest(origin=origin):
|
||||
response = self.bootstrap(origin=origin, application_url=origin, setup_token="wrong-token")
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assertNotIn(SETUP_TOKEN, response.text)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_different_origin_and_spoofed_routing_headers_cannot_claim(self):
|
||||
response = self.bootstrap(origin="https://evil.example.test", headers={
|
||||
"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test",
|
||||
"X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin",
|
||||
})
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_invalid_application_urls_never_claim_or_echo_secrets(self):
|
||||
with patch.object(setup, "consume_bootstrap_attempt", return_value=None):
|
||||
for value in ("", "https://user:secret@watch.example.test", PUBLIC_ORIGIN + "/path",
|
||||
PUBLIC_ORIGIN + "?", "javascript:alert(1)", "//watch.example.test"):
|
||||
with self.subTest(value=value):
|
||||
response = self.bootstrap(application_url=value)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertNotIn(SETUP_TOKEN, response.text)
|
||||
self.assertNotIn(ADMIN_PASSWORD, response.text)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_other_state_changing_endpoints_do_not_inherit_bootstrap_origin_exception(self):
|
||||
for path, method in (("/auth/login", "post"), ("/auth/jellyfin/login", "post"),
|
||||
("/setup/complete", "post"), ("/setup/state", "put"),
|
||||
("/setup/bootstrap/", "post"), ("/admin/settings", "put")):
|
||||
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
|
||||
with self.subTest(path=path, origin=origin):
|
||||
response = getattr(self.client, method)(path, json={}, headers={"Origin": origin}, follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_existing_admin_prevents_reclaim_and_url_replacement(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
response = self.bootstrap(username="second-admin")
|
||||
self.assertEqual(response.status_code, 409, response.text)
|
||||
response = self.bootstrap(origin="https://evil.example.test", application_url="https://evil.example.test")
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_completed_install_cannot_reopen_origin_claim_after_admin_removal(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
setup.complete_setup()
|
||||
with db._connect() as connection:
|
||||
connection.execute("DELETE FROM users")
|
||||
connection.execute("DELETE FROM settings WHERE key='magent_application_url'")
|
||||
self.assertFalse(can_claim_initial_origin())
|
||||
self.assertEqual(self.bootstrap().status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_setting_write_failure_rolls_back_admin_and_setup_progress(self):
|
||||
with db._connect() as connection:
|
||||
connection.execute("""CREATE TRIGGER reject_origin BEFORE INSERT ON settings
|
||||
WHEN NEW.key = 'magent_application_url'
|
||||
BEGIN SELECT RAISE(ABORT, 'synthetic origin storage failure'); END""")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
setup.bootstrap_administrator(SETUP_TOKEN, "first-admin", ADMIN_PASSWORD, application_url=PUBLIC_ORIGIN)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_concurrent_claims_keep_the_winning_admin_and_origin_together(self):
|
||||
barrier = Barrier(4)
|
||||
|
||||
def synchronized_hash(_):
|
||||
barrier.wait(timeout=10)
|
||||
return "test-only-precomputed-hash"
|
||||
|
||||
def claim(number):
|
||||
try:
|
||||
setup.bootstrap_administrator(
|
||||
SETUP_TOKEN, f"owner-{number}", ADMIN_PASSWORD,
|
||||
application_url=f"https://owner-{number}.example.test",
|
||||
)
|
||||
return number
|
||||
except setup.SetupUnavailableError:
|
||||
return None
|
||||
|
||||
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
winners = [number for number in executor.map(claim, range(4)) if number is not None]
|
||||
self.assertEqual(len(winners), 1)
|
||||
self.assertEqual([user["username"] for user in db.get_all_users()], [f"owner-{winners[0]}"])
|
||||
self.assertEqual(db.get_setting("magent_application_url"), f"https://owner-{winners[0]}.example.test")
|
||||
|
||||
def test_first_https_login_uses_secure_cookie_without_restart(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
self.assertFalse(settings.auth_cookie_secure)
|
||||
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
|
||||
"username": "first-admin", "password": ADMIN_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
cookie = next(value for value in response.headers.get_list("set-cookie")
|
||||
if value.startswith(settings.auth_cookie_name + "="))
|
||||
self.assertIn("Secure", cookie)
|
||||
self.assertIn("HttpOnly", cookie)
|
||||
self.assertEqual(self.client.get("/auth/me").status_code, 200)
|
||||
|
||||
def test_http_lan_claim_uses_non_secure_cookie_despite_static_secure_default(self):
|
||||
origin = "http://magent.lan:3000"
|
||||
self.assertEqual(self.bootstrap(origin=origin, application_url=origin).status_code, 201)
|
||||
with patch.object(settings, "auth_cookie_secure", True):
|
||||
self.assertFalse(auth._cookie_settings()["secure"])
|
||||
self.assertTrue(auth._cookie_settings()["httponly"])
|
||||
as_client = TestClient(main.app, base_url=origin)
|
||||
try:
|
||||
response = as_client.post("/auth/login", headers={"Origin": origin}, data={
|
||||
"username": "first-admin", "password": ADMIN_PASSWORD,
|
||||
})
|
||||
self.assertEqual(as_client.get("/auth/me").status_code, 200)
|
||||
finally:
|
||||
as_client.close()
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
cookie = next(value for value in response.headers.get_list("set-cookie")
|
||||
if value.startswith(settings.auth_cookie_name + "="))
|
||||
self.assertNotIn("Secure", cookie)
|
||||
self.assertIn("HttpOnly", cookie)
|
||||
|
||||
def test_login_origin_policy_and_saved_url_survive_setup_reinitialization(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertFalse(can_claim_initial_origin())
|
||||
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
|
||||
for origin in (LOCAL_ORIGIN, "https://evil.example.test"):
|
||||
response = self.client.post("/auth/login", headers={"Origin": origin}, data={
|
||||
"username": "first-admin", "password": ADMIN_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
|
||||
"username": "first-admin", "password": ADMIN_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
|
||||
def test_managed_origin_policy_changes_with_saved_settings(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
db.set_setting("magent_application_url", "http://magent.lan:3000")
|
||||
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
self.assertTrue(is_allowed_request_origin("http://magent.lan:3000"))
|
||||
self.assertFalse(auth._cookie_settings()["secure"])
|
||||
|
||||
def test_unclaimed_install_does_not_grant_cors_to_any_browser_origin(self):
|
||||
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
|
||||
with self.subTest(origin=origin):
|
||||
response = self.preflight(origin)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
response = self.client.get("/setup/status", headers={"Origin": origin})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
self.assert_unclaimed()
|
||||
|
||||
def test_claim_immediately_updates_cors_preflights_and_response_headers(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
response = self.preflight(PUBLIC_ORIGIN)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
|
||||
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
|
||||
self.assertIn("Origin", response.headers["vary"])
|
||||
response = self.client.get("/setup/status", headers={"Origin": PUBLIC_ORIGIN})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
|
||||
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
|
||||
|
||||
def test_claimed_install_denies_localhost_and_foreign_cors_preflights_and_reads(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
for origin in (LOCAL_ORIGIN, "https://evil.example.test", PUBLIC_ORIGIN + "/", "null"):
|
||||
with self.subTest(origin=origin):
|
||||
response = self.preflight(origin)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
response = self.client.get("/setup/status", headers={"Origin": origin})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
|
||||
def test_admin_cannot_blank_or_malform_managed_url_or_partially_save_other_settings(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
for value in (None, "", " ", False, 123, [], {}, "javascript:alert(1)", "//watch.example.test",
|
||||
PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?", "https://user:secret@watch.example.test"):
|
||||
with self.subTest(value=value):
|
||||
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
|
||||
"site_login_message": "must-not-be-written",
|
||||
"magent_application_url": value,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
|
||||
self.assertIsNone(db.get_setting("site_login_message"))
|
||||
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(can_claim_initial_origin())
|
||||
|
||||
def test_admin_url_update_is_canonical_and_immediately_replaces_cors_origin(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
next_origin = "https://new.example.test"
|
||||
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
|
||||
"magent_application_url": "HTTPS://NEW.EXAMPLE.TEST:443/",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(db.get_setting("magent_application_url"), next_origin)
|
||||
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 400)
|
||||
response = self.preflight(next_origin)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
|
||||
response = self.client.get("/setup/status", headers={"Origin": next_origin})
|
||||
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
|
||||
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={"site_login_message": "stale"})
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
response = self.client.put("/admin/settings", headers=self.admin_headers(next_origin), json={"site_login_message": "new"})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(db.get_setting("site_login_message"), "new")
|
||||
|
||||
def test_proxy_settings_cannot_replace_managed_origin_or_lower_cookie_security(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
db.set_setting("magent_proxy_enabled", "true")
|
||||
db.set_setting("magent_proxy_base_url", "http://proxy.example.test")
|
||||
self.assertEqual(magent_public_url(), PUBLIC_ORIGIN)
|
||||
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin("http://proxy.example.test"))
|
||||
self.assertTrue(auth._cookie_settings()["secure"])
|
||||
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 200)
|
||||
self.assertEqual(self.preflight("http://proxy.example.test").status_code, 400)
|
||||
|
||||
def test_unclaimed_managed_url_ignores_legacy_proxy_and_link_fallback(self):
|
||||
db.set_setting("magent_proxy_enabled", "true")
|
||||
db.set_setting("magent_proxy_base_url", "https://proxy.example.test")
|
||||
self.assertEqual(magent_public_url("https://legacy.example.test"), "")
|
||||
self.assertTrue(can_claim_initial_origin())
|
||||
self.assertFalse(is_allowed_request_origin("https://proxy.example.test"))
|
||||
self.assertEqual(self.preflight("https://proxy.example.test").status_code, 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
from backend.app.config import settings
|
||||
from backend.app.services import manual_releases as manual
|
||||
from backend.app.routers import requests
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.feature_access import permissions, update_permissions
|
||||
from backend.app import db
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class ManualSelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
|
||||
secret.start(); self.addCleanup(secret.stop)
|
||||
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
|
||||
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
|
||||
'rejections': ['WEBDL-2160p is not wanted in profile']}
|
||||
self.payload = {**self.release, 'ignoreProfileLimits': True,
|
||||
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
|
||||
|
||||
def test_profile_only_rejections_are_overridable(self):
|
||||
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
|
||||
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
|
||||
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
|
||||
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
|
||||
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
|
||||
|
||||
def test_receipt_binds_request_user_source_item_and_release(self):
|
||||
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
|
||||
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '43', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
|
||||
(self.payload, '42', self.user, 'http://other', 55),
|
||||
(self.payload, '42', self.user, 'http://sonarr', 56),
|
||||
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
|
||||
for args in attempts:
|
||||
with self.assertRaises(HTTPException): manual.verify_selection(*args)
|
||||
|
||||
def test_permission_revocation_and_literal_confirmation_enforced(self):
|
||||
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
|
||||
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
|
||||
self.assertEqual(error.exception.status_code, code)
|
||||
|
||||
|
||||
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_default_off_individual_and_bulk(self):
|
||||
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
|
||||
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': True}, 'one')
|
||||
self.assertTrue(permissions(one)['ignore_profile_limits'])
|
||||
self.assertFalse(permissions(two)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': False})
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
|
||||
|
||||
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
access = patch.object(
|
||||
requests,
|
||||
'_ensure_request_mutation_access',
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||
active = peak = 0
|
||||
async def search(identity):
|
||||
nonlocal active, peak
|
||||
active += 1; peak = max(peak, active)
|
||||
await asyncio.sleep(0.001); active -= 1
|
||||
return []
|
||||
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
|
||||
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
|
||||
first = await requests.action_search('42', {'username':'viewer','role':'user'})
|
||||
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24)
|
||||
self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset'])
|
||||
self.assertEqual(sonarr.search_episode_releases.await_count,4)
|
||||
self.assertLessEqual(peak,3)
|
||||
self.assertEqual(first['totalMissingEpisodes'],25)
|
||||
|
||||
async def test_auto_search_preserves_current_profile(self):
|
||||
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
|
||||
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
|
||||
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
|
||||
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
|
||||
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
|
||||
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
|
||||
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
|
||||
@@ -0,0 +1,20 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from prometheus_client import REGISTRY, generate_latest
|
||||
from backend.app.metrics import record_api, record_remote
|
||||
|
||||
|
||||
class MetricsTests(unittest.TestCase):
|
||||
def test_route_template_not_private_path(self):
|
||||
request = SimpleNamespace(method='GET', scope={'route': SimpleNamespace(path='/requests/{request_id}')})
|
||||
before = REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}) or 0
|
||||
record_api(request, 200, .1)
|
||||
self.assertEqual(REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}), before + 1)
|
||||
|
||||
def test_unknown_route_and_service_are_bounded(self):
|
||||
record_api(SimpleNamespace(method='SECRET-USER-METHOD', scope={}), 404, .01)
|
||||
record_remote('secret-service-name', 'GET', 'error', .1)
|
||||
data = generate_latest().decode()
|
||||
self.assertNotIn('secret-service-name', data)
|
||||
self.assertNotIn('SECRET-USER-METHOD', data)
|
||||
self.assertIn('route="unmatched"', data)
|
||||
@@ -0,0 +1,209 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
|
||||
from backend.app.routers import insights as router
|
||||
from backend.app.services import insights, monthly_reports as reports
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
from backend.tests.test_insights import LIBRARIES, NOW, USER, play
|
||||
|
||||
|
||||
class MonthlyPeriodTests(unittest.TestCase):
|
||||
def test_default_is_last_complete_calendar_month(self):
|
||||
period = reports.month_periods(None, NOW)
|
||||
self.assertEqual(period['month'], '2026-08')
|
||||
self.assertEqual(period['period_start'], '2026-08-01T00:00:00+00:00')
|
||||
self.assertEqual(period['period_end'], '2026-09-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_start'], '2026-07-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_end'], period['period_start'])
|
||||
self.assertFalse(period['is_partial'])
|
||||
self.assertEqual(len(period['available_months']), 24)
|
||||
|
||||
def test_leap_year_and_year_rollover(self):
|
||||
period = reports.month_periods('2024-02', datetime(2024, 3, 2, tzinfo=timezone.utc))
|
||||
data = insights.summarize([], LIBRARIES, datetime.fromisoformat(period['period_start']),
|
||||
datetime.fromisoformat(period['period_end']), end_exclusive=True)
|
||||
self.assertEqual(len(data['daily']), 29)
|
||||
self.assertEqual(data['daily'][-1]['date'], '2024-02-29')
|
||||
period = reports.month_periods(None, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['month'], '2025-12')
|
||||
self.assertEqual(period['comparison_month'], '2025-11')
|
||||
|
||||
def test_partial_month_matches_elapsed_time_and_caps_short_month(self):
|
||||
period = reports.month_periods('2026-09', NOW)
|
||||
self.assertTrue(period['is_partial'])
|
||||
self.assertEqual(period['comparison_end'], '2026-08-07T12:00:00+00:00')
|
||||
self.assertFalse(period['comparison_capped'])
|
||||
period = reports.month_periods('2026-03', datetime(2026, 3, 31, 12, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['comparison_end'], '2026-03-01T00:00:00+00:00')
|
||||
self.assertTrue(period['comparison_capped'])
|
||||
|
||||
def test_utc_month_is_used_near_local_month_boundary(self):
|
||||
local = datetime(2026, 9, 1, 0, 30, tzinfo=timezone(timedelta(hours=12)))
|
||||
self.assertEqual(reports.month_periods(None, local)['month'], '2026-07')
|
||||
|
||||
def test_invalid_future_and_out_of_range_months_are_rejected(self):
|
||||
for month in ['', '2026-9', '2026-00', '2026-13', '2026-10', '2024-09', '2026-08\r\nheader', '../../file']:
|
||||
with self.subTest(month=month), self.assertRaises(ValueError):
|
||||
reports.month_periods(month, NOW)
|
||||
|
||||
def test_changes_handle_zero_baselines_and_decreases(self):
|
||||
self.assertEqual(reports.change(0, 0), {'current': 0, 'previous': 0, 'difference': 0, 'percent': 0})
|
||||
self.assertIsNone(reports.change(5, 0)['percent'])
|
||||
self.assertEqual(reports.change(30, 60)['percent'], -50)
|
||||
|
||||
def test_adjacent_months_never_double_count_boundary_play(self):
|
||||
start = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
row = play(ActivityDateInserted=start.isoformat())
|
||||
previous = insights.summarize([row], LIBRARIES, reports.shift_month(start, -1), start, end_exclusive=True)
|
||||
current = insights.summarize([row, row, play('next-month', ActivityDateInserted=end.isoformat())], LIBRARIES, start, end, end_exclusive=True)
|
||||
self.assertEqual(previous['summary']['plays'], 0)
|
||||
self.assertEqual(current['summary']['plays'], 1)
|
||||
self.assertEqual(current['daily'][-1]['date'], '2026-08-31')
|
||||
|
||||
|
||||
class MonthlyReportTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
reports._cache.clear()
|
||||
db.create_user('viewer', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jellyfin', jellyfin_api_key='PRIVATE-JF-KEY',
|
||||
jellystat_base_url='http://jellystat', jellystat_api_key='PRIVATE-STATS-KEY')
|
||||
self.runtime_patch = patch.object(reports, 'get_runtime_settings', return_value=self.runtime)
|
||||
self.runtime_patch.start()
|
||||
self.addCleanup(self.runtime_patch.stop)
|
||||
self.clock = patch.object(reports, 'datetime', wraps=datetime)
|
||||
self.clock.start().now.return_value = NOW
|
||||
self.addCleanup(self.clock.stop)
|
||||
|
||||
def add_request(self, request_id, date, owner=42, status=2):
|
||||
db.upsert_request_cache(request_id, request_id, 'movie', status, 'Request', 2026,
|
||||
'viewer', 'viewer', owner, date, date, '{}')
|
||||
|
||||
async def test_report_uses_linked_identity_and_separates_periods_and_request_owners(self):
|
||||
rows = [play('previous', PlaybackDuration=1800, ActivityDateInserted='2026-07-31T23:59:59Z'),
|
||||
play('current', ActivityDateInserted='2026-08-01T00:00:00Z'),
|
||||
play('future', ActivityDateInserted='2026-09-01T00:00:00Z')]
|
||||
for request_id, date, owner in [(1, '2026-07-31T23:59:59Z', 42), (2, '2026-08-01T00:00:00Z', 42),
|
||||
(3, '2026-08-15T00:00:00Z', 99), (4, '2026-09-01T00:00:00Z', 42)]:
|
||||
self.add_request(request_id, date, owner)
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)) as remote, \
|
||||
patch.object(insights.JellyfinClient, 'get_users', new_callable=AsyncMock) as directory:
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
self.assertEqual(result['previous_summary']['minutes'], 30)
|
||||
self.assertEqual(result['changes']['minutes']['percent'], 100)
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(result['previous_requests']['total'], 1)
|
||||
self.assertEqual(result['requests']['recent'][0]['request_id'], 2)
|
||||
self.assertEqual(remote.await_args.args[0], 'jf-viewer')
|
||||
directory.assert_not_called()
|
||||
self.assertNotIn('PRIVATE', json.dumps(result))
|
||||
self.assertNotIn('artwork_item_id', json.dumps(result))
|
||||
self.assertNotIn('jf-viewer', json.dumps(result))
|
||||
|
||||
async def test_partial_comparison_ignores_later_days_in_previous_month(self):
|
||||
rows = [play('previous', ActivityDateInserted='2026-08-07T11:59:59Z'),
|
||||
play('cutoff', ActivityDateInserted='2026-08-07T12:00:00Z'),
|
||||
play('later', ActivityDateInserted='2026-08-30T12:00:00Z'),
|
||||
play('current', ActivityDateInserted='2026-09-01T12:00:00Z')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
result = await reports.get_monthly_report(USER, '2026-09')
|
||||
self.assertEqual(result['previous_summary']['minutes'], 60)
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
|
||||
async def test_cache_isolated_by_identity_month_and_connections_but_requests_refresh(self):
|
||||
db.create_user('second', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('second', 'jf-second', 'http://jellyfin')
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.add_request(1, '2026-08-15T12:00:00Z')
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await reports.get_monthly_report({**USER, 'username': 'second'}, '2026-08')
|
||||
await reports.get_monthly_report(USER, '2026-07')
|
||||
self.runtime.jellystat_api_key = 'rotated-key'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.runtime.jellystat_base_url = 'http://other-jellystat'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(remote.await_count, 5)
|
||||
|
||||
async def test_unlinked_and_unconfigured_never_fetch_history(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock) as remote:
|
||||
result = await reports.get_monthly_report({**USER, 'username': 'unlinked', 'auth_provider': 'local'})
|
||||
self.assertEqual(result['state'], 'unlinked')
|
||||
self.runtime.jellystat_api_key = None
|
||||
result = await reports.get_monthly_report(USER)
|
||||
self.assertEqual(result['state'], 'not_configured')
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_failed_history_is_not_cached_or_returned_as_a_partial_report(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, side_effect=HistoryLimitError()):
|
||||
with self.assertRaises(HistoryLimitError):
|
||||
await reports.get_monthly_report(USER)
|
||||
self.assertEqual(reports._cache, {})
|
||||
|
||||
async def test_csv_preserves_unicode_and_quotes_but_blocks_formulas_and_private_fields(self):
|
||||
rows = [play('csv', ActivityDateInserted='2026-08-10T12:00:00Z', NowPlayingItemName='=HYPERLINK("x")', Client='\t\ufeff@SUM(1,2)'),
|
||||
play('unicode', NowPlayingItemId='second-film', ActivityDateInserted='2026-08-11T12:00:00Z', NowPlayingItemName='Amélie, "Paris"')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
report = await reports.get_monthly_report(USER)
|
||||
exported = reports.report_csv(report)
|
||||
cells = [cell for row in csv.reader(io.StringIO(exported.lstrip('\ufeff'))) for cell in row]
|
||||
self.assertIn('\'=HYPERLINK("x")', cells)
|
||||
self.assertIn("'\t\ufeff@SUM(1,2)", cells)
|
||||
self.assertIn('Amélie, "Paris"', cells)
|
||||
for private in ['PRIVATE', 'jf-viewer', 'artwork/', 'token=', 'http://jellystat']:
|
||||
self.assertNotIn(private, exported)
|
||||
|
||||
|
||||
class MonthlyReportRouteTests(unittest.TestCase):
|
||||
def client(self, authenticated=True):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
|
||||
return TestClient(app)
|
||||
|
||||
def test_both_formats_require_auth_and_reject_scope_overrides(self):
|
||||
for path in ['/insights/reports/monthly', '/insights/reports/monthly.csv']:
|
||||
self.assertEqual(self.client(False).get(path).status_code, 401)
|
||||
for query in ['userid=other', 'scope=server', 'user_id=1', 'month=2026-9', 'month=2026-08%0D%0Ax']:
|
||||
self.assertEqual(self.client().get(path+'?'+query).status_code, 422)
|
||||
|
||||
def test_report_no_store_and_export_attachment_headers(self):
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'ready', 'month':'2026-08'}) as report, \
|
||||
patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
|
||||
response = self.client().get('/insights/reports/monthly?month=2026-08')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
report.assert_awaited_with({**USER, "features": {"stats": True}}, '2026-08')
|
||||
response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(response.headers['x-content-type-options'], 'nosniff')
|
||||
self.assertEqual(response.headers['content-disposition'], 'attachment; filename="magent-monthly-report-2026-08.csv"')
|
||||
self.assertTrue(response.content.startswith(b'\xef\xbb\xbf'))
|
||||
|
||||
def test_errors_are_sanitized_and_unlinked_export_is_blocked(self):
|
||||
for exception, status in [(JellystatError('PRIVATE'), 502), (HistoryLimitError('PRIVATE'), 422), (ValueError('PRIVATE'), 422)]:
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, side_effect=exception):
|
||||
for suffix in ['', '.csv']:
|
||||
response = self.client().get('/insights/reports/monthly'+suffix)
|
||||
self.assertEqual(response.status_code, status)
|
||||
self.assertNotIn('PRIVATE', response.text)
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'unlinked'}):
|
||||
self.assertEqual(self.client().get('/insights/reports/monthly.csv').status_code, 409)
|
||||
@@ -0,0 +1,533 @@
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.routers import newsletters as router
|
||||
from backend.app.services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
|
||||
from backend.app.services import newsletter_email as template, recap_store, recap_email as mail, email_recaps
|
||||
from backend.app.services.jellyfin_identity import link_user, source_key
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
from backend.tests.test_email_recaps import runtime
|
||||
|
||||
USER_ID, SERVER_ID, VIEW_ID = 'a' * 32, 'b' * 32, 'c' * 32
|
||||
MOVIE_ID, SERIES_ID, EPISODE_ID, SECOND_EPISODE = '1' * 32, '2' * 32, '3' * 32, '4' * 32
|
||||
START = datetime(2026, 9, 4, 9, tzinfo=timezone.utc)
|
||||
END = START + timedelta(days=7)
|
||||
|
||||
|
||||
def arrivals():
|
||||
return [dict(Id=MOVIE_ID, Name='Arrival', Type='Movie', DateCreated='2026-09-10T10:00:00Z', ProductionYear=2016, ImageTags={'Primary': 'art'}, Overview='A story <with> meaning.'),
|
||||
dict(Id=EPISODE_ID, Name='Episode one', Type='Episode', SeriesId=SERIES_ID, SeriesName='Severance', SeriesPrimaryImageTag='art', DateCreated='2026-09-09T10:00:00Z', ParentIndexNumber=2, IndexNumber=1),
|
||||
dict(Id=SECOND_EPISODE, Name='Episode two', Type='Episode', SeriesId=SERIES_ID, SeriesName='Severance', SeriesPrimaryImageTag='art', DateCreated='2026-09-08T10:00:00Z', ParentIndexNumber=2, IndexNumber=2)]
|
||||
|
||||
|
||||
def content():
|
||||
titles = catalog.group_arrivals(arrivals(), START, END)
|
||||
for title in titles:
|
||||
title['selected'] = True
|
||||
return dict(titles=titles, total_titles=len(titles), source=source_key('http://jellyfin'), server_id=SERVER_ID,
|
||||
playback_url='https://watch.example.test', period_start=START.isoformat(), period_end=END.isoformat())
|
||||
|
||||
|
||||
def jpeg():
|
||||
image = Image.new('RGB', (100, 150), '#69508c')
|
||||
target = io.BytesIO()
|
||||
image.save(target, format='JPEG')
|
||||
return target.getvalue()
|
||||
|
||||
|
||||
class NewsletterFixture(TempDatabaseMixin):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
|
||||
link_user('viewer', USER_ID, 'http://jellyfin')
|
||||
self.user = db.get_user_by_username('viewer')
|
||||
self.runtime = runtime()
|
||||
self.runtime.jellyfin_api_key = 'PRIVATE-JF-KEY'
|
||||
self.runtime.jellyfin_public_url = 'https://watch.example.test'
|
||||
for target, name, result in [(service, 'get_runtime_settings', self.runtime), (email_recaps, 'get_runtime_settings', self.runtime),
|
||||
(router, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime), (service, 'smtp_email_config_ready', (True, 'ok'))]:
|
||||
mocked = patch.object(target, name, return_value=result)
|
||||
mocked.start(); self.addCleanup(mocked.stop)
|
||||
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
|
||||
env.start(); self.addCleanup(env.stop)
|
||||
self.config = store.save_settings({**store.public_settings(), 'public_url': 'https://beta.example.test'}, datetime.now(timezone.utc))
|
||||
|
||||
def subscribe(self, when=None, table=store):
|
||||
now = time.time() - 10 if when is None else when
|
||||
token = table.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, now)
|
||||
self.assertTrue(table.confirm(table.subscription(self.user['id']), now+1))
|
||||
return table.subscription(self.user['id']), token
|
||||
|
||||
def draft(self):
|
||||
return store.create_edition(content(), 'Weekend discoveries', '', 'viewer', time.time())
|
||||
|
||||
def queue(self, sub=None, edition=None, request='test-request'):
|
||||
sub = sub or self.subscribe()[0]
|
||||
edition = edition or self.draft()
|
||||
return store.enqueue_test(sub, edition['id'], edition['revision'], request, self.config['public_url'], time.time())
|
||||
|
||||
def delivery(self, identity):
|
||||
return store.read_one('SELECT * FROM newsletter_deliveries WHERE id=?', (identity,))
|
||||
|
||||
|
||||
class NewsletterConsentTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_confirmation_only_then_explicit_public_post(self):
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
result = await service.subscribe(self.user)
|
||||
self.assertEqual(result['state'], 'pending')
|
||||
rendered = sender.call_args.args[1]
|
||||
self.assertEqual(rendered['subject'], 'Confirm your Magent newsletter subscription')
|
||||
self.assertIn('NEW IN YOUR LIBRARY', rendered['body_html'])
|
||||
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
|
||||
self.assertNotIn('Arrival', rendered['body_html'])
|
||||
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
|
||||
self.assertEqual(urlsplit(url).path, '/newsletter-subscription')
|
||||
token = parse_qs(urlsplit(url).fragment)['token'][0]
|
||||
self.assertEqual(service.token_action(token, 'confirm')['state'], 'ready')
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
|
||||
self.assertEqual(service.token_action(token, 'confirm', apply=True)['state'], 'enabled')
|
||||
with self.assertRaises(service.NewsletterError):
|
||||
service.token_action(token, 'confirm', apply=True)
|
||||
|
||||
async def test_monthly_consent_is_not_automatic_newsletter_consent(self):
|
||||
recap, _ = self.subscribe(table=recap_store)
|
||||
self.assertEqual(service.preferences(self.user)['state'], 'off')
|
||||
with patch.object(mail, 'send_email') as sender:
|
||||
result = await service.subscribe(self.user)
|
||||
self.assertEqual(result['state'], 'enabled')
|
||||
sender.assert_not_called()
|
||||
sub = store.subscription(self.user['id'])
|
||||
service.token_action(sub['unsubscribe_token'], 'unsubscribe', apply=True)
|
||||
self.assertEqual(recap_store.subscription(self.user['id'])['state'], 'enabled')
|
||||
with self.assertRaises(service.NewsletterError):
|
||||
service.token_action(recap['unsubscribe_token'], 'unsubscribe', apply=True)
|
||||
|
||||
def test_unsubscribe_read_only_check_and_repeated_apply(self):
|
||||
sub, _ = self.subscribe()
|
||||
identity = self.queue(sub)
|
||||
token = sub['unsubscribe_token']
|
||||
self.assertEqual(service.token_action(token, 'unsubscribe')['state'], 'ready')
|
||||
self.assertEqual(self.delivery(identity)['state'], 'queued')
|
||||
for _ in range(2):
|
||||
self.assertEqual(service.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
|
||||
self.assertEqual(self.delivery(identity)['state'], 'cancelled')
|
||||
|
||||
def test_address_and_identity_changes_revoke_consent(self):
|
||||
for index, change in enumerate(['email', 'identity', 'blocked', 'source']):
|
||||
with self.subTest(change=change):
|
||||
self.subscribe(time.time() - 1600 + index * 400)
|
||||
if change == 'email':
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
db.set_user_email('viewer', 'viewer@example.test')
|
||||
elif change == 'identity':
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE jellyfin_user_links SET jellyfin_user_id=?', ('d' * 32,))
|
||||
conn.execute('UPDATE jellyfin_user_links SET jellyfin_user_id=?', (USER_ID,))
|
||||
elif change == 'blocked':
|
||||
with store.transaction() as conn:
|
||||
conn.execute('UPDATE users SET is_blocked=1')
|
||||
conn.execute('UPDATE users SET is_blocked=0')
|
||||
else:
|
||||
self.runtime.jellyfin_base_url = 'http://changed-jellyfin'
|
||||
self.assertEqual(service.preferences(self.user)['state'], 'off')
|
||||
|
||||
async def test_failed_confirmation_stays_pending_and_rate_limited(self):
|
||||
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'fixture')):
|
||||
with self.assertRaises(service.NewsletterError) as raised:
|
||||
await service.subscribe(self.user)
|
||||
self.assertEqual(raised.exception.status, 502)
|
||||
with self.assertRaises(service.NewsletterError) as raised:
|
||||
await service.subscribe(self.user)
|
||||
self.assertEqual(raised.exception.status, 429)
|
||||
self.assertEqual(service.preferences(self.user)['state'], 'pending')
|
||||
|
||||
def test_expired_or_changed_confirmation_cannot_enable(self):
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time()-90000)
|
||||
self.assertEqual(service.preferences(self.user)['state'], 'expired')
|
||||
with self.assertRaises(service.NewsletterError):
|
||||
service.token_action(token, 'confirm', apply=True)
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time())
|
||||
previous = store.subscription(self.user['id'])
|
||||
db.set_user_email('viewer', 'changed@example.test')
|
||||
self.assertFalse(store.confirm(previous, time.time()))
|
||||
with self.assertRaises(service.NewsletterError):
|
||||
service.token_action(token, 'confirm', apply=True)
|
||||
|
||||
|
||||
class NewsletterEditionTests(NewsletterFixture, unittest.TestCase):
|
||||
def test_defaults_paused_no_users_opted_in(self):
|
||||
self.assertFalse(store.settings()['enabled'])
|
||||
self.assertEqual(store.overview()['subscribers'], 0)
|
||||
self.assertIsNone(store.claim_weekly(datetime.now(timezone.utc)))
|
||||
|
||||
def test_conflicts_and_title_injection_fail(self):
|
||||
row = self.draft()
|
||||
selections = [{key: title[key] for key in ('id', 'selected', 'featured')} for title in row['content']['titles']]
|
||||
for values in [[], [*selections, selections[0]], [{**selections[0], 'id': 'f'*32}, selections[1]], [{**selections[0], 'selected': False, 'featured': True}, selections[1]]]:
|
||||
with self.assertRaises(store.Conflict):
|
||||
store.update_edition(row['id'], 1, 'Updated', '', values, time.time())
|
||||
result = store.update_edition(row['id'], 1, 'Updated', 'An announcement', selections, time.time())
|
||||
self.assertEqual(result['revision'], 2)
|
||||
with self.assertRaises(store.Conflict):
|
||||
store.update_edition(row['id'], 1, 'Stale tab', '', selections, time.time())
|
||||
|
||||
def test_test_version_is_frozen_and_request_idempotent(self):
|
||||
sub, _ = self.subscribe()
|
||||
row = self.draft()
|
||||
identity = self.queue(sub, row)
|
||||
self.assertEqual(self.queue(sub, row), identity)
|
||||
selections = [{key: title[key] for key in ('id', 'selected', 'featured')} for title in row['content']['titles']]
|
||||
changed = store.update_edition(row['id'], 1, 'Changed after test queued', '', selections, time.time())
|
||||
self.assertEqual(store.version(self.delivery(identity))['subject'], 'Weekend discoveries')
|
||||
with self.assertRaises(store.Conflict):
|
||||
self.queue(sub, changed)
|
||||
with self.assertRaises(store.Conflict):
|
||||
self.queue(sub, changed, 'different-request')
|
||||
|
||||
def test_publish_is_immutable_and_delivery_deduplicated_under_concurrency(self):
|
||||
self.subscribe()
|
||||
row = self.draft()
|
||||
now = time.time()
|
||||
store.publish(row['id'], 1, now, now)
|
||||
self.assertEqual(store.publish(row['id'], 1, now+1, now+1)['send_at'], now)
|
||||
with self.assertRaises(store.Conflict):
|
||||
store.update_edition(row['id'], 1, 'No longer editable', '', [], now)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
list(pool.map(store.enqueue_due, [now+5]*4))
|
||||
self.assertEqual(store.overview()['total'], 1)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
claims = list(pool.map(store.claim_delivery, [now+5]*4))
|
||||
self.assertEqual(sum(claim is not None for claim in claims), 1)
|
||||
store.cancel(row['id'], now+6)
|
||||
self.assertEqual(store.overview()['deliveries'][0]['state'], 'cancelled')
|
||||
self.assertFalse(store.begin_sending(next(claim for claim in claims if claim), now+6))
|
||||
|
||||
def test_empty_edition_needs_announcement(self):
|
||||
row = store.create_edition({**content(), 'titles': []}, 'Announcement', '', 'viewer', time.time())
|
||||
with self.assertRaises(store.Conflict):
|
||||
store.publish(row['id'], 1, time.time(), time.time())
|
||||
row = store.update_edition(row['id'], 1, 'Announcement', 'Welcome to the weekend.', [], time.time())
|
||||
self.assertEqual(store.publish(row['id'], 2, time.time(), time.time())['state'], 'scheduled')
|
||||
|
||||
def test_schedule_boundary_utc_and_no_past_time(self):
|
||||
self.assertEqual(store.next_due(END, 4, 9), END+timedelta(days=7))
|
||||
self.assertEqual(store.next_due(END-timedelta(seconds=1), 4, 9), END)
|
||||
row = self.draft()
|
||||
for when in [datetime.now(), datetime.now(timezone.utc)-timedelta(days=1), datetime.now(timezone.utc)+timedelta(days=91)]:
|
||||
with self.assertRaises(service.NewsletterError):
|
||||
service.publish(row['id'], 1, when)
|
||||
|
||||
def test_weekly_catchup_once_not_every_missed_week_and_late_subscriber_excluded(self):
|
||||
config = store.save_settings({**self.config, 'enabled': True}, START-timedelta(days=40))
|
||||
self.subscribe(END.timestamp()+30)
|
||||
claimed = store.claim_weekly(END+timedelta(hours=1))
|
||||
self.assertEqual(claimed['due'], END)
|
||||
self.assertIsNone(store.claim_weekly(END+timedelta(hours=1)))
|
||||
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
|
||||
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
|
||||
store.enqueue_due((END+timedelta(hours=1)).timestamp())
|
||||
self.assertEqual(len(store.overview()['editions']), 1)
|
||||
self.assertEqual(store.overview()['editions'][0]['subject'], 'What’s new in your library · 11 Sep 2026')
|
||||
self.assertEqual(store.overview()['total'], 0)
|
||||
self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp())
|
||||
self.assertTrue(config['enabled'])
|
||||
|
||||
def test_empty_week_is_skipped_and_generation_failure_retries_three_times(self):
|
||||
store.save_settings({**self.config, 'enabled': True}, START)
|
||||
now = END
|
||||
for attempt in range(3):
|
||||
claimed = store.claim_weekly(now)
|
||||
self.assertEqual(claimed['generation_attempts'], attempt+1)
|
||||
store.complete_weekly(claimed, None, now, 'Jellyfin unavailable')
|
||||
self.assertIsNone(store.claim_weekly(now+timedelta(seconds=1)))
|
||||
now += timedelta(seconds=301)
|
||||
self.assertEqual(store.overview()['editions'], [])
|
||||
claimed = store.claim_weekly(END+timedelta(days=7))
|
||||
store.complete_weekly(claimed, {**content(), 'titles': []}, END+timedelta(days=7))
|
||||
self.assertEqual(store.overview()['editions'][0]['state'], 'skipped')
|
||||
self.assertEqual(store.overview()['total'], 0)
|
||||
|
||||
def test_pause_cancels_weekly_but_preserves_manual_and_stale_generation_cannot_publish(self):
|
||||
self.subscribe(START.timestamp()-10)
|
||||
manual = self.draft()
|
||||
store.publish(manual['id'], 1, END.timestamp()+3600, START.timestamp())
|
||||
config = store.save_settings({**self.config, 'enabled': True}, START)
|
||||
claimed = store.claim_weekly(END)
|
||||
store.complete_weekly(claimed, content(), END)
|
||||
store.enqueue_due(END.timestamp())
|
||||
store.save_settings({**config, 'enabled': False}, END)
|
||||
self.assertEqual(store.edition(manual['id'])['state'], 'scheduled')
|
||||
self.assertEqual(store.overview()['deliveries'][0]['state'], 'cancelled')
|
||||
store.complete_weekly(claimed, content(), END)
|
||||
self.assertEqual(len(store.overview()['editions']), 2)
|
||||
with self.assertRaises(store.Conflict):
|
||||
store.save_settings(config, END)
|
||||
|
||||
def test_stale_smtp_claim_becomes_unknown_and_never_retried(self):
|
||||
identity = self.queue()
|
||||
now = time.time()
|
||||
claimed = store.claim_delivery(now)
|
||||
self.assertTrue(store.begin_sending(claimed, now))
|
||||
self.assertIsNone(store.claim_delivery(now+1801))
|
||||
self.assertEqual(self.delivery(identity)['state'], 'unknown')
|
||||
self.assertIsNone(store.claim_delivery(now+7200))
|
||||
|
||||
|
||||
class NewsletterCatalogTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.runtime = runtime()
|
||||
self.runtime.jellyfin_api_key = 'private'
|
||||
|
||||
def test_grouped_tv_boundary_deduplication_virtual_and_missing_names(self):
|
||||
rows = arrivals()
|
||||
rows += [rows[0], {**rows[0], 'Id': '5'*32, 'DateCreated': END.isoformat()},
|
||||
{**rows[0], 'Id': '6'*32, 'DateCreated': START.isoformat()},
|
||||
{**rows[0], 'Id': '7'*32, 'LocationType': 'Virtual'},
|
||||
{**rows[1], 'Id': '8'*32, 'SeriesId': '9'*32, 'SeriesName': None}]
|
||||
result = catalog.group_arrivals(rows, START, END)
|
||||
self.assertEqual(len(result), 3)
|
||||
tv = next(title for title in result if title['type'] == 'series')
|
||||
self.assertEqual(tv['id'], SERIES_ID)
|
||||
self.assertEqual(len(tv['items']), 2)
|
||||
self.assertTrue(tv['has_artwork'])
|
||||
self.assertIn('2 new episodes', template.description(tv))
|
||||
|
||||
async def test_collect_pages_and_fails_closed_on_incomplete_or_changed_library(self):
|
||||
async def collect_pages(pages):
|
||||
with patch.object(catalog, 'PAGE_SIZE', 2), patch.object(catalog, 'MAX_ITEMS', 4), patch.object(catalog, 'get_json', new=AsyncMock(side_effect=[{'Id': SERVER_ID}, *pages])):
|
||||
return await catalog.collect(self.runtime, START, END, 1)
|
||||
rows = arrivals()
|
||||
result = await collect_pages([{'Items': rows[:2], 'TotalRecordCount': 3}, {'Items': rows[2:], 'TotalRecordCount': 3}])
|
||||
self.assertEqual(result['total_titles'], 2)
|
||||
self.assertEqual(sum(title['selected'] for title in result['titles']), 1)
|
||||
cases = [
|
||||
[{'Items': rows[:1], 'TotalRecordCount': 3}],
|
||||
[{'Items': rows[:2], 'TotalRecordCount': None}],
|
||||
[{'Items': rows[:2], 'TotalRecordCount': 3}, {'Items': rows[:1], 'TotalRecordCount': 3}],
|
||||
[{'Items': [rows[1], rows[0]], 'TotalRecordCount': 2}],
|
||||
[{'Items': [{**rows[0], 'DateCreated': None}], 'TotalRecordCount': 1}],
|
||||
[{'Items': rows[:2], 'TotalRecordCount': 5}, {'Items': [rows[2], {**rows[2], 'Id': '5'*32}], 'TotalRecordCount': 5}],
|
||||
]
|
||||
for pages in cases:
|
||||
with self.subTest(pages=pages), self.assertRaises(catalog.CatalogError):
|
||||
await collect_pages(pages)
|
||||
|
||||
async def test_recipient_scope_requires_permitted_parent_even_with_ids(self):
|
||||
async def jellyfin(client, runtime, path, params=None):
|
||||
if path == '/System/Info': return {'Id': SERVER_ID}
|
||||
if path.startswith('/Users/'): return {'Id': USER_ID, 'Policy': {'IsDisabled': False}}
|
||||
if path == '/UserViews':
|
||||
self.assertEqual(params['UserId'], USER_ID)
|
||||
return {'Items': [{'Id': VIEW_ID}]}
|
||||
self.assertEqual(path, '/Items')
|
||||
self.assertEqual(params['ParentId'], VIEW_ID)
|
||||
self.assertEqual(params['UserId'], USER_ID)
|
||||
self.assertEqual(params['Recursive'], 'true')
|
||||
# The movie and second episode belong to a restricted library.
|
||||
return {'Items': [{'Id': SERIES_ID}, {'Id': EPISODE_ID}]}
|
||||
with patch.object(catalog, 'get_json', side_effect=jellyfin):
|
||||
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
|
||||
self.assertEqual(len(result['titles']), 1)
|
||||
self.assertEqual(len(result['titles'][0]['items']), 1)
|
||||
self.assertIn('1 new episode', template.description(result['titles'][0]))
|
||||
|
||||
async def test_no_views_does_not_fall_back_to_unrestricted_lookup(self):
|
||||
responses = [{'Id': SERVER_ID}, {'Id': USER_ID, 'Policy': {}}, {'Items': []}]
|
||||
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=responses)) as get:
|
||||
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
|
||||
self.assertEqual(result['titles'], [])
|
||||
self.assertEqual(get.await_count, 3)
|
||||
|
||||
async def test_changed_server_or_user_and_disabled_account_stop_delivery(self):
|
||||
for responses in [[{'Id': 'f'*32}], [{'Id': SERVER_ID}, {'Id': 'f'*32, 'Policy': {}}], [{'Id': SERVER_ID}, {}]]:
|
||||
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=responses)), self.assertRaises(catalog.CatalogError):
|
||||
await catalog.for_recipient(self.runtime, content(), USER_ID)
|
||||
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=[{'Id': SERVER_ID}, {'Id': USER_ID, 'Policy': {'IsDisabled': True}}])):
|
||||
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
|
||||
self.assertTrue(result['recipient_disabled'])
|
||||
|
||||
async def test_posters_are_bounded_reencoded_cached_and_keys_stay_server_side(self):
|
||||
original = httpx.AsyncClient
|
||||
requests = []
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
self.assertEqual(request.headers['X-Emby-Token'], 'private')
|
||||
self.assertNotIn('private', str(request.url))
|
||||
return httpx.Response(200, content=jpeg(), headers={'Content-Type': 'image/jpeg'})
|
||||
catalog._posters.clear()
|
||||
with patch.object(catalog.httpx, 'AsyncClient', side_effect=lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs)):
|
||||
result = await catalog.poster(self.runtime, MOVIE_ID)
|
||||
self.assertEqual(await catalog.poster(self.runtime, MOVIE_ID), result)
|
||||
self.assertEqual(len(requests), 1)
|
||||
with Image.open(io.BytesIO(result)) as image:
|
||||
self.assertEqual(image.format, 'JPEG')
|
||||
self.assertLessEqual(image.width, 160)
|
||||
for data in [b'not an image', b'x' * (512*1024+1)]:
|
||||
catalog._posters.clear()
|
||||
with patch.object(catalog.httpx, 'AsyncClient', side_effect=lambda **kwargs: original(transport=httpx.MockTransport(lambda request: httpx.Response(200, content=data)), **kwargs)):
|
||||
self.assertIsNone(await catalog.poster(self.runtime, MOVIE_ID))
|
||||
|
||||
|
||||
class NewsletterDeliveryTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_draft_has_generic_subject_and_preserves_custom_intro(self):
|
||||
custom_intro = 'News from our own media community.'
|
||||
store.save_settings({**self.config, 'intro': custom_intro}, datetime.now(timezone.utc))
|
||||
with patch.object(service, 'collect', new=AsyncMock(return_value=content())):
|
||||
draft = await service.create_draft(self.user, 7)
|
||||
self.assertTrue(draft['subject'].startswith('What’s new in your library · '))
|
||||
self.assertEqual(draft['intro'], custom_intro)
|
||||
|
||||
def test_generic_email_template_preserves_custom_subject_and_intro(self):
|
||||
custom_subject = 'Grizzlyflix weekend discoveries'
|
||||
custom_intro = 'Welcome to our own <media> community.'
|
||||
rendered = template.render(
|
||||
{**content(), 'subject': custom_subject, 'intro': custom_intro}, {},
|
||||
self.config['public_url'], self.runtime.jellyfin_public_url,
|
||||
'https://beta.example.test/profile#newsletters',
|
||||
)
|
||||
self.assertEqual(rendered['subject'], custom_subject)
|
||||
self.assertIn(custom_subject, rendered['body_text'])
|
||||
self.assertIn(custom_intro, rendered['body_text'])
|
||||
self.assertIn('Welcome to our own <media> community.', rendered['body_html'])
|
||||
self.assertIn('Watch on Jellyfin', rendered['body_html'])
|
||||
self.assertIn('Explore Jellyfin', rendered['body_html'])
|
||||
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
|
||||
|
||||
async def test_weekly_worker_collects_once_and_delivers_to_confirmed_subscriber(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
self.subscribe((now-timedelta(days=14)).timestamp())
|
||||
store.save_settings({**self.config, 'enabled': True}, now-timedelta(days=10))
|
||||
def scoped(runtime, snapshot, identity):
|
||||
self.assertEqual(identity, USER_ID)
|
||||
return snapshot
|
||||
def captured(recipient, rendered, message_id, before_data):
|
||||
before_data()
|
||||
with patch.object(service, 'collect', new=AsyncMock(return_value=content())) as collect, patch.object(catalog, 'for_recipient', new=AsyncMock(side_effect=scoped)), patch.object(catalog, 'posters', new=AsyncMock(return_value={})), patch.object(mail, 'send_email', side_effect=captured) as send:
|
||||
await service.run_once()
|
||||
await service.run_once()
|
||||
self.assertEqual(collect.await_count, 1)
|
||||
self.assertEqual(send.call_count, 1)
|
||||
self.assertEqual(store.overview()['total'], 1)
|
||||
self.assertEqual(store.overview()['deliveries'][0]['state'], 'sent')
|
||||
self.assertEqual(store.overview()['editions'][0]['state'], 'complete')
|
||||
self.assertGreater(store.settings()['next_send_at'], now.timestamp())
|
||||
|
||||
async def test_captured_smtp_contains_inline_posters_and_only_accessible_titles(self):
|
||||
identity = self.queue()
|
||||
scoped = {**content(), 'titles': content()['titles'][1:]}
|
||||
scoped.update(subject='Weekend discoveries', intro='A <b>plain text</b> welcome')
|
||||
smtp = MagicMock()
|
||||
smtp.mail.return_value = smtp.rcpt.return_value = smtp.data.return_value = (250, b'ok')
|
||||
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value=scoped)) as scope, patch.object(catalog, 'posters', new=AsyncMock(return_value={SERIES_ID: jpeg()})), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
await service.process_delivery(store.claim_delivery(time.time()))
|
||||
self.assertEqual(scope.call_args.args[2], USER_ID)
|
||||
self.assertEqual(self.delivery(identity)['state'], 'sent')
|
||||
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
|
||||
body = message.get_body(preferencelist=('html',)).get_content()
|
||||
self.assertIn('Severance', body)
|
||||
self.assertNotIn('Arrival</h3>', body)
|
||||
self.assertIn('<b>plain text</b>', body)
|
||||
self.assertNotIn('PRIVATE-', body)
|
||||
self.assertIn('cid:newsletter-', body)
|
||||
self.assertNotIn('data:image', body)
|
||||
self.assertIn('serverId=' + SERVER_ID, body)
|
||||
images = [part for part in message.walk() if part.get_content_type() == 'image/jpeg']
|
||||
self.assertEqual(len(images), 1)
|
||||
self.assertEqual(images[0].get_payload(decode=True), jpeg())
|
||||
self.assertIn('/newsletter-subscription#action=unsubscribe', body)
|
||||
self.assertEqual(str(message['To']), 'viewer@example.test')
|
||||
|
||||
async def test_cancel_between_preparation_and_smtp_data_stops_send(self):
|
||||
identity = self.queue()
|
||||
scoped = {**content(), 'subject': 'Subject', 'intro': ''}
|
||||
def send(recipient, rendered, message_id, before_data):
|
||||
store.disable(self.user['id'])
|
||||
before_data()
|
||||
self.fail('SMTP DATA must not run after unsubscribe')
|
||||
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value=scoped)), patch.object(catalog, 'posters', new=AsyncMock(return_value={})), patch.object(mail, 'send_email', side_effect=send):
|
||||
await service.process_delivery(store.claim_delivery(time.time()))
|
||||
self.assertEqual(self.delivery(identity)['state'], 'cancelled')
|
||||
|
||||
async def test_access_failure_retries_but_empty_access_skips(self):
|
||||
identity = self.queue()
|
||||
with patch.object(catalog, 'for_recipient', new=AsyncMock(side_effect=catalog.CatalogError('fixture'))), patch.object(mail, 'send_email') as send:
|
||||
await service.process_delivery(store.claim_delivery(time.time()))
|
||||
self.assertEqual(self.delivery(identity)['state'], 'retry')
|
||||
send.assert_not_called()
|
||||
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value={**content(), 'titles': [], 'intro': ''})), patch.object(mail, 'send_email') as send:
|
||||
await service.process_delivery(store.claim_delivery(time.time()+301))
|
||||
self.assertEqual(self.delivery(identity)['state'], 'skipped')
|
||||
send.assert_not_called()
|
||||
|
||||
def test_multiple_images_share_one_related_mime_container(self):
|
||||
rendered = template.render({**content(), 'subject': 'Subject', 'intro': ''}, {MOVIE_ID: jpeg(), SERIES_ID: jpeg()}, self.config['public_url'], self.runtime.jellyfin_public_url, 'https://beta.example.test/profile#newsletters')
|
||||
smtp = MagicMock()
|
||||
smtp.mail.return_value = smtp.rcpt.return_value = smtp.data.return_value = (250, b'ok')
|
||||
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
|
||||
mail.send_email('viewer@example.test', rendered, '<test@example.test>')
|
||||
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
|
||||
related = [part for part in message.walk() if part.get_content_type() == 'multipart/related']
|
||||
self.assertEqual(len(related), 1)
|
||||
self.assertEqual(len(related[0].get_payload()), 3)
|
||||
|
||||
|
||||
class NewsletterApiTests(NewsletterFixture, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
self.actor = {'username': 'viewer', 'role': 'admin'}
|
||||
app.dependency_overrides[get_current_user] = lambda: self.actor
|
||||
self.client = TestClient(app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def test_admin_endpoints_and_personal_preference_do_not_accept_other_identity(self):
|
||||
identity = self.draft()['id']
|
||||
self.actor['role'] = 'user'
|
||||
for method, path, payload in [('GET', '/admin/newsletters', None), ('PUT', '/admin/newsletters', self.config),
|
||||
('POST', '/admin/newsletters/drafts', {'days': 7}), ('GET', '/admin/newsletters/editions/'+identity, None),
|
||||
('POST', f'/admin/newsletters/editions/{identity}/publish', {'revision': 1}), ('GET', '/admin/newsletters/artwork/'+MOVIE_ID, None)]:
|
||||
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
|
||||
response = self.client.get('/profile/newsletters')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('no-store', response.headers['cache-control'])
|
||||
for extra in [{'email': 'other@example.test'}, {'user_id': 99}, {'jellyfin_id': USER_ID}]:
|
||||
self.assertEqual(self.client.put('/profile/newsletters', json={'enabled': True, **extra}).status_code, 422)
|
||||
|
||||
def test_admin_settings_validate_origin_revision_and_subject(self):
|
||||
payload = {key: self.config[key] for key in ('enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision')}
|
||||
for invalid in ['https://beta.example.test/path', 'javascript:alert(1)', 'https://user:pass@example.test']:
|
||||
self.assertEqual(self.client.put('/admin/newsletters', json={**payload, 'public_url': invalid}).status_code, 422)
|
||||
self.assertEqual(self.client.put('/admin/newsletters', json=payload).status_code, 200)
|
||||
self.assertEqual(self.client.put('/admin/newsletters', json=payload).status_code, 409)
|
||||
row = self.draft()
|
||||
values = [{'id': entry['id'], 'selected': entry['selected'], 'featured': entry['featured']} for entry in row['content']['titles']]
|
||||
for subject in [' ', 'Subject\r\nBcc: someone@example.test']:
|
||||
self.assertEqual(self.client.put(f"/admin/newsletters/editions/{row['id']}", json={'revision': 1, 'subject': subject, 'intro': '', 'titles': values}).status_code, 422)
|
||||
|
||||
def test_confirmation_get_never_changes_consent_and_invalid_action_rejected(self):
|
||||
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time())
|
||||
self.assertEqual(self.client.get('/newsletter-subscription/confirm').status_code, 405)
|
||||
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
|
||||
self.assertEqual(self.client.post('/newsletter-subscription/check', json={'token': token, 'action': 'confirm'}).json()['state'], 'ready')
|
||||
self.assertEqual(self.client.post('/newsletter-subscription/confirm', json={'token': token, 'action': 'subscribe'}).status_code, 422)
|
||||
self.assertEqual(self.client.post('/newsletter-subscription/confirm', json={'token': token, 'action': 'confirm'}).json()['state'], 'enabled')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.app.routers import portal
|
||||
|
||||
|
||||
class PortalPrivacyTests(unittest.TestCase):
|
||||
def test_detail_and_comments_are_private_for_regular_users(self):
|
||||
item = {'id': 1, 'kind': 'issue', 'title': 'Broken movie', 'status': 'new',
|
||||
'created_by_username': 'private-reporter', 'created_by_id': 42,
|
||||
'assignee_username': 'private-admin', 'metadata_json': '{"email":"secret@example.com"}',
|
||||
'description': 'Contact private-reporter or secret@example.com', 'created_at': '2026-09-07'}
|
||||
comment = {'id': 1, 'item_id': 1, 'author_username': 'private-admin', 'author_role': 'admin',
|
||||
'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False}
|
||||
app = FastAPI()
|
||||
app.include_router(portal.router)
|
||||
app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'issues': True, 'requests': True, 'new_requests': True}}
|
||||
with patch.object(portal, 'get_portal_item', return_value=item), \
|
||||
patch.object(portal, '_list_portal_comments', return_value=[comment]), \
|
||||
patch.object(portal, 'list_portal_item_activity', return_value=[]), \
|
||||
patch.object(portal, 'issue_resolution_state', return_value={}), \
|
||||
patch.object(portal, 'get_all_users', return_value=[{'username': 'private-reporter', 'email': 'secret@example.com'}, {'username': 'private-admin'}]):
|
||||
client = TestClient(app)
|
||||
for path in ['/portal/items/1', '/portal/items/1/comments']:
|
||||
response = client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for secret in ['private-reporter', 'private-admin', 'secret@example.com', 'metadata_json', 'assignee_username', 'created_by_id']:
|
||||
self.assertNotIn(secret, response.text)
|
||||
admin_result = portal._serialize_item(item, {'username': 'admin', 'role': 'admin'})
|
||||
self.assertEqual(admin_result['created_by_username'], 'private-reporter')
|
||||
own_result = portal._serialize_item(item, {'username': 'private-reporter', 'role': 'user'})
|
||||
self.assertTrue(own_result['permissions']['can_edit'])
|
||||
@@ -0,0 +1,57 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.services import public_urls, newsletter_store, recap_store, newsletters, newsletter_catalog
|
||||
from backend.tests.test_newsletters import NewsletterFixture
|
||||
|
||||
|
||||
class PublicUrlTests(unittest.TestCase):
|
||||
def resolve(self, application=None, proxy=None, enabled=False, legacy='https://legacy.test'):
|
||||
with patch.object(public_urls,'get_runtime_settings',return_value=SimpleNamespace(
|
||||
magent_application_url=application,magent_proxy_base_url=proxy,magent_proxy_enabled=enabled)):
|
||||
return public_urls.magent_public_url(legacy)
|
||||
|
||||
def test_hosting_is_authoritative_with_proxy_and_path_support(self):
|
||||
self.assertEqual(self.resolve('https://prod.test/'),'https://prod.test')
|
||||
self.assertEqual(self.resolve('http://internal:3000','https://public.test/magent/',True),'https://public.test/magent')
|
||||
self.assertEqual(self.resolve('https://prod.test','https://old-proxy.test',False),'https://prod.test')
|
||||
self.assertEqual(self.resolve(),'https://legacy.test')
|
||||
|
||||
def test_invalid_configured_address_does_not_use_stale_legacy(self):
|
||||
for value in ['javascript:alert(1)','https://user:password@host.test','https://host.test?key=secret','https://host.test/#fragment','https://host.test:99999','https://host.test/ bad']:
|
||||
self.assertEqual(self.resolve(value),'')
|
||||
|
||||
|
||||
class NewsletterHostingTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_existing_draft_previews_using_hosting_without_duplicate_url(self):
|
||||
draft=self.draft()
|
||||
with newsletter_store.transaction() as c:
|
||||
c.execute("UPDATE newsletter_settings SET public_url=''")
|
||||
self.runtime.magent_application_url='https://prod.example.test'
|
||||
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime),patch.object(newsletter_catalog,'posters',new=AsyncMock(return_value={})):
|
||||
rendered=await newsletters.preview(draft['id'],draft['revision'])
|
||||
self.assertIn('https://prod.example.test/profile#newsletters',rendered['body_html'])
|
||||
self.assertIn('https://watch.example.test',rendered['body_html'])
|
||||
self.assertNotIn('https://beta.example.test',rendered['body_html'])
|
||||
self.assertEqual(recap_store.settings()['public_url'],'https://prod.example.test')
|
||||
|
||||
def test_scheduled_delivery_uses_current_hosting_address(self):
|
||||
self.subscribe(when=100)
|
||||
draft=self.draft()
|
||||
newsletter_store.publish(draft['id'],draft['revision'],200,150)
|
||||
self.runtime.magent_application_url='https://prod.example.test'
|
||||
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
|
||||
newsletter_store.enqueue_due(201)
|
||||
delivery=newsletter_store.read_one('SELECT * FROM newsletter_deliveries WHERE edition_id=?',(draft['id'],))
|
||||
self.assertEqual(delivery['public_url'],'https://prod.example.test')
|
||||
self.runtime.magent_application_url='https://new.example.test'
|
||||
self.assertEqual(newsletter_store.settings()['public_url'],'https://new.example.test')
|
||||
|
||||
def test_saving_schedule_uses_hosting_instead_of_client_address(self):
|
||||
self.runtime.magent_application_url='https://prod.example.test'
|
||||
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
|
||||
result=newsletter_store.save_settings({**self.config,'public_url':'https://stale.example.test'},datetime.now(timezone.utc))
|
||||
self.assertEqual(result['public_url'],'https://prod.example.test')
|
||||
result=recap_store.save_settings({'enabled':False,'day':2,'hour':9,'public_url':''},datetime.now(timezone.utc))
|
||||
self.assertEqual(result['public_url'],'https://prod.example.test')
|
||||
@@ -0,0 +1,65 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, AsyncMock, patch
|
||||
from contextlib import ExitStack
|
||||
from backend.app.routers import requests
|
||||
|
||||
|
||||
class RecentStageTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_displayed_stage_controls_filter_and_pagination(self):
|
||||
runtime = SimpleNamespace(jellyseerr_base_url='', jellyseerr_api_key='',
|
||||
requests_data_source='prefer_cache', artwork_cache_mode='remote',
|
||||
jellyfin_base_url='', jellyfin_api_key='')
|
||||
rows = [dict(request_id=i, title=str(i), status=status, media_type='movie',
|
||||
requested_by_id=10) for i, status in [(1,5),(2,5),(3,4),(4,6),(5,5),(6,2),(7,1),(8,3)]]
|
||||
async def available(client, title, *args): return title in {'1','3','4','5'}
|
||||
with ExitStack() as stack:
|
||||
for name, value in [('get_runtime_settings', runtime), ('get_request_stage_cache', {1:{'ready':True},3:{'ready':True},4:{'ready':True},5:{'ready':True}}), ('_recent_cache_stale', False),
|
||||
('active_repair_request_ids', {'5'}), ('get_request_cache_payload', None)]:
|
||||
stack.enter_context(patch.object(requests, name, return_value=value))
|
||||
stack.enter_context(patch.dict(requests._recent_cache, {'items':rows}))
|
||||
stack.enter_context(patch.object(requests, '_request_is_available_in_jellyfin', new=AsyncMock(side_effect=AssertionError('Recent requests must not call Jellyfin'))))
|
||||
user={'role':'user','username':'viewer','jellyseerr_user_id':10}
|
||||
expected={'working':[2,5], 'ready':[1,3], 'partial':[4], 'approved':[6],
|
||||
'pending':[7], 'declined':[8], 'in_progress':[2,4,5,6]}
|
||||
for stage, ids in expected.items():
|
||||
result=await requests.recent_requests(take=20,skip=0,days=0,stage=stage,user=user)
|
||||
self.assertEqual([r['id'] for r in result['results']],ids,stage)
|
||||
result=await requests.recent_requests(take=1,skip=1,days=0,stage='working',user=user)
|
||||
self.assertEqual([r['id'] for r in result['results']],[5])
|
||||
result=await requests.recent_requests(take=1,skip=0,days=0,stage='ready',user=user)
|
||||
self.assertEqual(result['results'][0]['status'],4)
|
||||
result=await requests.recent_requests(take=20,skip=0,days=0,stage='all',user={**user,'jellyseerr_user_id':99})
|
||||
self.assertEqual(result['results'],[])
|
||||
|
||||
async def test_background_refresh_skips_fresh_rows_and_preserves_failures(self):
|
||||
import time
|
||||
rows=[{'request_id':i,'status':5,'title':str(i),'updated_at':'v1'} for i in [1,2,3]]
|
||||
runtime=SimpleNamespace(jellyfin_base_url='http://jellyfin',jellyfin_api_key='test',requests_stage_refresh_minutes=15)
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(requests,'get_runtime_settings',return_value=runtime))
|
||||
stack.enter_context(patch.object(requests,'get_cached_requests_since',return_value=rows))
|
||||
stack.enter_context(patch.object(requests,'get_request_stage_cache',return_value={1:{'source_updated':'v1','checked_at':time.time()}}))
|
||||
stack.enter_context(patch.object(requests,'get_request_cache_payload',return_value={}))
|
||||
check=stack.enter_context(patch.object(requests,'_request_is_available_in_jellyfin',new=AsyncMock(side_effect=[True,RuntimeError('offline')])) )
|
||||
save=stack.enter_context(patch.object(requests,'save_request_stage_cache'))
|
||||
await requests.refresh_local_request_stages()
|
||||
self.assertEqual(check.await_count,2)
|
||||
written=save.call_args.args[0]
|
||||
self.assertEqual(len(written),1)
|
||||
self.assertEqual(written[0][:3],(2,'v1',1))
|
||||
|
||||
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
from backend.app import db
|
||||
|
||||
class StagePersistenceTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_saved_stages_survive_initialization_and_actions_mark_due(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("INSERT INTO requests_cache (request_id, payload_json) VALUES (42, '{}')")
|
||||
db.save_request_stage_cache([(42,'v1',1,12345)])
|
||||
db.init_db()
|
||||
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
|
||||
db.save_action('42','search_releases','Search','ok')
|
||||
self.assertEqual(db.get_request_stage_cache()[42]['checked_at'],0)
|
||||
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
|
||||
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.routers import requests
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
|
||||
class RecheckMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_series_restores_only_requested_seasons_and_episodes(self):
|
||||
original={'id':10,'monitored':False,'qualityProfileId':7,'seasons':[{'seasonNumber':1,'monitored':False},{'seasonNumber':2,'monitored':False}]}
|
||||
restored={**original,'monitored':True,'seasons':[{'seasonNumber':1,'monitored':True},{'seasonNumber':2,'monitored':False}]}
|
||||
client=SimpleNamespace(get_series=AsyncMock(side_effect=[original,restored]),update_series=AsyncMock(),get_episodes=AsyncMock(side_effect=[[{'id':1,'seasonNumber':1,'monitored':False},{'id':2,'seasonNumber':2,'monitored':False}],[{'id':1,'seasonNumber':1,'monitored':True},{'id':2,'seasonNumber':2,'monitored':False}]]),monitor_episodes=AsyncMock())
|
||||
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.tv,raw={'arr':{'item':{'id':10}}})
|
||||
runtime=SimpleNamespace(sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'SonarrClient',return_value=client):
|
||||
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2,'seasons':[{'seasonNumber':1}]}))
|
||||
client.update_series.assert_awaited_once_with(restored)
|
||||
client.monitor_episodes.assert_awaited_once_with([1],True)
|
||||
|
||||
async def test_movie_monitoring_preserves_profile_and_pending_is_noop(self):
|
||||
movie={'id':10,'monitored':False,'qualityProfileId':7}
|
||||
client=SimpleNamespace(get_movie=AsyncMock(side_effect=[movie,{**movie,'monitored':True}]),update_movie=AsyncMock())
|
||||
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.movie,raw={'arr':{'item':{'id':10}}})
|
||||
runtime=SimpleNamespace(radarr_base_url='http://radarr',radarr_api_key='test')
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'RadarrClient',return_value=client):
|
||||
self.assertFalse(await requests._restore_request_monitoring(snapshot,{'status':1}))
|
||||
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2}))
|
||||
client.update_movie.assert_awaited_once_with({**movie,'monitored':True})
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Replacement-cycle regressions. All collectors/downloads are fixtures."""
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
||||
from backend.app.routers import requests as requests_router
|
||||
from backend.app.services import snapshot as service, media_repair
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class RepairPipelineTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cycle = datetime.now(timezone.utc).isoformat()
|
||||
self.item = {"id": 12, "title": "Example", "hasFile": False}
|
||||
self.jf = {"Id": "jf-1", "Name": "Example", "Type": "Movie", "ProviderIds": {"Tmdb": "123"}, "Etag": "old"}
|
||||
self.episodes = [
|
||||
{"id": 109, "seasonNumber": 5, "episodeNumber": 9, "hasFile": False, "episodeFileId": 0},
|
||||
{"id": 110, "seasonNumber": 5, "episodeNumber": 10, "hasFile": True, "episodeFileId": 42},
|
||||
]
|
||||
self.torrents = []
|
||||
self.queue = []
|
||||
self.commands = []
|
||||
self.jf_episodes = [{"Id": "ep9", "ParentIndexNumber": 5, "IndexNumber": 9, "Etag": "old"}]
|
||||
self.media_type = RequestType.movie
|
||||
self.fail_collector = False
|
||||
|
||||
def start(self, media_type=RequestType.movie):
|
||||
self.media_type = media_type
|
||||
if media_type == RequestType.tv:
|
||||
self.jf.update(Type="Series", ProviderIds={"Tvdb": "456"})
|
||||
tracking = {
|
||||
"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media",
|
||||
"collectorId": 12, "mediaType": media_type.value, "originalFileIds": [40],
|
||||
"previousDownloadIds": ["old"],
|
||||
"episodes": [{"id": 109, "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [],
|
||||
"jellyfinFoundAtStart": True,
|
||||
"jellyfinBaseline": [{"Id": "ep9", "Etag": "old", "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [{"Id": "jf-1", "Etag": "old"}],
|
||||
}
|
||||
db.start_request_repair(tracking)
|
||||
return tracking
|
||||
|
||||
async def snapshot(self):
|
||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache", "jellyfin_public_url": "https://media.test"})
|
||||
lookup = AsyncMock(side_effect=RuntimeError("offline")) if self.fail_collector else AsyncMock(return_value=[self.item])
|
||||
collector = SimpleNamespace(
|
||||
get_movie_by_tmdb_id=lookup, get_series_by_tvdb_id=lookup,
|
||||
get_episodes=AsyncMock(return_value=self.episodes), get_queue=AsyncMock(return_value={"records": self.queue}),
|
||||
get=AsyncMock(return_value=self.commands),
|
||||
)
|
||||
jellyfin = SimpleNamespace(configured=lambda: True, search_items=AsyncMock(return_value={"Items": [self.jf]}),
|
||||
get_series_episodes=AsyncMock(return_value=self.jf_episodes))
|
||||
with ExitStack() as stack:
|
||||
mocks = {
|
||||
"get_runtime_settings": runtime,
|
||||
"get_request_cache_payload": {"id": 12, "type": self.media_type.value, "status": 4,
|
||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
||||
"get_request_cache_by_id": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "JellyfinClient": jellyfin,
|
||||
"QBittorrentClient": SimpleNamespace(configured=lambda: True,
|
||||
get_torrents_by_hashes=AsyncMock(return_value=self.torrents), get_torrents_by_tag=AsyncMock(return_value=self.torrents)),
|
||||
"SonarrClient": collector, "RadarrClient": collector,
|
||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
||||
"_latest_repair_action": None,
|
||||
}
|
||||
for name, value in mocks.items():
|
||||
stack.enter_context(patch.object(service, name, return_value=value))
|
||||
stack.enter_context(patch.object(service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
||||
stack.enter_context(patch.object(media_repair, "JellyfinClient", return_value=jellyfin))
|
||||
return await service.build_snapshot("12")
|
||||
|
||||
@staticmethod
|
||||
def stage(snapshot, name):
|
||||
return next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == name)
|
||||
|
||||
async def test_movie_old_catalog_and_completed_torrent_do_not_complete_repair(self):
|
||||
self.start()
|
||||
self.torrents = [{"hash": "old", "progress": 1, "state": "uploading", "added_on": 1, "completion_on": 2}]
|
||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
||||
self.assertEqual(self.stage(snapshot, "available")["state"], "waiting")
|
||||
self.assertEqual(snapshot.presentation["status"]["label"], "Waiting for a replacement")
|
||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
||||
self.assertTrue(snapshot.raw["jellyfin"]["catalogFound"])
|
||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
||||
self.assertIn("search_auto", [a.id for a in snapshot.actions])
|
||||
for name in ["requested", "approved"]:
|
||||
self.assertEqual(self.stage(snapshot, name)["state"], "complete")
|
||||
|
||||
async def test_movie_repair_search_download_import_index_and_complete(self):
|
||||
self.start()
|
||||
self.commands = [{"name": "MoviesSearch", "status": "started", "body": {"movieIds": [12]}}]
|
||||
searching = await self.snapshot()
|
||||
self.assertEqual(searching.presentation["status"]["label"], "Searching for a replacement")
|
||||
self.commands = []
|
||||
self.torrents = [{"hash": "new", "progress": .32, "state": "downloading"}]
|
||||
downloading = await self.snapshot()
|
||||
self.assertEqual(downloading.state, NormalizedState.downloading)
|
||||
self.assertEqual(self.stage(downloading, "download")["torrents"][0]["progressPercent"], 32)
|
||||
self.assertNotIn("resume_torrent", [a.id for a in downloading.actions])
|
||||
self.item.update(hasFile=True, movieFile={"id": 41})
|
||||
imported = await self.snapshot()
|
||||
self.assertEqual(self.stage(imported, "available")["stateLabel"], "Indexing")
|
||||
self.assertEqual(self.stage(imported, "download")["state"], "complete")
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
self.jf["Etag"] = "new"
|
||||
self.torrents = []
|
||||
completed = await self.snapshot()
|
||||
self.assertEqual(completed.state, NormalizedState.completed)
|
||||
self.assertEqual(db.get_request_repairs("12"), [])
|
||||
self.assertEqual(completed.presentation["status"]["label"], "Available to watch")
|
||||
|
||||
async def test_old_queue_record_without_torrent_is_not_new_download_attempt(self):
|
||||
self.start()
|
||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
||||
|
||||
async def test_same_original_file_cannot_confirm_replacement(self):
|
||||
self.start()
|
||||
self.item.update(hasFile=True, movieFile={"id": 40})
|
||||
self.jf["Etag"] = "new"
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
||||
|
||||
async def test_tv_preserves_unaffected_episodes_and_verifies_exact_replacement(self):
|
||||
self.start(RequestType.tv)
|
||||
self.item["statistics"] = {"episodeFileCount": 2, "totalEpisodeCount": 2} # stale summary
|
||||
pending = await self.snapshot()
|
||||
self.assertEqual(self.stage(pending, "library")["missing"], 1)
|
||||
self.assertEqual(self.stage(pending, "available")["state"], "partial")
|
||||
self.assertEqual(self.stage(pending, "download")["stateLabel"], "Pending")
|
||||
self.episodes[0].update(hasFile=True, episodeFileId=43)
|
||||
imported = await self.snapshot()
|
||||
self.assertEqual(imported.state, NormalizedState.importing)
|
||||
self.assertEqual(self.stage(imported, "available")["state"], "partial")
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
self.jf_episodes[0]["Etag"] = "new"
|
||||
completed = await self.snapshot()
|
||||
self.assertEqual(completed.state, NormalizedState.completed)
|
||||
self.assertEqual(db.get_request_repairs("12"), [])
|
||||
|
||||
async def test_collector_outage_does_not_restore_old_availability(self):
|
||||
self.start()
|
||||
self.fail_collector = True
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual(snapshot.presentation["status"]["label"], "Repair status temporarily unavailable")
|
||||
|
||||
async def test_external_movie_removal_reconciles_old_jellyfin_entry(self):
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
||||
|
||||
async def test_history_from_previous_cycle_and_late_old_poll_are_ignored(self):
|
||||
old = Snapshot(request_id="12", title="Example", state=NormalizedState.completed,
|
||||
timeline=[{"service": "qBittorrent", "status": "completed", "details": {"torrents": [{"hash": "old"}]}}])
|
||||
db.save_snapshot(old)
|
||||
self.start()
|
||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
||||
old.state_reason = "A pre-repair poll returned late"
|
||||
db.save_snapshot(old)
|
||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
||||
self.torrents = [{"hash": "new", "progress": .5, "state": "downloading"}]
|
||||
await self.snapshot()
|
||||
self.assertTrue(db.get_request_download_evidence("12")["observed"])
|
||||
|
||||
def test_same_hash_redownload_and_new_completed_job_are_kept(self):
|
||||
old = {"hash": "same", "progress": 1, "added_on": 1, "completion_on": 2}
|
||||
retry = {**old, "progress": .3}
|
||||
fresh = {**old, "completion_on": datetime.now(timezone.utc).timestamp() + 1}
|
||||
self.assertEqual(media_repair.current_cycle_torrents([old, retry, fresh], self.cycle), [retry, fresh])
|
||||
|
||||
def test_repair_cycle_survives_restart_and_list_does_not_say_ready(self):
|
||||
self.start()
|
||||
db.init_db()
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
with patch.dict(requests_router._recent_cache, {"items": [{"request_id": 12, "status": 4, "requested_by_id": 7}]}):
|
||||
self.assertEqual(requests_router._get_recent_from_cache(None, 7, 10, 0, None, [4]), [])
|
||||
rows = requests_router._get_recent_from_cache(None, 7, 10, 0, None, [5])
|
||||
self.assertEqual(rows[0]["status"], 5)
|
||||
|
||||
async def test_live_poll_ignores_old_completed_download(self):
|
||||
self.start()
|
||||
runtime = settings.model_copy(update={"jellyseerr_base_url": None, "jellyseerr_api_key": None})
|
||||
qbit = SimpleNamespace(configured=lambda: True, get_torrents_by_tag=AsyncMock(return_value=[{"progress": 1, "hash": "old", "state": "uploading"}]))
|
||||
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "QBittorrentClient", return_value=qbit):
|
||||
progress = await requests_router.get_download_progress("12", {"role": "user"})
|
||||
self.assertEqual(progress["state"], "not_started")
|
||||
self.assertFalse(progress["visible"])
|
||||
self.assertEqual(progress["repairCycle"], self.cycle)
|
||||
|
||||
async def test_failed_search_after_deletion_keeps_cycle_and_pending_pipeline(self):
|
||||
before = Snapshot(request_id="12", title="Example", request_type=RequestType.movie,
|
||||
raw={"arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 40}}},
|
||||
"jellyfin": {"found": True, "item": self.jf}})
|
||||
async def delete(_):
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1, "Must persist before removal")
|
||||
radarr = SimpleNamespace(configured=lambda: True, monitor_movie=AsyncMock(),
|
||||
delete_movie_file=AsyncMock(side_effect=delete), search=AsyncMock(side_effect=RuntimeError("search failed")))
|
||||
with ExitStack() as stack:
|
||||
for name, value in {"_user_can_use_search_auto": True, "_linked_issue_for_replacement": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "RadarrClient": radarr}.items():
|
||||
stack.enter_context(patch.object(requests_router, name, return_value=value))
|
||||
stack.enter_context(patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=before)))
|
||||
with self.assertRaises(requests_router.HTTPException):
|
||||
await requests_router.action_replace_media("12", {"file_ids": [40], "confirmed": True}, {"role": "admin"})
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
||||
|
||||
def test_existing_issue_tracking_is_migrated_once_and_survives_ticket_deletion(self):
|
||||
tracking = {"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media", "collectorId": 12,
|
||||
"mediaType": "movie", "originalFileIds": [40]}
|
||||
issue = db.create_portal_item(kind="issue", title="Repair", description="Replace movie", status="in_progress",
|
||||
created_by_username="reporter", created_by_id=None, issue_type="broken_media")
|
||||
db.add_portal_item_activity(issue["id"], event_type="replacement_started", actor_username="reporter",
|
||||
actor_role="user", message="Repair requested", metadata_json=json.dumps({"repairTracking": tracking}))
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
db.delete_portal_item(issue["id"])
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
|
||||
async def test_multiple_repairs_wait_for_every_target_not_just_latest(self):
|
||||
first = self.start(RequestType.tv)
|
||||
second = {**first, "startedAt": (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat(),
|
||||
"episodes": [{"id": 110, "seasonNumber": 5, "episodeNumber": 10}], "originalFileIds": [42],
|
||||
"jellyfinFoundAtStart": False, "jellyfinBaseline": []}
|
||||
db.start_request_repair(second)
|
||||
self.episodes[1].update(episodeFileId=43)
|
||||
self.jf_episodes.append({"Id": "ep10", "ParentIndexNumber": 5, "IndexNumber": 10})
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual([r["originalFileIds"] for r in db.get_request_repairs("12")], [[40]])
|
||||
@@ -0,0 +1,34 @@
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.tests.test_insights import play, NOW, LIBRARIES
|
||||
from backend.tests.test_email_recaps import fixture_report
|
||||
from backend.app.services.insights import summarize
|
||||
from backend.app.services.email_recaps import illustrated_recap
|
||||
|
||||
class ReportGraphicsTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_patterns_deduplicate_and_handle_empty_history(self):
|
||||
first = play()
|
||||
second = play("second", PlaybackDuration=1800, EpisodeId="episode", ActivityDateInserted=(NOW-timedelta(days=1)).isoformat())
|
||||
report = summarize([first, first, second], LIBRARIES, NOW-timedelta(days=7), NOW)
|
||||
self.assertEqual(report["patterns"]["average_play_minutes"], 45)
|
||||
self.assertEqual(report["patterns"]["longest_play_minutes"], 60)
|
||||
self.assertEqual(report["patterns"]["weekend_percent"], 33.3)
|
||||
self.assertEqual(sum(r["minutes"] for r in report["patterns"]["media"]), 90)
|
||||
empty = summarize([], [], NOW-timedelta(days=7), NOW)
|
||||
self.assertEqual(empty["patterns"]["average_play_minutes"], 0)
|
||||
|
||||
async def test_artwork_embedded_without_private_links_and_optional_on_failure(self):
|
||||
report = fixture_report()
|
||||
report["top_titles"][0]["artwork_url"] = "/insights/artwork/" + "a"*32 + "?token=123." + "b"*64
|
||||
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(return_value=(b"picture", "image/webp"))):
|
||||
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||
self.assertIn("cid:recap-title-0@magent", rendered["body_html"])
|
||||
self.assertNotIn("?token=", rendered["body_html"])
|
||||
self.assertEqual(rendered["inline_images"][0]["subtype"], "webp")
|
||||
preview = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe", preview=True)
|
||||
self.assertIn("data:image/webp;base64,", preview["body_html"])
|
||||
self.assertNotIn("inline_images", preview)
|
||||
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(side_effect=RuntimeError())):
|
||||
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||
self.assertEqual(rendered["inline_images"], [])
|
||||
@@ -0,0 +1,136 @@
|
||||
import copy
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from backend.app.services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome
|
||||
from backend.app.routers import requests
|
||||
|
||||
|
||||
class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_metadata_is_not_audio_evidence(self):
|
||||
for code in ('en', '', 'xx', 'invalid'):
|
||||
self.assertIsNone(language_info({'originalLanguage': code}))
|
||||
self.assertEqual(language_info({'original_language': 'es'}), {'code': 'es'})
|
||||
|
||||
async def test_copy_preserves_quality_and_reuses_verified_profile(self):
|
||||
default = {'id': 6, 'name': 'HD', 'language': {'id': 1, 'name': 'English'},
|
||||
'items': [{'quality': {'id': 7}, 'allowed': True}], 'minFormatScore': 50,
|
||||
'formatItems': [{'format': 10, 'score': -1000}], 'upgradeAllowed': True}
|
||||
original = copy.deepcopy(default)
|
||||
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[default]), post=AsyncMock(return_value={'id': 20}))
|
||||
self.assertEqual(await original_profile(client, 6), 20)
|
||||
payload = client.post.await_args.kwargs['payload']
|
||||
self.assertEqual(payload['language']['id'], -2)
|
||||
self.assertEqual(payload['items'], default['items'])
|
||||
self.assertEqual(payload['formatItems'], default['formatItems'])
|
||||
self.assertEqual(payload['minFormatScore'], 50)
|
||||
self.assertEqual(default, original)
|
||||
self.assertTrue(is_original_profile(payload))
|
||||
client.get_quality_profiles.return_value.append({**payload, 'id': 20})
|
||||
self.assertEqual(await original_profile(client, 6), 20)
|
||||
self.assertEqual(client.post.await_count, 1)
|
||||
payload['minFormatScore'] = 0
|
||||
self.assertFalse(is_original_profile(payload))
|
||||
|
||||
async def test_missing_default_never_creates_profile(self):
|
||||
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[]), post=AsyncMock())
|
||||
with self.assertRaises(HTTPException):
|
||||
await original_profile(client, 6)
|
||||
client.post.assert_not_awaited()
|
||||
|
||||
async def test_only_explicit_verified_movie_consent_changes_destination(self):
|
||||
runtime = SimpleNamespace(jellyseerr_base_url='http://seerr', jellyseerr_api_key='key',
|
||||
radarr_base_url='http://radarr', radarr_api_key='key')
|
||||
seerr = SimpleNamespace(configured=lambda: True, get_movie=AsyncMock(), get_tv=AsyncMock(),
|
||||
create_request=AsyncMock(return_value={'status': 1}))
|
||||
for code, consent, media_type, expected in [('es', True, 'movie', 20), ('es', False, 'movie', 6),
|
||||
('en', True, 'movie', None), ('es', 'yes', 'movie', None),
|
||||
('ja', True, 'tv', 6)]:
|
||||
details = {'title': 'Title', 'originalLanguage': code, 'seasons': [{'seasonNumber': 1}]}
|
||||
seerr.get_movie.return_value = seerr.get_tv.return_value = details
|
||||
seerr.create_request.reset_mock()
|
||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
|
||||
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
|
||||
patch.object(requests, 'apply_original_to_movie', new=AsyncMock(return_value=None)), \
|
||||
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
|
||||
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
|
||||
if expected is None:
|
||||
with self.assertRaises(HTTPException):
|
||||
await requests.create_request(payload, {'username': 'viewer'})
|
||||
seerr.create_request.assert_not_awaited()
|
||||
clone.assert_not_awaited()
|
||||
else:
|
||||
await requests.create_request(payload, {'username': 'viewer'})
|
||||
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
|
||||
self.assertEqual(clone.await_count, int(expected == 20))
|
||||
|
||||
|
||||
async def test_existing_radarr_movie_is_updated_and_read_back(self):
|
||||
movie = {'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9, 'monitored': True}
|
||||
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[movie]),
|
||||
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={**movie, 'qualityProfileId': 20}))
|
||||
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||
self.assertEqual(await apply_original_to_movie(client, 613), 20)
|
||||
self.assertEqual(client.update_movie.await_args.args[0]['qualityProfileId'], 20)
|
||||
self.assertTrue(client.update_movie.await_args.args[0]['monitored'])
|
||||
|
||||
async def test_failed_profile_verification_does_not_claim_success(self):
|
||||
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[{'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9}]),
|
||||
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={'qualityProfileId': 9}))
|
||||
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||
with self.assertRaises(HTTPException):
|
||||
await apply_original_to_movie(client, 613)
|
||||
|
||||
async def test_search_reports_real_outcomes(self):
|
||||
for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
|
||||
('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
|
||||
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
|
||||
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False}))
|
||||
result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
|
||||
self.assertEqual(result['status'], expected)
|
||||
|
||||
async def test_language_endpoint_checks_consent_identity_and_access(self):
|
||||
for payload in ({}, {'acceptOriginalLanguage': 'true'}, {'acceptOriginalLanguage': True, 'languageCode': 'es'}):
|
||||
with patch.object(requests, '_request_language_context', new=AsyncMock(return_value=(SimpleNamespace(), 613, {'code': 'de'}))), \
|
||||
patch.object(requests, 'apply_original_to_movie', new=AsyncMock()) as apply:
|
||||
with self.assertRaises(HTTPException):
|
||||
await requests.accept_request_language('3976', payload, {'role': 'admin'})
|
||||
apply.assert_not_awaited()
|
||||
with self.assertRaises(HTTPException):
|
||||
await requests.accept_request_language('3976', {'acceptOriginalLanguage': True}, {'role': 'user', 'auto_search_enabled': False})
|
||||
|
||||
|
||||
async def test_radarr_queue_filters_before_pagination(self):
|
||||
from backend.app.clients.radarr import RadarrClient
|
||||
client = RadarrClient('http://radarr.test', 'test')
|
||||
with patch.object(client, 'get', new=AsyncMock(return_value={'records': []})) as get:
|
||||
await client.get_queue(6940)
|
||||
get.assert_awaited_once_with('/api/v3/queue', params={'movieIds': 6940, 'pageSize': 1000})
|
||||
|
||||
|
||||
async def test_tv_search_distinguishes_no_download_and_queue(self):
|
||||
from backend.app.services.request_language import series_search_outcome
|
||||
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []}))
|
||||
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending')
|
||||
client.get_queue.return_value = {'records': [{'seriesId': 50}]}
|
||||
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
|
||||
|
||||
|
||||
class SearchHandoffTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_radarr_completed_before_queue_refresh(self):
|
||||
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
|
||||
get_queue=AsyncMock(side_effect=[{'records':[]}, {'records':[]}, {'records':[{'movieId':2206}]}]),
|
||||
get_movie=AsyncMock(return_value={'hasFile':False}))
|
||||
result = await movie_search_outcome(client, 2206, {'id':1}, attempts=3, delay=0)
|
||||
self.assertEqual(result['status'], 'downloading')
|
||||
self.assertEqual(client.get_queue.await_count, 3)
|
||||
|
||||
async def test_sonarr_completed_before_queue_refresh(self):
|
||||
from backend.app.services.request_language import series_search_outcome
|
||||
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
|
||||
get_queue=AsyncMock(side_effect=[{'records':[{'seriesId':999}]}, {'records':[{'seriesId':50}]}]))
|
||||
result = await series_search_outcome(client, 50, [{'id':1}], attempts=2, delay=0)
|
||||
self.assertEqual(result['status'], 'downloading')
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Origin checks use operator configuration, never caller-controlled routing headers."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.services import public_urls
|
||||
from backend.app.services.request_origins import is_allowed_request_origin
|
||||
|
||||
|
||||
PUBLIC_ORIGIN = "https://watch.example.test"
|
||||
LOCAL_ORIGIN = "http://localhost:3000"
|
||||
|
||||
|
||||
class RequestOriginTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runtime = SimpleNamespace(
|
||||
magent_proxy_enabled=False,
|
||||
magent_proxy_base_url=None,
|
||||
magent_application_url=PUBLIC_ORIGIN,
|
||||
)
|
||||
self.enterContext(patch.object(settings, "cors_allow_origin", LOCAL_ORIGIN))
|
||||
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
|
||||
|
||||
def test_explicit_cors_and_configured_public_url_are_both_allowed(self):
|
||||
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin("https://unrelated.example.test"))
|
||||
|
||||
def test_scheme_hostname_case_and_default_ports_are_canonicalized(self):
|
||||
for origin in (PUBLIC_ORIGIN, "HTTPS://WATCH.EXAMPLE.TEST", "https://watch.example.test:443"):
|
||||
with self.subTest(origin=origin):
|
||||
self.assertTrue(is_allowed_request_origin(origin))
|
||||
self.runtime.magent_application_url = "http://watch.example.test:80"
|
||||
self.assertTrue(is_allowed_request_origin("http://WATCH.example.test"))
|
||||
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
|
||||
self.assertFalse(is_allowed_request_origin("http://watch.example.test:8080"))
|
||||
|
||||
def test_nondefault_ports_must_match(self):
|
||||
self.runtime.magent_application_url = "https://watch.example.test:8443/magent"
|
||||
self.assertTrue(is_allowed_request_origin("https://watch.example.test:8443"))
|
||||
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
|
||||
self.assertFalse(is_allowed_request_origin("https://watch.example.test:443"))
|
||||
|
||||
def test_configured_subpath_does_not_become_part_of_origin(self):
|
||||
self.runtime.magent_application_url = PUBLIC_ORIGIN + "/magent/"
|
||||
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN + "/magent"))
|
||||
|
||||
def test_enabled_proxy_uses_configured_proxy_public_url(self):
|
||||
self.runtime.magent_proxy_enabled = True
|
||||
self.runtime.magent_proxy_base_url = "https://proxy.example.test/magent"
|
||||
self.assertTrue(is_allowed_request_origin("https://proxy.example.test"))
|
||||
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
|
||||
def test_unconfigured_public_url_only_allows_explicit_cors(self):
|
||||
self.runtime.magent_application_url = None
|
||||
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
|
||||
def test_invalid_or_non_origin_inputs_are_rejected(self):
|
||||
for origin in (
|
||||
"", "null", "*", "watch.example.test", "//watch.example.test",
|
||||
"ftp://watch.example.test", "javascript:alert(1)",
|
||||
PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path",
|
||||
PUBLIC_ORIGIN + "?query=true", PUBLIC_ORIGIN + "#fragment",
|
||||
PUBLIC_ORIGIN + "?", PUBLIC_ORIGIN + "#",
|
||||
"https://user@watch.example.test", "https://user:password@watch.example.test",
|
||||
"https://watch.example.test@evil.example.test", "https://watch.example.test.evil.example.test",
|
||||
"https://watch.example.test:0", "https://watch.example.test:65536",
|
||||
"https://watch.example.test:invalid", "https://[invalid",
|
||||
PUBLIC_ORIGIN + " https://evil.example.test", PUBLIC_ORIGIN + ",https://evil.example.test",
|
||||
"https://watch.example.test\\@evil.example.test", PUBLIC_ORIGIN + "\n",
|
||||
):
|
||||
with self.subTest(origin=repr(origin)):
|
||||
self.assertFalse(is_allowed_request_origin(origin))
|
||||
|
||||
def test_invalid_configured_public_url_does_not_authorize_an_origin(self):
|
||||
for configured in (
|
||||
"https://user:password@watch.example.test", PUBLIC_ORIGIN + "?token=private",
|
||||
PUBLIC_ORIGIN + "#fragment", "javascript:alert(1)", "https://watch.example.test:65536",
|
||||
):
|
||||
with self.subTest(configured=configured):
|
||||
self.runtime.magent_application_url = configured
|
||||
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||
|
||||
|
||||
class RequestOriginHttpTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.runtime = SimpleNamespace(
|
||||
magent_proxy_enabled=False,
|
||||
magent_proxy_base_url=None,
|
||||
magent_application_url=PUBLIC_ORIGIN,
|
||||
)
|
||||
for name, value in {
|
||||
"sqlite_path": str(Path(temporary.name) / "origin-tests.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"jwt_secret": "request-origin-tests-jwt-secret-at-least-32-characters",
|
||||
"settings_encryption_key": None,
|
||||
"admin_username": "unused-environment-admin",
|
||||
"admin_password": "",
|
||||
"cors_allow_origin": LOCAL_ORIGIN,
|
||||
"auth_cookie_domain": None,
|
||||
"auth_cookie_secure": True,
|
||||
}.items():
|
||||
self.enterContext(patch.object(settings, name, value))
|
||||
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
|
||||
# Constructing without a context deliberately skips production startup:
|
||||
# no migrations/workers/listeners/log files outside this temporary DB.
|
||||
db.init_db()
|
||||
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def test_public_origin_reaches_both_auth_handlers_with_localhost_cors_default(self):
|
||||
for path in ("/auth/login", "/auth/jellyfin/login"):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.post(path, data={}, headers={"Origin": PUBLIC_ORIGIN})
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertNotEqual(response.json().get("detail"), "Cross-origin state change rejected")
|
||||
|
||||
def test_explicit_cors_origin_remains_allowed(self):
|
||||
response = self.client.post("/auth/login", data={}, headers={"Origin": LOCAL_ORIGIN})
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
|
||||
def test_no_origin_keeps_existing_nonbrowser_behavior(self):
|
||||
response = self.client.post("/auth/login", data={})
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
|
||||
def test_caller_controlled_host_forwarding_and_fetch_headers_cannot_authorize_evil_origin(self):
|
||||
for path in ("/auth/login", "/auth/jellyfin/login"):
|
||||
for routing_headers in (
|
||||
{},
|
||||
{"Host": "evil.example.test"},
|
||||
{"X-Forwarded-Host": "evil.example.test", "X-Forwarded-Proto": "https"},
|
||||
{"Host": "evil.example.test", "X-Forwarded-Host": "evil.example.test", "Sec-Fetch-Site": "same-origin"},
|
||||
{"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test", "Sec-Fetch-Site": "same-origin"},
|
||||
):
|
||||
with self.subTest(path=path, routing_headers=routing_headers):
|
||||
response = self.client.post(path, data={}, headers={"Origin": "https://evil.example.test", **routing_headers})
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
|
||||
|
||||
def test_null_path_query_and_userinfo_origins_are_rejected_before_login(self):
|
||||
for origin in ("null", PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?query=1", "https://user@watch.example.test"):
|
||||
with self.subTest(origin=origin):
|
||||
response = self.client.post("/auth/login", data={}, headers={"Origin": origin})
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
|
||||
def test_valid_local_login_works_from_configured_public_origin(self):
|
||||
password = "origin-tests-valid-local-password"
|
||||
db.create_user("origin-owner", password, role="admin")
|
||||
response = self.client.post("/auth/login", data={"username": "origin-owner", "password": password}, headers={"Origin": PUBLIC_ORIGIN})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||
profile = self.client.get("/auth/profile")
|
||||
self.assertEqual(profile.status_code, 200, profile.text)
|
||||
self.assertEqual(profile.json()["user"]["username"], "origin-owner")
|
||||
|
||||
def test_valid_mocked_jellyfin_login_works_from_configured_public_origin(self):
|
||||
jellyfin_runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin.test:8096", jellyfin_api_key="test-api-key")
|
||||
upstream = SimpleNamespace(
|
||||
configured=lambda: True,
|
||||
authenticate_by_name=AsyncMock(return_value={"User": {"Id": "test-jellyfin-id", "Name": "origin-viewer"}}),
|
||||
get_users=AsyncMock(return_value=[]),
|
||||
_extract_user_id=lambda _response: "test-jellyfin-id",
|
||||
)
|
||||
with patch.object(auth_router, "get_runtime_settings", return_value=jellyfin_runtime), patch.object(auth_router, "JellyfinClient", return_value=upstream), patch.object(auth_router, "get_cached_jellyseerr_users", return_value=[]):
|
||||
response = self.client.post("/auth/jellyfin/login", data={"username": "origin-viewer", "password": "origin-tests-jellyfin-password"}, headers={"Origin": PUBLIC_ORIGIN})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
upstream.authenticate_by_name.assert_awaited_once_with("origin-viewer", "origin-tests-jellyfin-password")
|
||||
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||
self.assertEqual(db.get_user_by_username("origin-viewer")["auth_provider"], "jellyfin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from backend.app.schema_migrations import run_schema_migrations
|
||||
|
||||
|
||||
class SchemaMigrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.execute(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)"
|
||||
)
|
||||
self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)")
|
||||
self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def test_migrations_are_versioned_and_idempotent(self) -> None:
|
||||
self.assertEqual(run_schema_migrations(self.conn), [1])
|
||||
self.assertEqual(run_schema_migrations(self.conn), [])
|
||||
|
||||
user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")}
|
||||
self.assertIn("auth_version", user_columns)
|
||||
self.assertIn("email", user_columns)
|
||||
request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")}
|
||||
self.assertIn("requested_by_id", request_columns)
|
||||
applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall()
|
||||
self.assertEqual(applied, [(1, "legacy_columns_and_indexes")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,17 @@
|
||||
import unittest
|
||||
from backend.app import db
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class SeerrUserVisibilityTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_seerr_only_users_are_visible(self):
|
||||
db.create_user('local-admin', 'test-password', role='admin')
|
||||
db.create_user('imported-member', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
|
||||
self.assertEqual({u['username'] for u in db.get_all_users()}, {'local-admin', 'imported-member'})
|
||||
|
||||
def test_linked_duplicate_prefers_jellyfin(self):
|
||||
db.create_user('member@example.com', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
|
||||
db.create_user('member', 'jellyfin-user', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
users = db.get_all_users()
|
||||
self.assertEqual(len(users), 1)
|
||||
self.assertEqual(users[0]['username'], 'member')
|
||||
@@ -0,0 +1,267 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import setup as setup_router
|
||||
from backend.app.security import create_access_token
|
||||
from backend.app.services import setup
|
||||
|
||||
|
||||
SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890"
|
||||
ADMIN_PASSWORD = "A-long-admin-password!123"
|
||||
|
||||
|
||||
class SetupTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
for field, value in {
|
||||
"sqlite_path": os.path.join(self.temp.name, "test.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"admin_username": "environment-admin",
|
||||
"admin_password": "",
|
||||
"jwt_secret": "setup-test-jwt-secret-only-1234567890",
|
||||
"settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=",
|
||||
}.items():
|
||||
context = patch.object(settings, field, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN))
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(setup_router.router)
|
||||
self.client = TestClient(self.app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def bootstrap(self, **changes):
|
||||
return self.client.post("/setup/bootstrap", json={
|
||||
"setup_token": SETUP_TOKEN,
|
||||
"username": "first-admin",
|
||||
"password": ADMIN_PASSWORD,
|
||||
**changes,
|
||||
})
|
||||
|
||||
def admin_headers(self):
|
||||
return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"}
|
||||
|
||||
def test_fresh_install_requires_setup_and_exposes_no_configuration(self):
|
||||
response = self.client.get("/setup/status")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True})
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||
|
||||
def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("DROP TABLE installation_setup")
|
||||
setup.initialize_setup_state()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False})
|
||||
self.assertIsNotNone(setup.get_setup_state()["completed_at"])
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_missing_marker_fails_closed(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("DROP TABLE installation_setup")
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_marker_survives_restart_before_schema_initialization(self):
|
||||
new_path = os.path.join(self.temp.name, "interrupted.db")
|
||||
with patch.object(settings, "sqlite_path", new_path):
|
||||
setup.initialize_setup_state()
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_empty_precreated_database_is_a_fresh_install(self):
|
||||
new_path = os.path.join(self.temp.name, "empty.db")
|
||||
with open(new_path, "wb"):
|
||||
pass
|
||||
with patch.object(settings, "sqlite_path", new_path):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_environment_admin_uses_wizard_without_public_bootstrap(self):
|
||||
with patch.object(settings, "admin_password", ADMIN_PASSWORD):
|
||||
db.ensure_admin_user()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False})
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_valid_token_creates_local_admin_once_and_uses_password_hash(self):
|
||||
response = self.bootstrap()
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.assertEqual(response.json(), {"status": "created", "username": "first-admin"})
|
||||
user = db.verify_user_password("first-admin", ADMIN_PASSWORD)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertEqual(user["role"], "admin")
|
||||
self.assertEqual(user["auth_provider"], "local")
|
||||
self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD)
|
||||
self.assertEqual(setup.get_setup_state()["step"], "apps")
|
||||
self.assertEqual(self.bootstrap(username="second-admin").status_code, 409)
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_invalid_and_missing_operator_tokens_never_create_admin(self):
|
||||
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||
with patch.object(setup.settings, "setup_token", ""):
|
||||
self.assertEqual(self.bootstrap().status_code, 403)
|
||||
with patch.object(setup.settings, "setup_token", "too-short"):
|
||||
self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_non_ascii_token_fails_cleanly(self):
|
||||
self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_example_and_repeated_character_setup_tokens_are_rejected(self):
|
||||
for token in (
|
||||
"replace-with-a-separate-random-setup-token",
|
||||
"CHANGE_ME_before_starting_this_installation",
|
||||
"your-setup-token-goes-here-at-least-32-characters",
|
||||
"a" * 64,
|
||||
"0" * 64,
|
||||
" " * 64,
|
||||
):
|
||||
with self.subTest(token=token), patch.object(setup.settings, "setup_token", token):
|
||||
self.assertFalse(setup.setup_token_configured())
|
||||
with self.assertRaises(setup.InvalidSetupTokenError):
|
||||
setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.assertTrue(setup.setup_token_configured())
|
||||
|
||||
def test_password_policy_and_username_validation(self):
|
||||
for username in (" ", "admin user", "admin\x7f", "admin\nname"):
|
||||
with self.subTest(username=repr(username)):
|
||||
self.assertEqual(self.bootstrap(username=username).status_code, 400)
|
||||
self.assertEqual(self.bootstrap(password="short").status_code, 400)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_oversized_fields_and_unexpected_privileges_are_rejected(self):
|
||||
self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422)
|
||||
self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422)
|
||||
self.assertEqual(self.bootstrap(role="admin").status_code, 422)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_existing_normalized_username_is_not_replaced(self):
|
||||
db.create_user("Taken", ADMIN_PASSWORD)
|
||||
self.assertEqual(self.bootstrap(username="taken").status_code, 409)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_bootstrap_attempts_are_persistently_limited(self):
|
||||
for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS):
|
||||
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||
setup.initialize_setup_state()
|
||||
response = self.bootstrap()
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertGreater(int(response.headers["retry-after"]), 0)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
with db._connect() as conn:
|
||||
keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")]
|
||||
self.assertNotIn("testclient", keys)
|
||||
|
||||
def test_rate_limit_global_cap_and_expiry(self):
|
||||
with patch.object(setup, "time", return_value=1000):
|
||||
for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS):
|
||||
self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}"))
|
||||
self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900)
|
||||
with patch.object(setup, "time", return_value=1901):
|
||||
self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1"))
|
||||
|
||||
def test_concurrent_attempts_cannot_bypass_rate_limit(self):
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12)))
|
||||
self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS)
|
||||
|
||||
def test_concurrent_bootstraps_create_only_one_admin(self):
|
||||
barrier = Barrier(4)
|
||||
|
||||
def synchronized_hash(_):
|
||||
barrier.wait(timeout=10)
|
||||
return "test-only-precomputed-hash"
|
||||
|
||||
def create(number):
|
||||
try:
|
||||
setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD)
|
||||
return True
|
||||
except setup.SetupUnavailableError:
|
||||
return False
|
||||
|
||||
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
results = list(executor.map(create, range(4)))
|
||||
self.assertEqual(results.count(True), 1)
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_state_mutations_require_admin_and_progress_resumes(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
db.create_user("viewer", ADMIN_PASSWORD)
|
||||
user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"}
|
||||
for path, method, kwargs in (
|
||||
("/setup/state", "get", {}),
|
||||
("/setup/state", "put", {"json": {"step": "review"}}),
|
||||
("/setup/complete", "post", {}),
|
||||
):
|
||||
with self.subTest(path=path, method=method):
|
||||
call = getattr(self.client, method)
|
||||
self.assertEqual(call(path, **kwargs).status_code, 401)
|
||||
self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403)
|
||||
response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertEqual(setup.get_setup_state()["step"], "preferences")
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
self.assertEqual(self.client.put(
|
||||
"/setup/state", json={"step": "invalid"}, headers=self.admin_headers()
|
||||
).status_code, 422)
|
||||
|
||||
def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
callback = AsyncMock()
|
||||
self.app.state.on_setup_complete = callback
|
||||
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertTrue(response.json()["completed"])
|
||||
self.assertIsNotNone(response.json()["completed_at"])
|
||||
callback.assert_awaited_once()
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
# A retry can restart an idempotent callback if the first response was
|
||||
# interrupted, while keeping the original completion timestamp.
|
||||
retry = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(retry.json(), response.json())
|
||||
self.assertEqual(callback.await_count, 2)
|
||||
self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers())
|
||||
with db._connect() as conn:
|
||||
conn.execute("DELETE FROM users")
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
self.assertEqual(setup.get_setup_state()["step"], "review")
|
||||
|
||||
def test_completion_requires_an_administrator(self):
|
||||
with self.assertRaises(setup.SetupUnavailableError):
|
||||
setup.complete_setup()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_sync_callback_is_supported(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
called = []
|
||||
self.app.state.on_setup_complete = lambda: called.append(True)
|
||||
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(called, [True])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from types import SimpleNamespace
|
||||
from backend.app.clients.sonarr import SonarrClient
|
||||
from backend.app.services.download_labels import label_episode_downloads
|
||||
from backend.app.routers import requests
|
||||
|
||||
|
||||
class TvDownloadTrackingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_queue_paginates_with_correct_filter(self):
|
||||
client = SonarrClient('http://sonarr.test', 'test')
|
||||
with patch.object(client, 'get', new=AsyncMock(side_effect=[
|
||||
{'records': [{'id': 1}], 'totalRecords': 2},
|
||||
{'records': [{'id': 2}], 'totalRecords': 2},
|
||||
])) as get:
|
||||
result = await client.get_queue(42)
|
||||
self.assertEqual(len(result['records']), 2)
|
||||
self.assertEqual(get.call_args_list[0].kwargs['params']['seriesIds'], 42)
|
||||
self.assertEqual(get.call_args_list[1].kwargs['params']['page'], 2)
|
||||
self.assertEqual(get.call_args.kwargs['params']['includeEpisode'], 'true')
|
||||
|
||||
async def test_live_poll_discovers_two_unseen_episode_downloads(self):
|
||||
runtime = SimpleNamespace(jellyseerr_base_url=None, jellyseerr_api_key=None,
|
||||
sonarr_base_url='http://sonarr.test', sonarr_api_key='test',
|
||||
qbittorrent_base_url='http://qbit.test', qbittorrent_username='test', qbittorrent_password='test')
|
||||
queue = {'records': [
|
||||
{'seriesId': 42, 'downloadId': 'ABC', 'episode': {'seasonNumber': 5, 'episodeNumber': 9}},
|
||||
{'seriesId': 42, 'downloadId': 'DEF', 'episode': {'seasonNumber': 5, 'episodeNumber': 10}},
|
||||
{'seriesId': 99, 'downloadId': 'OTHER'},
|
||||
]}
|
||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(requests, 'get_request_repairs', return_value=[]), \
|
||||
patch.object(requests, 'get_request_download_evidence', return_value={'observed': True, 'torrents': []}), \
|
||||
patch.object(requests, 'get_request_cache_payload', return_value={'type': 'tv', 'media': {'tvdbId': 123}}), \
|
||||
patch.object(requests.SonarrClient, 'get_series_by_tvdb_id', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||
patch.object(requests.SonarrClient, 'get_queue', new=AsyncMock(return_value=queue)), \
|
||||
patch.object(requests.QBittorrentClient, 'get_torrents_by_hashes', new=AsyncMock(return_value=[
|
||||
{'hash': 'abc', 'progress': .25, 'state': 'downloading'},
|
||||
{'hash': 'def', 'progress': .5, 'state': 'downloading'},
|
||||
])) as torrents:
|
||||
result = await requests.get_download_progress('12', {'username': 'viewer', 'role': 'user'})
|
||||
torrents.assert_awaited_once_with('abc|def')
|
||||
self.assertEqual(result['state'], 'downloading')
|
||||
self.assertEqual(result['torrents'][0]['episodeLabel'], 'S05E09')
|
||||
self.assertEqual(result['torrents'][1]['progressPercent'], 50)
|
||||
|
||||
def test_pack_does_not_claim_individual_episode_progress(self):
|
||||
rows = [{'downloadId': 'PACK', 'episode': {'seasonNumber': 1, 'episodeNumber': n}} for n in [1, 2, 2]]
|
||||
result = label_episode_downloads([{'hash': 'pack'}], rows)
|
||||
self.assertEqual(result[0]['episodeLabel'], 'S01E01 · S01E02 — shared download progress')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,19 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from backend.app.config import Settings
|
||||
from backend.app.routers.site import _build_site_info
|
||||
|
||||
|
||||
class WelcomeSiteTests(unittest.TestCase):
|
||||
def test_public_response_does_not_expose_playback_url(self):
|
||||
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com'})):
|
||||
self.assertNotIn('mediaServerUrl', _build_site_info(False))
|
||||
|
||||
def test_authenticated_response_uses_public_playback_url(self):
|
||||
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com/web/'})):
|
||||
self.assertEqual(_build_site_info(True)['mediaServerUrl'], 'https://watch.example.com/web/')
|
||||
|
||||
def test_missing_unsafe_or_credential_urls_have_no_watch_link(self):
|
||||
for url in ['', 'javascript:alert(1)', '//internal', 'https://user:secret@example.com', 'https://[broken']:
|
||||
with self.subTest(url=url), patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': url})):
|
||||
self.assertIsNone(_build_site_info(True)['mediaServerUrl'])
|
||||
Reference in New Issue
Block a user