personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_backup_cli.py
25 kB 803 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7import sys 8from pathlib import Path 9from typing import Any 10from unittest.mock import Mock 11 12import pytest 13from typer.testing import CliRunner 14 15from solstone.think import backup_cli, sol_cli 16from solstone.think.backup.destination import Destination, DestinationStatus 17from solstone.think.backup.engine import BackupResult, PruneResult 18from solstone.think.backup.hosted import hosted_binding_path, load_hosted_binding 19from solstone.think.backup.keys import format_recovery_key_display 20from solstone.think.backup.restore import RestoreResult 21from solstone.think.backup.rotation import RotationResult 22from solstone.think.backup.state import BackupKeys 23from solstone.think.backup.teardown import TeardownResult 24from solstone.think.offload import OffloadResult, OffloadSegmentDetail 25from solstone.think.offload_restore import OffloadRestoreResult 26 27 28def _config_path(journal: Path) -> Path: 29 return journal / "config" / "journal.json" 30 31 32def _write_config(journal: Path, payload: dict[str, Any]) -> None: 33 config_path = _config_path(journal) 34 config_path.parent.mkdir(parents=True, exist_ok=True) 35 config_path.write_text(json.dumps(payload), encoding="utf-8") 36 37 38def _destination() -> Destination: 39 return Destination( 40 repository="s3:safe-bucket/path", 41 backend="s3", 42 credentials={ 43 "access_key_id": "access-key", 44 "secret_access_key": "secret-key", 45 }, 46 ) 47 48 49def _destination_config() -> dict[str, Any]: 50 destination = _destination() 51 return { 52 "repository": destination.repository, 53 "backend": destination.backend, 54 "credentials": destination.credentials, 55 } 56 57 58def _status(reason_code: str) -> DestinationStatus: 59 if reason_code == "repo_missing": 60 return DestinationStatus( 61 reachable=True, 62 repo_exists=False, 63 reason_code="repo_missing", 64 message="backup destination is reachable and needs setup", 65 ) 66 if reason_code == "auth_failed": 67 return DestinationStatus( 68 reachable=True, 69 repo_exists=True, 70 reason_code="auth_failed", 71 message="repository password was rejected", 72 ) 73 return DestinationStatus( 74 reachable=True, 75 repo_exists=True, 76 reason_code="repo_exists", 77 message="backup repository is reachable", 78 ) 79 80 81def test_registry_and_command_tree(monkeypatch: pytest.MonkeyPatch) -> None: 82 assert sol_cli.COMMANDS["backup"].module == "solstone.think.backup_cli" 83 assert sol_cli.COMMANDS["backup"].surface == "service" 84 85 captured: dict[str, object] = {} 86 87 def fake_run_command(module_path: str) -> int: 88 captured["module"] = module_path 89 captured["argv"] = list(sys.argv) 90 return 0 91 92 monkeypatch.setattr(sol_cli, "run_command", fake_run_command) 93 monkeypatch.setattr(sol_cli.setproctitle, "setproctitle", lambda _title: None) 94 monkeypatch.setattr(sys, "argv", ["journal", "backup", "status"]) 95 96 with pytest.raises(SystemExit) as exc_info: 97 sol_cli.journal_main() 98 99 assert exc_info.value.code == 0 100 assert captured == { 101 "module": "solstone.think.backup_cli", 102 "argv": ["journal backup", "status"], 103 } 104 105 runner = CliRunner() 106 root_help = runner.invoke(backup_cli.app, ["--help"]) 107 assert root_help.exit_code == 0 108 for command in ( 109 "enable", 110 "destination", 111 "run", 112 "prune", 113 "status", 114 "recovery-key", 115 "offload", 116 "restore", 117 "off", 118 ): 119 assert command in root_help.output 120 121 destination_help = runner.invoke(backup_cli.app, ["destination", "--help"]) 122 assert destination_help.exit_code == 0 123 assert "set" in destination_help.output 124 assert "show" in destination_help.output 125 126 recovery_help = runner.invoke(backup_cli.app, ["recovery-key", "--help"]) 127 assert recovery_help.exit_code == 0 128 assert "show" in recovery_help.output 129 assert "rotate" in recovery_help.output 130 131 offload_help = runner.invoke(backup_cli.app, ["offload", "--help"]) 132 assert offload_help.exit_code == 0 133 assert "status" in offload_help.output 134 assert "run" in offload_help.output 135 assert "restore" in offload_help.output 136 137 138@pytest.mark.parametrize( 139 ("reason_code", "expected_exit"), 140 [ 141 ("repo_missing", 0), 142 ("auth_failed", 1), 143 ], 144) 145def test_destination_set_reads_stdin_and_keeps_secrets_off_argv( 146 tmp_path: Path, 147 monkeypatch: pytest.MonkeyPatch, 148 reason_code: str, 149 expected_exit: int, 150) -> None: 151 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 152 _write_config(tmp_path, {}) 153 captured: dict[str, Any] = {} 154 155 def fake_set_destination(destination: Destination) -> None: 156 captured["destination"] = destination 157 158 def fake_validate_destination( 159 destination: Destination, 160 password: str, 161 *, 162 restic_path: Path, 163 ) -> DestinationStatus: 164 captured["argv"] = list(sys.argv) 165 captured["password"] = password 166 captured["restic_path"] = restic_path 167 return _status(reason_code) 168 169 monkeypatch.setattr(backup_cli, "set_destination", fake_set_destination) 170 monkeypatch.setattr(backup_cli, "ensure_restic", lambda: Path("/restic")) 171 monkeypatch.setattr(backup_cli, "generate_daily_key", lambda: "probe-secret") 172 monkeypatch.setattr(backup_cli, "validate_destination", fake_validate_destination) 173 174 payload = { 175 "repository": "s3:safe-bucket/path", 176 "backend": "s3", 177 "credentials": { 178 "access_key_id": "AKIASECRET", 179 "secret_access_key": "TOPSECRET", 180 }, 181 } 182 result = CliRunner().invoke( 183 backup_cli.app, 184 ["destination", "set"], 185 input=json.dumps(payload), 186 ) 187 188 assert result.exit_code == expected_exit 189 destination = captured["destination"] 190 assert destination.credentials == payload["credentials"] 191 assert captured["password"] == "probe-secret" 192 assert captured["restic_path"] == Path("/restic") 193 assert "TOPSECRET" not in " ".join(captured["argv"]) 194 assert "AKIASECRET" not in " ".join(captured["argv"]) 195 assert "TOPSECRET" not in result.output 196 assert "AKIASECRET" not in result.output 197 198 199def test_destination_set_hosted_writes_binding_0600_without_leaking_token( 200 tmp_path: Path, 201 monkeypatch: pytest.MonkeyPatch, 202) -> None: 203 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 204 payload = { 205 "broker_endpoint": "https://broker.example", 206 "account_id": "acct", 207 "instance_id": "inst", 208 "bucket": "bkt", 209 "prefix": "users/acct/inst/", 210 "broker_token": "BTOKEN", 211 } 212 213 result = CliRunner().invoke( 214 backup_cli.app, 215 ["destination", "set-hosted"], 216 input=json.dumps(payload), 217 ) 218 219 assert result.exit_code == 0 220 assert "BTOKEN" not in result.stdout 221 binding = load_hosted_binding() 222 assert binding is not None 223 assert binding.prefix == "users/acct/inst/" 224 assert hosted_binding_path().stat().st_mode & 0o777 == 0o600 225 226 227def test_destination_set_hosted_rejects_missing_field( 228 tmp_path: Path, 229 monkeypatch: pytest.MonkeyPatch, 230) -> None: 231 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 232 payload = { 233 "broker_endpoint": "https://broker.example", 234 "account_id": "acct", 235 "instance_id": "inst", 236 "bucket": "bkt", 237 "prefix": "users/acct/inst/", 238 } 239 240 result = CliRunner().invoke( 241 backup_cli.app, 242 ["destination", "set-hosted"], 243 input=json.dumps(payload), 244 ) 245 246 assert result.exit_code != 0 247 248 249def test_status_and_destination_show_are_redacted( 250 tmp_path: Path, 251 monkeypatch: pytest.MonkeyPatch, 252) -> None: 253 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 254 recovery_key = "A" * 64 255 _write_config( 256 tmp_path, 257 { 258 "backup": { 259 "destination": { 260 "repository": "s3:safe-bucket/path", 261 "backend": "s3", 262 "credentials": { 263 "access_key_id": "ACCESSSECRET", 264 "secret_access_key": "BACKENDSECRET", 265 }, 266 }, 267 "daily_key": "DAILYSECRET", 268 "recovery_key": recovery_key, 269 } 270 }, 271 ) 272 runner = CliRunner() 273 274 status_result = runner.invoke(backup_cli.app, ["status"]) 275 destination_result = runner.invoke(backup_cli.app, ["destination", "show"]) 276 277 assert status_result.exit_code == 0 278 assert destination_result.exit_code == 0 279 output = status_result.output + destination_result.output 280 for secret in ("DAILYSECRET", recovery_key, "ACCESSSECRET", "BACKENDSECRET"): 281 assert secret not in output 282 assert "credentials_set" in output 283 284 285def test_enable_accepts_lookalike_recovery_key_confirmation( 286 tmp_path: Path, 287 monkeypatch: pytest.MonkeyPatch, 288) -> None: 289 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 290 _write_config(tmp_path, {"backup": {"destination": _destination_config()}}) 291 recovery_key = ("0" * 32) + ("1" * 32) 292 display = format_recovery_key_display(recovery_key) 293 keys = BackupKeys( 294 daily_key="daily-secret", 295 recovery_key=recovery_key, 296 recovery_key_display=display, 297 ) 298 calls: dict[str, Any] = {} 299 300 def fake_set_recovery_key_confirmed(confirmed: bool = True) -> None: 301 calls["confirmed"] = confirmed 302 303 def fake_set_enabled(enabled: bool) -> None: 304 calls["enabled"] = enabled 305 306 def fake_init_repository( 307 destination: Destination, 308 *, 309 daily_key: str, 310 recovery_key: str, 311 restic_path: Path, 312 ) -> None: 313 calls["init"] = { 314 "destination": destination, 315 "daily_key": daily_key, 316 "recovery_key": recovery_key, 317 "restic_path": restic_path, 318 } 319 320 monkeypatch.setattr(backup_cli, "generate_and_store_keys", lambda: keys) 321 monkeypatch.setattr(backup_cli, "ensure_restic", lambda: Path("/restic")) 322 monkeypatch.setattr( 323 backup_cli, 324 "set_recovery_key_confirmed", 325 fake_set_recovery_key_confirmed, 326 ) 327 monkeypatch.setattr(backup_cli, "set_enabled", fake_set_enabled) 328 monkeypatch.setattr(backup_cli, "init_repository", fake_init_repository) 329 330 entered = display.replace("0", "O").replace("1", "I") 331 result = CliRunner().invoke(backup_cli.app, ["enable"], input=entered) 332 333 assert result.exit_code == 0 334 assert calls["confirmed"] is True 335 assert calls["enabled"] is True 336 assert calls["init"]["daily_key"] == "daily-secret" 337 assert calls["init"]["recovery_key"] == recovery_key 338 339 340def test_off_requires_yes_before_teardown(monkeypatch: pytest.MonkeyPatch) -> None: 341 teardown_backup = Mock(return_value=TeardownResult(status="ok", reason_code=None)) 342 monkeypatch.setattr(backup_cli, "teardown_backup", teardown_backup) 343 runner = CliRunner() 344 345 refused = runner.invoke(backup_cli.app, ["off"]) 346 accepted = runner.invoke(backup_cli.app, ["off", "--yes"]) 347 348 assert refused.exit_code == 1 349 assert "Refusing" in refused.output 350 teardown_backup.assert_called_once_with() 351 assert accepted.exit_code == 0 352 assert "Backup turned off." in accepted.output 353 354 355def test_enable_power_user_skips_ceremony( 356 tmp_path: Path, 357 monkeypatch: pytest.MonkeyPatch, 358) -> None: 359 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 360 _write_config( 361 tmp_path, 362 { 363 "backup": { 364 "destination": _destination_config(), 365 "daily_key": "manual-daily", 366 "recovery_key": None, 367 } 368 }, 369 ) 370 set_enabled = Mock() 371 monkeypatch.setattr( 372 backup_cli, 373 "generate_and_store_keys", 374 lambda: pytest.fail("ceremony should be skipped"), 375 ) 376 monkeypatch.setattr( 377 backup_cli, 378 "set_recovery_key_confirmed", 379 lambda *_args, **_kwargs: pytest.fail("confirmation should not be set"), 380 ) 381 monkeypatch.setattr(backup_cli, "ensure_restic", lambda: Path("/restic")) 382 monkeypatch.setattr( 383 backup_cli, "validate_destination", lambda *a, **k: _status("ok") 384 ) 385 monkeypatch.setattr(backup_cli, "set_enabled", set_enabled) 386 387 result = CliRunner().invoke(backup_cli.app, ["enable"]) 388 389 assert result.exit_code == 0 390 assert "Your recovery key" not in result.output 391 set_enabled.assert_called_once_with(True) 392 393 394def test_enable_power_user_requires_existing_repository( 395 tmp_path: Path, 396 monkeypatch: pytest.MonkeyPatch, 397) -> None: 398 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 399 _write_config( 400 tmp_path, 401 { 402 "backup": { 403 "destination": _destination_config(), 404 "daily_key": "manual-daily", 405 "recovery_key": None, 406 } 407 }, 408 ) 409 set_enabled = Mock() 410 monkeypatch.setattr(backup_cli, "ensure_restic", lambda: Path("/restic")) 411 monkeypatch.setattr( 412 backup_cli, 413 "validate_destination", 414 lambda *a, **k: _status("repo_missing"), 415 ) 416 monkeypatch.setattr(backup_cli, "set_enabled", set_enabled) 417 418 result = CliRunner().invoke(backup_cli.app, ["enable"]) 419 420 assert result.exit_code == 1 421 assert "Repository not found" in result.output 422 set_enabled.assert_not_called() 423 424 425@pytest.mark.parametrize( 426 ("result", "expected_exit", "expected_text"), 427 [ 428 (BackupResult("ok", "snap123", None), 0, "snap123"), 429 (BackupResult("skipped", None, None), 0, "skipped"), 430 (BackupResult("error", None, "auth_failed"), 1, "auth_failed"), 431 ], 432) 433def test_run_maps_engine_status( 434 monkeypatch: pytest.MonkeyPatch, 435 result: BackupResult, 436 expected_exit: int, 437 expected_text: str, 438) -> None: 439 monkeypatch.setattr(backup_cli, "run_backup", lambda: result) 440 441 invoke_result = CliRunner().invoke(backup_cli.app, ["run"]) 442 443 assert invoke_result.exit_code == expected_exit 444 assert expected_text in invoke_result.output 445 446 447@pytest.mark.parametrize( 448 ("result", "expected_exit", "expected_text"), 449 [ 450 (PruneResult("ok", None), 0, "Retention prune complete."), 451 (PruneResult("skipped", None), 0, "skipped"), 452 (PruneResult("error", "auth_failed"), 1, "auth_failed"), 453 ], 454) 455def test_prune_maps_engine_status( 456 monkeypatch: pytest.MonkeyPatch, 457 result: PruneResult, 458 expected_exit: int, 459 expected_text: str, 460) -> None: 461 monkeypatch.setattr(backup_cli, "run_prune", lambda: result) 462 463 invoke_result = CliRunner().invoke(backup_cli.app, ["prune"]) 464 465 assert invoke_result.exit_code == expected_exit 466 assert expected_text in invoke_result.output 467 468 469def test_offload_status_json_delegates_to_status_builder( 470 monkeypatch: pytest.MonkeyPatch, 471) -> None: 472 payload = { 473 "offload": {"enabled": True, "budget_bytes": 100, "floor_bytes": 50}, 474 "raw_media": {"total_bytes": 30, "total_files": 3}, 475 "backup_only": {"total_bytes": 20, "degraded": False}, 476 } 477 build_offload_status = Mock(return_value=payload) 478 monkeypatch.setattr(backup_cli, "build_offload_status", build_offload_status) 479 480 result = CliRunner().invoke(backup_cli.app, ["offload", "status", "--json"]) 481 482 assert result.exit_code == 0 483 assert json.loads(result.output) == payload 484 build_offload_status.assert_called_once_with() 485 486 487def test_offload_run_delegates_to_existing_pass_and_formats_like_maintenance( 488 monkeypatch: pytest.MonkeyPatch, 489) -> None: 490 captured: dict[str, Any] = {} 491 492 def fake_run_offload(*, dry_run: bool) -> OffloadResult: 493 captured["dry_run"] = dry_run 494 return OffloadResult( 495 status="ok", 496 reason=None, 497 files_offloaded=0, 498 bytes_offloaded=0, 499 ran_out_of_media=False, 500 dry_run=True, 501 details=( 502 OffloadSegmentDetail( 503 day="20260101", 504 stream="_default", 505 segment="090000_300", 506 files=2, 507 bytes=50, 508 ), 509 ), 510 ) 511 512 monkeypatch.setattr(backup_cli, "run_offload", fake_run_offload) 513 514 result = CliRunner().invoke(backup_cli.app, ["offload", "run", "--dry-run"]) 515 516 assert result.exit_code == 0 517 assert captured == {"dry_run": True} 518 assert ( 519 "backup offload: ok dry_run=true selected_files=2 selected_bytes=50 " 520 "ran_out_of_media=False segments=20260101/_default/090000_300:50" 521 in result.output 522 ) 523 524 525def test_offload_restore_day_json_delegates_to_restore_engine( 526 monkeypatch: pytest.MonkeyPatch, 527) -> None: 528 restore_day = Mock( 529 return_value=OffloadRestoreResult( 530 status="ok", 531 reason=None, 532 scope="day", 533 day="20260228", 534 segments_selected=1, 535 segments_restored=1, 536 files_expected=2, 537 files_restored=2, 538 bytes_expected=50, 539 bytes_restored=50, 540 details=(), 541 ) 542 ) 543 monkeypatch.setattr(backup_cli, "restore_day", restore_day) 544 545 result = CliRunner().invoke( 546 backup_cli.app, 547 ["offload", "restore", "20260228", "--json"], 548 ) 549 550 assert result.exit_code == 0 551 assert json.loads(result.output)["day"] == "20260228" 552 restore_day.assert_called_once_with("20260228") 553 554 555def test_offload_restore_all_and_invalid_scope( 556 monkeypatch: pytest.MonkeyPatch, 557) -> None: 558 restore_all = Mock( 559 return_value=OffloadRestoreResult( 560 status="no_op", 561 reason="nothing_to_restore", 562 scope="all", 563 day=None, 564 segments_selected=0, 565 segments_restored=0, 566 files_expected=0, 567 files_restored=0, 568 bytes_expected=0, 569 bytes_restored=0, 570 details=(), 571 ) 572 ) 573 monkeypatch.setattr(backup_cli, "restore_all", restore_all) 574 575 all_result = CliRunner().invoke(backup_cli.app, ["offload", "restore", "--all"]) 576 mixed_result = CliRunner().invoke( 577 backup_cli.app, 578 ["offload", "restore", "20260228", "--all"], 579 ) 580 missing_result = CliRunner().invoke(backup_cli.app, ["offload", "restore"]) 581 582 assert all_result.exit_code == 0 583 assert "status=no_op reason=nothing_to_restore" in all_result.output 584 restore_all.assert_called_once_with() 585 assert mixed_result.exit_code == 1 586 assert "Use either a day or --all" in mixed_result.output 587 assert missing_result.exit_code == 1 588 assert "Provide a day or --all" in missing_result.output 589 590 591def test_recovery_key_show_prints_display_only( 592 tmp_path: Path, 593 monkeypatch: pytest.MonkeyPatch, 594) -> None: 595 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 596 recovery_key = "A" * 64 597 display = format_recovery_key_display(recovery_key) 598 _write_config( 599 tmp_path, 600 { 601 "backup": { 602 "daily_key": "daily-secret", 603 "recovery_key": recovery_key, 604 } 605 }, 606 ) 607 608 result = CliRunner().invoke(backup_cli.app, ["recovery-key", "show"]) 609 610 assert result.exit_code == 0 611 assert "AAAA AAAA AAAA AAAA" in result.output 612 assert recovery_key not in result.output 613 assert display.replace(" ", "") not in result.output 614 615 616def test_recovery_key_show_errors_without_key( 617 tmp_path: Path, 618 monkeypatch: pytest.MonkeyPatch, 619) -> None: 620 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 621 _write_config(tmp_path, {}) 622 623 result = CliRunner().invoke(backup_cli.app, ["recovery-key", "show"]) 624 625 assert result.exit_code == 1 626 assert "No recovery key is set." in result.output 627 628 629def test_recovery_key_rotate_prints_display_not_canonical( 630 monkeypatch: pytest.MonkeyPatch, 631) -> None: 632 canonical = "B" * 64 633 display = format_recovery_key_display(canonical) 634 monkeypatch.setattr( 635 backup_cli, 636 "rotate_recovery_key", 637 lambda: RotationResult( 638 status="ok", 639 reason_code=None, 640 recovery_key=canonical, 641 recovery_key_display=display, 642 ), 643 ) 644 645 result = CliRunner().invoke(backup_cli.app, ["recovery-key", "rotate"]) 646 647 assert result.exit_code == 0 648 assert "BBBB BBBB BBBB BBBB" in result.output 649 assert canonical not in result.output 650 651 652def test_recovery_key_rotate_errors(monkeypatch: pytest.MonkeyPatch) -> None: 653 monkeypatch.setattr( 654 backup_cli, 655 "rotate_recovery_key", 656 lambda: RotationResult( 657 status="error", 658 reason_code="auth_failed", 659 recovery_key=None, 660 recovery_key_display=None, 661 ), 662 ) 663 664 result = CliRunner().invoke(backup_cli.app, ["recovery-key", "rotate"]) 665 666 assert result.exit_code == 1 667 assert "auth_failed" in result.output 668 669 670def test_restore_reads_secret_from_stdin_and_reports_result( 671 monkeypatch: pytest.MonkeyPatch, 672) -> None: 673 captured: dict[str, Any] = {} 674 675 def fake_restore_journal( 676 destination: Destination, 677 recovery_key: str, 678 ) -> RestoreResult: 679 captured["destination"] = destination 680 captured["recovery_key"] = recovery_key 681 captured["argv"] = list(sys.argv) 682 return RestoreResult( 683 status="ok", 684 reason_code=None, 685 integrity_ok=True, 686 resumable=True, 687 bytes_restored=123, 688 ) 689 690 monkeypatch.setattr(backup_cli, "restore_journal", fake_restore_journal) 691 payload = { 692 "repository": "b2:bucket:path", 693 "backend": "b2", 694 "credentials": { 695 "account_id": "account-id", 696 "account_key": "account-secret", 697 }, 698 "recovery_key": "RECOVERYSECRET", 699 } 700 701 result = CliRunner().invoke( 702 backup_cli.app, 703 ["restore"], 704 input=json.dumps(payload), 705 ) 706 707 assert result.exit_code == 0 708 assert captured["destination"].backend == "b2" 709 assert captured["recovery_key"] == "RECOVERYSECRET" 710 assert "RECOVERYSECRET" not in " ".join(captured["argv"]) 711 assert "RECOVERYSECRET" not in result.output 712 assert ( 713 "Restore complete: 123 bytes, integrity_ok=True, resumable=True." 714 in result.output 715 ) 716 717 718@pytest.mark.parametrize( 719 ("reason_code", "detail"), 720 [ 721 ( 722 "integrity_unverified", 723 "integrity verification could not run " 724 "(the repository was busy or timed out)", 725 ), 726 ( 727 "integrity_failed", 728 "integrity verification failed — the backup copy may be damaged", 729 ), 730 ], 731) 732def test_restore_maps_degraded( 733 monkeypatch: pytest.MonkeyPatch, 734 reason_code: str, 735 detail: str, 736) -> None: 737 monkeypatch.setattr( 738 backup_cli, 739 "restore_journal", 740 lambda *_args: RestoreResult( 741 status="degraded", 742 reason_code=reason_code, 743 integrity_ok=False, 744 resumable=True, 745 bytes_restored=123, 746 ), 747 ) 748 payload = { 749 "repository": "s3:safe-bucket/path", 750 "backend": "s3", 751 "credentials": { 752 "access_key_id": "access-key", 753 "secret_access_key": "secret-key", 754 }, 755 "recovery_key": "RECOVERYSECRET", 756 } 757 758 result = CliRunner().invoke( 759 backup_cli.app, 760 ["restore"], 761 input=json.dumps(payload), 762 ) 763 764 assert result.exit_code == 1 765 assert ( 766 f"Restored 123 bytes and saved the recovery key, but {detail} " 767 f"(reason_code={reason_code})." 768 ) in result.output 769 assert "Restore failed" not in result.output 770 assert "RECOVERYSECRET" not in result.output 771 772 773def test_restore_maps_error(monkeypatch: pytest.MonkeyPatch) -> None: 774 monkeypatch.setattr( 775 backup_cli, 776 "restore_journal", 777 lambda *_args: RestoreResult( 778 status="error", 779 reason_code="invalid_key", 780 integrity_ok=False, 781 resumable=False, 782 bytes_restored=None, 783 ), 784 ) 785 payload = { 786 "repository": "s3:safe-bucket/path", 787 "backend": "s3", 788 "credentials": { 789 "access_key_id": "access-key", 790 "secret_access_key": "secret-key", 791 }, 792 "recovery_key": "RECOVERYSECRET", 793 } 794 795 result = CliRunner().invoke( 796 backup_cli.app, 797 ["restore"], 798 input=json.dumps(payload), 799 ) 800 801 assert result.exit_code == 1 802 assert "invalid_key" in result.output 803 assert "RECOVERYSECRET" not in result.output