# SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc from __future__ import annotations import json import logging from pathlib import Path from typing import Any import pytest from solstone.think.backup import restore from solstone.think.backup.destination import Destination from solstone.think.backup.hosted import HostedBinding, HostedCredentials from solstone.think.backup.runner import ResticResult from solstone.think.indexer.journal import ScanReport def _config_path(journal: Path) -> Path: return journal / "config" / "journal.json" def _write_config(journal: Path, payload: dict[str, Any]) -> None: config_path = _config_path(journal) config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps(payload), encoding="utf-8") def _read_config(journal: Path) -> dict[str, Any]: return json.loads(_config_path(journal).read_text(encoding="utf-8")) def _destination() -> Destination: return Destination( repository="s3:safe-bucket/path", backend="s3", credentials={ "access_key_id": "access-key", "secret_access_key": "secret-key", }, ) def _operated_destination() -> Destination: return Destination( repository="s3:https://r2.example/journal-backups/users/acct/inst/", backend="s3", credentials={ "access_key_id": "AKID-OPERATED", "secret_access_key": "SAK-OPERATED", "session_token": "SESSION-OPERATED", }, ) def _operated_binding() -> HostedBinding: return HostedBinding( broker_endpoint="https://broker.example", account_id="acct", instance_id="inst", bucket="journal-backups", prefix="users/acct/inst/", broker_token="BTOKEN-OPERATED", ) def _operated_credentials() -> HostedCredentials: return HostedCredentials( access_key_id="AKID-OPERATED", secret_access_key="SAK-OPERATED", session_token="SESSION-OPERATED", endpoint="https://r2.example", expires_at="2026-07-13T12:00:00Z", ) def _result(returncode: int, parsed_json: Any | None = None) -> ResticResult: return ResticResult( returncode=returncode, stdout="", stderr="", json=parsed_json, argv=("restic",), ) def test_restore_success_normalizes_key_assembles_env_and_reindexes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) canonical = ("0" * 32) + ("1" * 32) entered = ("O" * 32) + ("I" * 32) destination = _destination() responses = iter( [ _result( 0, [{"paths": ["/old/journal"], "id": "snapshot-id"}], ), _result( 0, { "message_type": "summary", "bytes_restored": 123, "files_restored": 4, }, ), _result(0), ] ) order: list[str] = [] calls: list[tuple[list[str], dict[str, Any]]] = [] def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: order.append( next(arg for arg in args if arg in {"snapshots", "restore", "check"}) ) calls.append((args, kwargs)) return next(responses) def fake_set_destination(value: Destination) -> None: order.append("set_destination") assert value == destination def fake_set_recovery_key(value: str) -> None: order.append("set_recovery_key") assert value == canonical def fake_set_recovery_key_confirmed(value: bool) -> None: order.append("set_recovery_key_confirmed") assert value is True def fake_scan_journal(journal: str, **kwargs: Any) -> ScanReport: order.append("scan_journal") assert journal == str(tmp_path) assert kwargs == {"full": True} return ScanReport(changed=True, edge_rows_inserted=0) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "run_restic", fake_run_restic) monkeypatch.setattr(restore, "set_destination", fake_set_destination) monkeypatch.setattr(restore, "set_recovery_key", fake_set_recovery_key) monkeypatch.setattr( restore, "set_recovery_key_confirmed", fake_set_recovery_key_confirmed, ) monkeypatch.setattr(restore, "get_backup_config", lambda: {"daily_key": "daily"}) monkeypatch.setattr(restore, "scan_journal", fake_scan_journal) result = restore.restore_journal(destination, entered) assert result == restore.RestoreResult( status="ok", reason_code=None, integrity_ok=True, resumable=True, bytes_restored=123, ) assert order == [ "snapshots", "restore", "check", "set_destination", "set_recovery_key", "set_recovery_key_confirmed", "scan_journal", ] assert calls[0][0] == ["snapshots", "latest"] assert calls[0][1]["password"] == canonical assert calls[0][1]["repository"] == destination.repository assert "access-key" not in calls[0][1]["repository"] assert "secret-key" not in calls[0][1]["repository"] assert calls[0][1]["backend_env"] == { "AWS_ACCESS_KEY_ID": "access-key", "AWS_SECRET_ACCESS_KEY": "secret-key", } assert calls[0][1]["json"] is True assert calls[1][0] == [ "restore", "latest:/old/journal", "--target", str(tmp_path), ] assert calls[1][1]["json"] is True assert calls[2][0] == ["check"] assert "json" not in calls[2][1] def test_restore_wrong_key_returns_auth_failed_without_persisting_secrets( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) original_config = {"backup": {"daily_key": "daily-secret"}} _write_config(tmp_path, original_config) destination = _destination() recovery_key = "A" * 64 monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr( restore, "run_restic", lambda args, **kwargs: _result(12), ) monkeypatch.setattr( restore, "set_destination", lambda destination: pytest.fail("must not persist destination"), ) monkeypatch.setattr( restore, "set_recovery_key", lambda key: pytest.fail("must not persist key"), ) caplog.set_level(logging.WARNING, logger="solstone.backup.restore") result = restore.restore_journal(destination, recovery_key) assert result.reason_code == "auth_failed" assert _read_config(tmp_path) == original_config serialized = json.dumps(result.__dict__) for secret in (recovery_key, "access-key", "secret-key"): assert secret not in serialized assert secret not in caplog.text def test_restore_invalid_key_persists_nothing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) original_config = {"backup": {"daily_key": "daily-secret"}} _write_config(tmp_path, original_config) monkeypatch.setattr( restore, "ensure_restic", lambda: pytest.fail("restic should not be resolved"), ) result = restore.restore_journal(_destination(), "too-short") assert result.reason_code == "invalid_key" assert _read_config(tmp_path) == original_config def test_restore_timeout_reason_from_snapshots_call( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "run_restic", lambda args, **kwargs: _result(124)) result = restore.restore_journal(_destination(), "A" * 64) assert result == restore.RestoreResult( status="error", reason_code="timeout", integrity_ok=False, resumable=False, bytes_restored=None, ) def test_restore_backend_invalid_returns_failed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) destination = _destination() del destination.credentials["secret_access_key"] monkeypatch.setattr( restore, "ensure_restic", lambda: pytest.fail("restic should not be resolved"), ) result = restore.restore_journal(destination, "A" * 64) assert result.reason_code == "failed" def test_restore_restic_unavailable_returns_reason( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) monkeypatch.setattr( restore, "ensure_restic", lambda: (_ for _ in ()).throw(RuntimeError("missing")), ) monkeypatch.setattr( restore, "run_restic", lambda *args, **kwargs: pytest.fail("restic should not run"), ) result = restore.restore_journal(_destination(), "A" * 64) assert result.reason_code == "restic_unavailable" def test_restore_malformed_snapshots_json_returns_failed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr( restore, "run_restic", lambda args, **kwargs: _result(0, {"unexpected": True}), ) result = restore.restore_journal(_destination(), "A" * 64) assert result.reason_code == "failed" @pytest.mark.parametrize( ("check_returncode", "reason_code"), [ (11, "integrity_unverified"), (1, "integrity_failed"), ], ) def test_restore_check_failure_reports_degraded_and_keeps_side_effects( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, check_returncode: int, reason_code: str, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) destination = _destination() recovery_key = "A" * 64 responses = iter( [ _result(0, [{"paths": ["/old/journal"]}]), _result(0, {"message_type": "summary", "bytes_restored": 5}), _result(check_returncode), ] ) order: list[str] = [] calls: list[tuple[list[str], dict[str, Any]]] = [] def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: order.append(args[0]) calls.append((args, kwargs)) return next(responses) def fake_set_destination(value: Destination) -> None: order.append("set_destination") assert value == destination def fake_set_recovery_key(value: str) -> None: order.append("set_recovery_key") assert value == recovery_key def fake_set_recovery_key_confirmed(value: bool) -> None: order.append("set_recovery_key_confirmed") assert value is True def fake_scan_journal(journal: str, **kwargs: Any) -> ScanReport: order.append("scan_journal") assert journal == str(tmp_path) assert kwargs == {"full": True} return ScanReport(changed=True, edge_rows_inserted=0) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "run_restic", fake_run_restic) monkeypatch.setattr(restore, "set_destination", fake_set_destination) monkeypatch.setattr(restore, "set_recovery_key", fake_set_recovery_key) monkeypatch.setattr( restore, "set_recovery_key_confirmed", fake_set_recovery_key_confirmed, ) monkeypatch.setattr(restore, "get_backup_config", lambda: {"daily_key": "daily"}) monkeypatch.setattr(restore, "scan_journal", fake_scan_journal) result = restore.restore_journal(destination, recovery_key) assert result == restore.RestoreResult( status="degraded", reason_code=reason_code, integrity_ok=False, resumable=True, bytes_restored=5, ) assert result.integrity_ok is False assert order == [ "snapshots", "restore", "check", "set_destination", "set_recovery_key", "set_recovery_key_confirmed", "scan_journal", ] assert calls[2][0] == ["check"] def test_restore_missing_daily_key_is_not_resumable( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {}}) responses = iter( [ _result(0, [{"paths": ["/old/journal"]}]), _result(0, {"message_type": "summary", "bytes_restored": 5}), _result(0), ] ) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "run_restic", lambda args, **kwargs: next(responses)) monkeypatch.setattr(restore, "set_destination", lambda destination: None) monkeypatch.setattr(restore, "set_recovery_key", lambda key: None) monkeypatch.setattr(restore, "set_recovery_key_confirmed", lambda confirmed: None) monkeypatch.setattr(restore, "get_backup_config", lambda: {"daily_key": None}) monkeypatch.setattr( restore, "scan_journal", lambda journal, **kwargs: ScanReport(changed=True, edge_rows_inserted=0), ) result = restore.restore_journal(_destination(), "A" * 64) assert result.status == "ok" assert result.resumable is False def test_restore_operated_success_persists_mode_and_key_without_destination( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) _write_config(tmp_path, {"backup": {"daily_key": "daily-secret"}}) canonical = ("0" * 32) + ("1" * 32) entered = ("O" * 32) + ("I" * 32) destination = _operated_destination() responses = iter( [ _result(0, [{"paths": ["/old/journal"]}]), _result(0, {"message_type": "summary", "bytes_restored": 456}), _result(0), ] ) order: list[str] = [] calls: list[tuple[list[str], dict[str, Any]]] = [] real_set_mode = restore.set_mode real_set_recovery_key = restore.set_recovery_key real_set_recovery_key_confirmed = restore.set_recovery_key_confirmed def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: order.append( next(arg for arg in args if arg in {"snapshots", "restore", "check"}) ) calls.append((args, kwargs)) return next(responses) def fake_set_mode(mode: str) -> None: order.append("set_mode") assert mode == "operated" real_set_mode(mode) def fake_set_recovery_key(value: str) -> None: order.append("set_recovery_key") assert value == canonical real_set_recovery_key(value) def fake_set_recovery_key_confirmed(value: bool) -> None: order.append("set_recovery_key_confirmed") assert value is True real_set_recovery_key_confirmed(value) def fake_scan_journal(journal: str, **kwargs: Any) -> ScanReport: order.append("scan_journal") assert journal == str(tmp_path) assert kwargs == {"full": True} return ScanReport(changed=True, edge_rows_inserted=0) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "ensure_rclone", lambda: Path("/rclone")) monkeypatch.setattr(restore, "run_restic", fake_run_restic) monkeypatch.setattr( restore, "set_destination", lambda destination: pytest.fail( "operated restore must not persist destination" ), ) monkeypatch.setattr(restore, "set_mode", fake_set_mode) monkeypatch.setattr(restore, "set_recovery_key", fake_set_recovery_key) monkeypatch.setattr( restore, "set_recovery_key_confirmed", fake_set_recovery_key_confirmed, ) monkeypatch.setattr(restore, "scan_journal", fake_scan_journal) result = restore.restore_journal_operated( _operated_binding(), _operated_credentials(), entered, ) assert result == restore.RestoreResult( status="ok", reason_code=None, integrity_ok=True, resumable=True, bytes_restored=456, ) assert order == [ "snapshots", "restore", "check", "set_mode", "set_recovery_key", "set_recovery_key_confirmed", "scan_journal", ] assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_ACCESS_KEY_ID"] == ( "AKID-OPERATED" ) assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_SECRET_ACCESS_KEY"] == ( "SAK-OPERATED" ) assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_SESSION_TOKEN"] == ( "SESSION-OPERATED" ) assert calls[0][0][:4] == [ "-o", "rclone.program=/rclone", "-o", "rclone.args=serve restic --stdio --append-only --config /dev/null", ] assert calls[0][1]["repository"] == ("rclone:spb:journal-backups/users/acct/inst/") assert calls[0][1]["backend_env"]["RCLONE_CONFIG_SPB_ENV_AUTH"] == "false" config = _read_config(tmp_path) serialized = json.dumps(config) assert config["backup"]["mode"] == "operated" assert config["backup"]["recovery_key"] == canonical assert config["backup"]["confirmed_recovery_key"] is True assert "destination" not in config["backup"] for secret in ( "AKID-OPERATED", "SAK-OPERATED", "SESSION-OPERATED", destination.repository, ): assert secret not in serialized def test_restore_operated_invalid_key_persists_nothing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) original_config = {"backup": {"daily_key": "daily-secret"}} _write_config(tmp_path, original_config) monkeypatch.setattr( restore, "ensure_restic", lambda: pytest.fail("restic should not be resolved"), ) monkeypatch.setattr( restore, "set_mode", lambda mode: pytest.fail("must not persist operated mode"), ) monkeypatch.setattr( restore, "set_destination", lambda destination: pytest.fail("must not persist destination"), ) result = restore.restore_journal_operated( _operated_binding(), _operated_credentials(), "too-short", ) assert result.reason_code == "invalid_key" assert _read_config(tmp_path) == original_config def test_restore_operated_rclone_unavailable_persists_nothing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) original_config = {"backup": {"daily_key": "daily-secret"}} _write_config(tmp_path, original_config) monkeypatch.setattr( restore, "ensure_rclone", lambda: (_ for _ in ()).throw(RuntimeError("missing")), ) monkeypatch.setattr( restore, "ensure_restic", lambda: pytest.fail("restic should not be resolved"), ) result = restore.restore_journal_operated( _operated_binding(), _operated_credentials(), "A" * 64, ) assert result.reason_code == "rclone_unavailable" assert _read_config(tmp_path) == original_config def test_restore_operated_restic_failure_persists_nothing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) original_config = {"backup": {"daily_key": "daily-secret"}} _write_config(tmp_path, original_config) monkeypatch.setattr(restore, "ensure_restic", lambda: Path("/restic")) monkeypatch.setattr(restore, "ensure_rclone", lambda: Path("/rclone")) monkeypatch.setattr(restore, "run_restic", lambda args, **kwargs: _result(12)) monkeypatch.setattr( restore, "set_mode", lambda mode: pytest.fail("must not persist operated mode"), ) monkeypatch.setattr( restore, "set_destination", lambda destination: pytest.fail("must not persist destination"), ) monkeypatch.setattr( restore, "set_recovery_key", lambda key: pytest.fail("must not persist key"), ) result = restore.restore_journal_operated( _operated_binding(), _operated_credentials(), "A" * 64, ) assert result.reason_code == "auth_failed" assert _read_config(tmp_path) == original_config def test_restore_exports_operated_entrypoint() -> None: assert "restore_journal_operated" in restore.__all__