testsAndMisc/python_pkg/syncyomi_guard/tests/test_main.py
Krzysztof kuhy Rudnicki cef72fbab0 syncyomi_guard: refuse a collapsed payload as the new good copy
TachiyomiSY 1.13.2 can report a restore as SUCCESS while writing almost
nothing, then sync that near-empty library up and replace the only good
copy. On 2026-08-09 a 14 MB payload holding 2182 manga became a 267 KB
stub, and the real library survived only as stale pages in the SQLite
write-ahead log.

The upstream bug is Kotlin in an APK and cannot be fixed here, so this
targets the fixable half: decode the payload, count what is actually in
it, and refuse to treat a collapse as normal drift. A pure is-it-zero
check would have caught this incident but not the harder case upstream
describes, where a restore keeps some entries and drops the rest, so the
threshold is proportional. Categories get their own rule because all
2182 manga survived the round trip and all 18 categories did not.

Adds the setup script and a runbook covering the WAL-page salvage used
to recover the library on the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00

217 lines
6.9 KiB
Python

"""Tests for the CLI, whose exit code is the contract systemd reads.
The rule under test throughout: the guard fails closed. Anything it cannot
verify is a failure, because "I could not check, so everything is fine" is how
a degraded copy becomes the only copy.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
import pytest
from python_pkg.syncyomi_guard.__main__ import main
from python_pkg.syncyomi_guard.store import load_baseline
from python_pkg.syncyomi_guard.tests.conftest import backup
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
def _write_db(path: Path, blob: bytes) -> None:
conn = sqlite3.connect(path)
try:
with conn:
conn.execute("CREATE TABLE sync_data (id INTEGER PRIMARY KEY, data BLOB)")
conn.execute("INSERT INTO sync_data (id, data) VALUES (1, ?)", (blob,))
finally:
conn.close()
def _args(tmp_path: Path, db: Path, *extra: str) -> Sequence[str]:
return [
"--db",
str(db),
"--state-dir",
str(tmp_path / "state"),
"--snapshot-dir",
str(tmp_path / "snaps"),
*extra,
]
@pytest.fixture(autouse=True)
def _no_notifications(monkeypatch: pytest.MonkeyPatch) -> None:
"""Never fire a real desktop notification from a test."""
monkeypatch.setattr(
"python_pkg.syncyomi_guard.__main__.shutil.which", lambda _: None
)
def test_first_run_records_a_baseline_and_snapshot(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=10, chapters_each=2, categories=3))
assert main(_args(tmp_path, db)) == 0
assert load_baseline(tmp_path / "state") is not None
assert list((tmp_path / "snaps").glob("*.tachibk"))
def test_healthy_second_run_passes(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=10, chapters_each=2, categories=3))
assert main(_args(tmp_path, db)) == 0
assert main(_args(tmp_path, db)) == 0
def test_collapse_fails_and_preserves_the_good_snapshot(tmp_path: Path) -> None:
"""The 2026-08-09 scenario, end to end through the binary."""
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=100, chapters_each=5, categories=8))
assert main(_args(tmp_path, db)) == 0
good = sorted((tmp_path / "snaps").glob("*.tachibk"))
assert len(good) == 1
good_bytes = good[0].read_bytes()
# The library collapses to a sources-only stub, exactly as it did that night.
db.unlink()
_write_db(db, backup(manga_count=0, sources=2))
assert main(_args(tmp_path, db)) == 1
after = sorted((tmp_path / "snaps").glob("*.tachibk"))
assert len(after) == 1, "a collapsed payload must not be snapshotted"
assert after[0].read_bytes() == good_bytes
baseline = load_baseline(tmp_path / "state")
assert baseline is not None
assert baseline.manga == 100, "baseline must not absorb the collapse"
def test_accept_overrides_a_deliberate_purge(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=100, chapters_each=2, categories=4))
assert main(_args(tmp_path, db)) == 0
db.unlink()
_write_db(db, backup(manga_count=1, sources=1))
assert main(_args(tmp_path, db, "--accept")) == 0
baseline = load_baseline(tmp_path / "state")
assert baseline is not None
assert baseline.manga == 1
def test_no_snapshot_checks_without_writing(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=5, chapters_each=1))
assert main(_args(tmp_path, db, "--no-snapshot")) == 0
assert not (tmp_path / "snaps").exists()
assert load_baseline(tmp_path / "state") is None
def test_missing_database_exits_nonzero(tmp_path: Path) -> None:
"""Fail closed rather than reporting a pass for an unreadable database."""
assert main(_args(tmp_path, tmp_path / "absent.db")) == 1
def test_undecodable_payload_exits_nonzero(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, b"\xff\xff\xff not protobuf")
assert main(_args(tmp_path, db)) == 1
def test_invalid_threshold_exits_nonzero(tmp_path: Path) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=1))
assert main(_args(tmp_path, db, "--max-drop", "5")) == 1
def test_collapse_message_names_the_loss(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
db = tmp_path / "syncyomi.db"
_write_db(db, backup(manga_count=50, chapters_each=2, categories=6))
main(_args(tmp_path, db))
capsys.readouterr()
db.unlink()
_write_db(db, backup(manga_count=50, chapters_each=2, categories=0))
main(_args(tmp_path, db))
err = capsys.readouterr().err
assert "COLLAPSED" in err
assert "categories went from 6 to 0" in err
def test_report_handles_a_collapse_without_a_baseline(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Defensive branch: ``compare`` never returns this, but ``_report`` must cope.
A COLLAPSED verdict always carries the baseline it was measured against, so
this shape is unreachable through ``main``. It is covered directly so the
guard cannot crash while reporting the one thing it exists to report.
"""
from python_pkg.syncyomi_guard.__main__ import _report
from python_pkg.syncyomi_guard.payload import PayloadStats
from python_pkg.syncyomi_guard.verdict import Status, Verdict
stats = PayloadStats(
size_bytes=1,
manga=0,
categories=0,
chapters=0,
sources=0,
)
_report(
Verdict(
status=Status.COLLAPSED,
reason="synthetic",
current=stats,
previous=None,
),
)
err = capsys.readouterr().err
assert "COLLAPSED" in err
assert "previous:" not in err
def test_notification_is_attempted_on_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failure should reach the desktop, but must not depend on it."""
calls: list[list[str]] = []
monkeypatch.setattr(
"python_pkg.syncyomi_guard.__main__.shutil.which",
lambda _: "/usr/bin/notify-send",
)
monkeypatch.setattr(
"python_pkg.syncyomi_guard.__main__.subprocess.run",
lambda cmd, **_: calls.append(cmd),
)
assert main(_args(tmp_path, tmp_path / "absent.db")) == 1
assert calls
assert "notify-send" in calls[0][0]
def test_notification_failure_does_not_mask_the_verdict(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def explode(*_: object, **__: object) -> None:
msg = "no session bus"
raise OSError(msg)
monkeypatch.setattr(
"python_pkg.syncyomi_guard.__main__.shutil.which",
lambda _: "/usr/bin/notify-send",
)
monkeypatch.setattr("python_pkg.syncyomi_guard.__main__.subprocess.run", explode)
assert main(_args(tmp_path, tmp_path / "absent.db")) == 1