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>
110 lines
4.0 KiB
Python
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()
|