feat: add backup recovery, setup wizard and user-view guards
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s

This commit is contained in:
2026-09-18 17:23:03 +12:00
parent a6a4a9aa24
commit fd6671cf7e
44 changed files with 4650 additions and 114 deletions
+310
View File
@@ -0,0 +1,310 @@
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_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()
+175
View File
@@ -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)
+267
View File
@@ -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()