feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
@@ -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()
|
||||
@@ -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,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()
|
||||
Reference in New Issue
Block a user