personal memory agent
0

Configure Feed

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

solstone / tests / test_backup_rotation.py
10 kB 294 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7import logging 8from pathlib import Path 9from typing import Any 10 11import pytest 12 13from solstone.think.backup import rotation, state 14from solstone.think.backup.destination import DestinationStatus 15from solstone.think.backup.repo import ResticKeyError 16 17OLD_KEY = "A" * 64 18NEW_KEY = "B" * 64 19 20 21def _config_path(journal: Path) -> Path: 22 return journal / "config" / "journal.json" 23 24 25def _write_config(journal: Path, payload: dict[str, Any]) -> None: 26 config_path = _config_path(journal) 27 config_path.parent.mkdir(parents=True, exist_ok=True) 28 config_path.write_text(json.dumps(payload), encoding="utf-8") 29 30 31def _read_config(journal: Path) -> dict[str, Any]: 32 return json.loads(_config_path(journal).read_text(encoding="utf-8")) 33 34 35def _configured_backup() -> dict[str, Any]: 36 return { 37 "backup": { 38 "enabled": True, 39 "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 "daily_key": "daily-secret", 48 "recovery_key": OLD_KEY, 49 "confirmed_recovery_key": True, 50 } 51 } 52 53 54def _repo_exists() -> DestinationStatus: 55 return DestinationStatus(True, True, "repo_exists", "exists") 56 57 58def test_rotation_skips_when_unconfigured( 59 tmp_path: Path, 60 monkeypatch: pytest.MonkeyPatch, 61) -> None: 62 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 63 _write_config(tmp_path, {}) 64 monkeypatch.setattr( 65 rotation, 66 "ensure_restic", 67 lambda: pytest.fail("restic should not be resolved"), 68 ) 69 70 result = rotation.rotate_recovery_key() 71 72 assert result == rotation.RotationResult( 73 status="skipped", 74 reason_code=None, 75 recovery_key=None, 76 recovery_key_display=None, 77 ) 78 79 80def test_rotation_restic_unavailable( 81 tmp_path: Path, 82 monkeypatch: pytest.MonkeyPatch, 83) -> None: 84 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 85 _write_config(tmp_path, _configured_backup()) 86 monkeypatch.setattr( 87 rotation, 88 "ensure_restic", 89 lambda: (_ for _ in ()).throw(RuntimeError("missing")), 90 ) 91 92 result = rotation.rotate_recovery_key() 93 94 assert result.reason_code == "restic_unavailable" 95 96 97def test_rotation_backend_invalid( 98 tmp_path: Path, 99 monkeypatch: pytest.MonkeyPatch, 100) -> None: 101 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 102 config = _configured_backup() 103 del config["backup"]["destination"]["credentials"]["secret_access_key"] 104 _write_config(tmp_path, config) 105 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 106 107 result = rotation.rotate_recovery_key() 108 109 assert result.reason_code == "failed" 110 111 112def test_rotation_success_order_persists_new_key_and_scrubs_logs( 113 tmp_path: Path, 114 monkeypatch: pytest.MonkeyPatch, 115 caplog: pytest.LogCaptureFixture, 116) -> None: 117 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 118 _write_config(tmp_path, _configured_backup()) 119 order: list[tuple[str, Any]] = [] 120 original_set_recovery_key = state.set_recovery_key 121 original_set_recovery_key_confirmed = state.set_recovery_key_confirmed 122 123 def fake_capture(destination, *, password, restic_path, timeout=None): 124 order.append(("capture", password)) 125 return "old-key-id" 126 127 def fake_add(destination, *, daily_key, recovery_key, restic_path, timeout=None): 128 order.append(("add", daily_key, recovery_key)) 129 130 def fake_validate(destination, password, *, restic_path, timeout=None): 131 order.append(("verify", password)) 132 return _repo_exists() 133 134 def fake_remove(destination, *, password, key_id, restic_path, timeout=None): 135 order.append(("remove", password, key_id)) 136 137 def wrapped_set_recovery_key(recovery_key: str) -> None: 138 order.append(("persist-key", recovery_key)) 139 original_set_recovery_key(recovery_key) 140 141 def wrapped_set_recovery_key_confirmed(confirmed: bool) -> None: 142 order.append(("persist-confirmed", confirmed)) 143 original_set_recovery_key_confirmed(confirmed) 144 145 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 146 monkeypatch.setattr(rotation, "_capture_current_key_id", fake_capture) 147 monkeypatch.setattr(rotation, "_add_recovery_key", fake_add) 148 monkeypatch.setattr(rotation, "validate_destination", fake_validate) 149 monkeypatch.setattr(rotation, "_remove_key", fake_remove) 150 monkeypatch.setattr(rotation, "generate_recovery_key", lambda: NEW_KEY) 151 monkeypatch.setattr(rotation, "set_recovery_key", wrapped_set_recovery_key) 152 monkeypatch.setattr( 153 rotation, 154 "set_recovery_key_confirmed", 155 wrapped_set_recovery_key_confirmed, 156 ) 157 caplog.set_level(logging.INFO, logger="solstone.backup.rotation") 158 159 result = rotation.rotate_recovery_key() 160 161 assert result.status == "ok" 162 assert result.reason_code is None 163 assert result.recovery_key == NEW_KEY 164 assert result.recovery_key_display == rotation.format_recovery_key_display(NEW_KEY) 165 assert order == [ 166 ("capture", OLD_KEY), 167 ("add", "daily-secret", NEW_KEY), 168 ("verify", NEW_KEY), 169 ("remove", "daily-secret", "old-key-id"), 170 ("persist-key", NEW_KEY), 171 ("persist-confirmed", False), 172 ] 173 backup = _read_config(tmp_path)["backup"] 174 assert backup["daily_key"] == "daily-secret" 175 assert backup["recovery_key"] == NEW_KEY 176 assert backup["confirmed_recovery_key"] is False 177 serialized = json.dumps(result.__dict__) 178 for secret in ("daily-secret", OLD_KEY, "access-key", "secret-key"): 179 assert secret not in serialized 180 assert secret not in caplog.text 181 assert NEW_KEY not in caplog.text 182 183 184def test_rotation_capture_auth_failure_maps_reason_and_writes_nothing( 185 tmp_path: Path, 186 monkeypatch: pytest.MonkeyPatch, 187) -> None: 188 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 189 original_config = _configured_backup() 190 _write_config(tmp_path, original_config) 191 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 192 monkeypatch.setattr( 193 rotation, 194 "_capture_current_key_id", 195 lambda *args, **kwargs: (_ for _ in ()).throw(ResticKeyError("key list", 12)), 196 ) 197 198 result = rotation.rotate_recovery_key() 199 200 assert result.reason_code == "auth_failed" 201 assert _read_config(tmp_path) == original_config 202 203 204def test_rotation_confirm_failure_aborts_before_verify_or_remove( 205 tmp_path: Path, 206 monkeypatch: pytest.MonkeyPatch, 207) -> None: 208 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 209 original_config = _configured_backup() 210 _write_config(tmp_path, original_config) 211 calls: list[str] = [] 212 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 213 monkeypatch.setattr( 214 rotation, 215 "_capture_current_key_id", 216 lambda *args, **kwargs: calls.append("capture") or "old-key-id", 217 ) 218 monkeypatch.setattr( 219 rotation, 220 "_add_recovery_key", 221 lambda *args, **kwargs: calls.append("add"), 222 ) 223 monkeypatch.setattr(rotation, "generate_recovery_key", lambda: NEW_KEY) 224 monkeypatch.setattr(rotation, "confirm_recovery_key", lambda *args: False) 225 monkeypatch.setattr( 226 rotation, 227 "validate_destination", 228 lambda *args, **kwargs: pytest.fail("must not verify"), 229 ) 230 monkeypatch.setattr( 231 rotation, 232 "_remove_key", 233 lambda *args, **kwargs: pytest.fail("must not remove"), 234 ) 235 236 result = rotation.rotate_recovery_key() 237 238 assert result.reason_code == "failed" 239 assert calls == ["capture", "add"] 240 assert _read_config(tmp_path) == original_config 241 242 243def test_rotation_verify_failure_aborts_before_remove_and_writes_nothing( 244 tmp_path: Path, 245 monkeypatch: pytest.MonkeyPatch, 246) -> None: 247 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 248 original_config = _configured_backup() 249 _write_config(tmp_path, original_config) 250 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 251 monkeypatch.setattr(rotation, "_capture_current_key_id", lambda *a, **k: "old-id") 252 monkeypatch.setattr(rotation, "_add_recovery_key", lambda *a, **k: None) 253 monkeypatch.setattr(rotation, "generate_recovery_key", lambda: NEW_KEY) 254 monkeypatch.setattr( 255 rotation, 256 "validate_destination", 257 lambda *a, **k: DestinationStatus(True, True, "auth_failed", "bad"), 258 ) 259 monkeypatch.setattr( 260 rotation, 261 "_remove_key", 262 lambda *args, **kwargs: pytest.fail("must not remove"), 263 ) 264 265 result = rotation.rotate_recovery_key() 266 267 assert result.reason_code == "auth_failed" 268 assert _read_config(tmp_path) == original_config 269 270 271def test_rotation_remove_failure_leaves_config_unchanged( 272 tmp_path: Path, 273 monkeypatch: pytest.MonkeyPatch, 274) -> None: 275 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 276 original_config = _configured_backup() 277 _write_config(tmp_path, original_config) 278 monkeypatch.setattr(rotation, "ensure_restic", lambda: Path("/restic")) 279 monkeypatch.setattr(rotation, "_capture_current_key_id", lambda *a, **k: "old-id") 280 monkeypatch.setattr(rotation, "_add_recovery_key", lambda *a, **k: None) 281 monkeypatch.setattr(rotation, "generate_recovery_key", lambda: NEW_KEY) 282 monkeypatch.setattr( 283 rotation, "validate_destination", lambda *a, **k: _repo_exists() 284 ) 285 monkeypatch.setattr( 286 rotation, 287 "_remove_key", 288 lambda *args, **kwargs: (_ for _ in ()).throw(ResticKeyError("key remove", 11)), 289 ) 290 291 result = rotation.rotate_recovery_key() 292 293 assert result.reason_code == "locked" 294 assert _read_config(tmp_path) == original_config