Files
hermes-webui/tests/test_atomic_settings_writes.py
T
0d3ee7de9f Release Batch A: worktree cli-shadow + atomic settings.json + state.db read-only (#5832, #5829, #5833) (#5837)
Phase 1 low-risk backend reliability batch. Three independent contributor fixes
on distinct files, each byte-identical to its PR head, Codex SAFE, 691+16 tests pass.

- #5832 (silent-reader-cn): importlib-load agent cli.py by absolute path so a
  shadowing cli/ namespace package can't brick worktree creation
- #5829 (ai-ag2026): atomic settings.json writes (temp+fsync+os.replace, mode-preserving)
- #5833 (ai-ag2026): open the remaining 8 state.db read projections read-only

Co-authored-by: t <a@b>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 09:04:59 -07:00

110 lines
4.0 KiB
Python

"""Regression tests — settings.json is written atomically.
`save_settings` (and the startup default-workspace rewrite) persisted
`settings.json` via a plain `Path.write_text`, which truncates the file in
place. A crash / full disk / power loss mid-write leaves it truncated or
empty, so the next start loses every persisted setting — theme, workspace,
tab order, and the login `password_hash` (losing the hash also silently
disables auth, but the data-loss regression is the point here).
The write now goes through `_atomic_write_settings_text`: temp file in the
same dir, fsync, `os.replace`. A failure before the rename leaves the
ORIGINAL file byte-for-byte intact. These pin the helper directly (success,
mode preservation, crash-safety, symlink write-through) so a refactor can't
reintroduce the truncating plain write.
"""
import os
from pathlib import Path
import pytest
from api.config import _atomic_write_settings_text
def test_replaces_contents(tmp_path: Path) -> None:
target = tmp_path / "settings.json"
target.write_text('{"theme": "old"}', encoding="utf-8")
_atomic_write_settings_text(target, '{"theme": "new"}')
assert target.read_text(encoding="utf-8") == '{"theme": "new"}'
# No temp debris after a clean write.
assert [p.name for p in tmp_path.iterdir()] == ["settings.json"]
def test_creates_new_file(tmp_path: Path) -> None:
target = tmp_path / "settings.json"
_atomic_write_settings_text(target, '{"created": true}')
assert target.read_text(encoding="utf-8") == '{"created": true}'
def test_preserves_hardened_mode(tmp_path: Path) -> None:
"""A 0600 settings.json (operator-hardened because it holds the password
hash) must not be loosened to 0644 by the atomic replace."""
target = tmp_path / "settings.json"
target.write_text('{"password_hash": "x"}', encoding="utf-8")
os.chmod(target, 0o600)
_atomic_write_settings_text(target, '{"password_hash": "y"}')
assert (os.stat(target).st_mode & 0o777) == 0o600
def test_failed_replace_leaves_original_intact(tmp_path: Path, monkeypatch) -> None:
target = tmp_path / "settings.json"
original = '{"theme": "keep-me"}'
target.write_text(original, encoding="utf-8")
def _boom(src, dst):
raise RuntimeError("simulated crash before rename commits")
monkeypatch.setattr(os, "replace", _boom)
with pytest.raises(RuntimeError, match="simulated crash"):
_atomic_write_settings_text(target, '{"theme": "half-written"}')
assert target.read_text(encoding="utf-8") == original
# Temp file cleaned up, not left as debris.
assert [p.name for p in tmp_path.iterdir()] == ["settings.json"]
def test_writes_through_symlink(tmp_path: Path) -> None:
real_dir = tmp_path / "real"
link_dir = tmp_path / "link"
real_dir.mkdir()
link_dir.mkdir()
target = real_dir / "settings.json"
link = link_dir / "settings.json"
target.write_text('{"theme": "old"}', encoding="utf-8")
link.symlink_to(target)
_atomic_write_settings_text(link, '{"theme": "new"}')
# The link stays a link; the referent got the new contents.
assert link.is_symlink()
assert target.read_text(encoding="utf-8") == '{"theme": "new"}'
assert [p.name for p in link_dir.iterdir()] == ["settings.json"]
def test_save_settings_uses_atomic_writer(tmp_path: Path, monkeypatch) -> None:
"""End-to-end: save_settings must route through the atomic writer, not a
bare write_text (which a future edit could reintroduce)."""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
calls = []
real = config._atomic_write_settings_text
def _spy(path, text):
calls.append(Path(path))
return real(path, text)
monkeypatch.setattr(config, "_atomic_write_settings_text", _spy)
config.save_settings({"theme": "dark"})
assert settings_file in calls, "save_settings must use the atomic writer"
assert settings_file.exists()