Files
2026-07-13 13:12:33 +08:00

1116 lines
40 KiB
Python

from __future__ import annotations
import builtins
import contextlib
import getpass
import json
import os
import sqlite3
import stat
import subprocess
import sys
import threading
import warnings
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from yoyo import exceptions
from yoyo import migrations as yoyo_migrations
from opensquilla.persistence import migrator
from opensquilla.persistence.migrator import apply_pending
_REPO_MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
def test_apply_pending_registers_python312_datetime_adapter(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
(migrations_dir / "V001__demo.py").write_text(
"from yoyo import step\n"
"__depends__ = set()\n"
"steps = [step('CREATE TABLE demo (id INTEGER PRIMARY KEY)')]\n",
encoding="utf-8",
)
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
applied = apply_pending(str(tmp_path / "demo.sqlite"), migrations_dir)
assert applied == ["V001__demo"]
def test_apply_pending_forces_utf8_when_yoyo_loads_python_migrations(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A real migration with non-ASCII text loads even under a legacy locale."""
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
(migrations_dir / "V001__utf8.py").write_text(
'"""Migration docstring with non-ASCII text — 界."""\n'
"from yoyo import step\n"
"__depends__ = set()\n"
"steps = [step('CREATE TABLE utf8_demo (id INTEGER PRIMARY KEY)')]\n",
encoding="utf-8",
)
real_open = builtins.open
encodings: list[object] = []
def legacy_locale_open(file, mode="r", *args, **kwargs): # type: ignore[no-untyped-def]
if "b" not in mode and "encoding" not in kwargs:
raise UnicodeDecodeError("gbk", b"\x80", 0, 1, "fake legacy locale")
encodings.append(kwargs.get("encoding"))
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(builtins, "open", legacy_locale_open)
applied = apply_pending(str(tmp_path / "demo.sqlite"), migrations_dir)
assert applied == ["V001__utf8"]
assert "utf-8" in encodings
# The shim is removed from yoyo's module namespace afterwards.
assert not hasattr(yoyo_migrations, "open")
def test_yoyo_utf8_open_patches_only_the_yoyo_module() -> None:
before = builtins.open
assert not hasattr(yoyo_migrations, "open")
with migrator._yoyo_utf8_open():
# Process-wide builtins are never touched; only yoyo.migrations sees
# the UTF-8 shim as a module global shadowing the builtin.
assert builtins.open is before
assert yoyo_migrations.open is not before
assert builtins.open is before
assert not hasattr(yoyo_migrations, "open")
def _create_yoyo_lock_table(db_path: Path) -> None:
with sqlite3.connect(db_path) as connection:
connection.execute(
"CREATE TABLE yoyo_lock (locked INTEGER PRIMARY KEY, ctime TIMESTAMP, pid INTEGER)"
)
def _create_yoyo_lock(db_path: Path, pid: int) -> None:
_create_yoyo_lock_table(db_path)
with sqlite3.connect(db_path) as connection:
connection.execute(
"INSERT INTO yoyo_lock (locked, ctime, pid) VALUES (1, CURRENT_TIMESTAMP, ?)",
(pid,),
)
def _yoyo_lock_pids(db_path: Path) -> list[int]:
with sqlite3.connect(db_path) as connection:
return [row[0] for row in connection.execute("SELECT pid FROM yoyo_lock")]
class _RaisingLock:
def __init__(self, message: str = "Process 424242 has locked this database") -> None:
self._message = message
def __enter__(self) -> None:
raise exceptions.LockTimeout(self._message)
def __exit__(self, *_args) -> None: # type: ignore[no-untyped-def]
return None
class _FakeConnection:
"""Match yoyo's real backend surface: cleanup goes through .connection."""
def __init__(self, closed: list[str]) -> None:
self._closed = closed
def close(self) -> None:
self._closed.append("closed")
class _FakeBackend:
def __init__(self, lock_context: object, applied: list[list[str]], closed: list[str]) -> None:
self._lock_context = lock_context
self._applied = applied
self.connection = _FakeConnection(closed)
def to_apply(self, migrations): # type: ignore[no-untyped-def]
return migrations
def lock(self):
return self._lock_context
def apply_migrations(self, pending) -> None: # type: ignore[no-untyped-def]
self._applied.append([item.id for item in pending])
def test_apply_pending_clears_dead_yoyo_lock_and_retries_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "sessions.db"
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
_create_yoyo_lock(db_path, 424242)
applied: list[list[str]] = []
closed: list[str] = []
backends = [
_FakeBackend(_RaisingLock(), applied, closed),
_FakeBackend(contextlib.nullcontext(), applied, closed),
]
monkeypatch.setattr(migrator, "_is_pid_alive", lambda _pid: False)
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
monkeypatch.setattr(migrator, "_read_applied_migration_ids", lambda _path: {"V001"})
result = apply_pending(str(db_path), migrations_dir)
assert result == ["V001"]
assert applied == [["V001"]]
assert closed == ["closed", "closed"]
assert _yoyo_lock_pids(db_path) == []
assert backends == []
def test_apply_pending_recovers_stale_yoyo_lock_with_real_backend(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
(migrations_dir / "V001__real_backend.py").write_text(
"from yoyo import step\n"
"__depends__ = set()\n"
"steps = [step('CREATE TABLE real_backend_demo (id INTEGER PRIMARY KEY)')]\n",
encoding="utf-8",
)
db_path = tmp_path / "sessions.db"
_create_yoyo_lock(db_path, 424242)
real_get_backend = migrator.get_backend
def get_fast_lock_backend(url: str):
backend = real_get_backend(url)
real_lock = backend.lock
def fast_lock(timeout: float = 10):
return real_lock(timeout=0.01)
backend.lock = fast_lock # type: ignore[method-assign]
return backend
monkeypatch.setattr(migrator, "_is_pid_alive", lambda _pid: False)
monkeypatch.setattr(migrator, "get_backend", get_fast_lock_backend)
result = apply_pending(str(db_path), migrations_dir)
assert result == ["V001__real_backend"]
assert _yoyo_lock_pids(db_path) == []
with sqlite3.connect(db_path) as connection:
rows = connection.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='real_backend_demo'"
).fetchall()
assert rows == [("real_backend_demo",)]
def test_apply_pending_does_not_clear_live_yoyo_lock(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "sessions.db"
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
_create_yoyo_lock(db_path, 12345)
applied: list[list[str]] = []
closed: list[str] = []
backends = [
_FakeBackend(_RaisingLock("Process 12345 has locked this database"), applied, closed)
]
monkeypatch.setattr(migrator, "_is_pid_alive", lambda pid: pid == 12345)
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
with pytest.raises(exceptions.LockTimeout, match="live process pid=12345") as excinfo:
apply_pending(str(db_path), migrations_dir)
# The message carries concrete remediation for operators.
assert "Stop the other OpenSquilla process" in str(excinfo.value)
assert "break-lock" in str(excinfo.value)
assert applied == []
assert closed == ["closed"]
assert _yoyo_lock_pids(db_path) == [12345]
assert backends == []
def test_apply_pending_does_not_loop_when_stale_lock_retry_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "sessions.db"
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
_create_yoyo_lock(db_path, 424242)
applied: list[list[str]] = []
closed: list[str] = []
backends = [
_FakeBackend(_RaisingLock(), applied, closed),
_FakeBackend(_RaisingLock("Database locked after cleanup"), applied, closed),
]
monkeypatch.setattr(migrator, "_is_pid_alive", lambda _pid: False)
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
with pytest.raises(exceptions.LockTimeout, match="Database locked after cleanup"):
apply_pending(str(db_path), migrations_dir)
assert applied == []
assert closed == ["closed", "closed"]
assert _yoyo_lock_pids(db_path) == []
assert backends == []
def test_apply_pending_retries_when_lock_table_is_empty(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An empty lock table means the holder released it — retry, don't brick boot."""
db_path = tmp_path / "sessions.db"
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
_create_yoyo_lock_table(db_path) # table exists, no lock row
applied: list[list[str]] = []
closed: list[str] = []
backends = [
_FakeBackend(_RaisingLock("Database locked"), applied, closed),
_FakeBackend(contextlib.nullcontext(), applied, closed),
]
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
monkeypatch.setattr(migrator, "_read_applied_migration_ids", lambda _path: {"V001"})
result = apply_pending(str(db_path), migrations_dir)
assert result == ["V001"]
assert applied == [["V001"]]
assert backends == []
def test_recover_stale_lock_retries_on_empty_lock_table(tmp_path: Path) -> None:
db_path = tmp_path / "sessions.db"
_create_yoyo_lock_table(db_path)
assert migrator._recover_stale_yoyo_lock(str(db_path), exceptions.LockTimeout("t")) is True
def test_recover_stale_lock_propagates_when_lock_table_unreadable(tmp_path: Path) -> None:
db_path = tmp_path / "sessions.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE unrelated (id INTEGER PRIMARY KEY)")
assert migrator._recover_stale_yoyo_lock(str(db_path), exceptions.LockTimeout("t")) is False
def test_clear_yoyo_lock_deletes_only_verified_dead_pids(tmp_path: Path) -> None:
db_path = tmp_path / "sessions.db"
_create_yoyo_lock(db_path, 55555)
# A pid that is not in the table: nothing is deleted, call still succeeds.
assert migrator._clear_yoyo_lock(db_path, [424242]) is True
assert _yoyo_lock_pids(db_path) == [55555]
assert migrator._clear_yoyo_lock(db_path, [55555]) is True
assert _yoyo_lock_pids(db_path) == []
def test_recover_stale_lock_never_erases_lock_reacquired_by_live_process(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""TOCTOU regression: the delete is scoped to the pids verified dead."""
db_path = tmp_path / "sessions.db"
_create_yoyo_lock(db_path, 55555) # a live process now owns the lock
# Simulate the race window: inspection observed a dead pid just before
# pid 55555 cleared that row and reacquired the lock.
monkeypatch.setattr(migrator, "_read_yoyo_lock_pids", lambda _path: [424242])
monkeypatch.setattr(migrator, "_is_pid_alive", lambda _pid: False)
assert migrator._recover_stale_yoyo_lock(str(db_path), exceptions.LockTimeout("t")) is True
# The live owner's lock row survives; the retry serializes on yoyo's lock.
assert _yoyo_lock_pids(db_path) == [55555]
def test_sqlite_path_from_db_url_rejects_unsupported_database_urls() -> None:
assert migrator._sqlite_path_from_db_url(":memory:") is None
assert migrator._sqlite_path_from_db_url("sqlite:///:memory:") is None
assert migrator._sqlite_path_from_db_url("postgresql://example/db") is None
def _write_demo_migration(migrations_dir: Path, name: str = "V001__demo") -> None:
migrations_dir.mkdir(exist_ok=True)
(migrations_dir / f"{name}.py").write_text(
"from yoyo import step\n"
"__depends__ = set()\n"
f"steps = [step('CREATE TABLE {name.lower()} (id INTEGER PRIMARY KEY)')]\n",
encoding="utf-8",
)
def _record_applied_migration(db_path: Path, migration_id: str) -> None:
"""Simulate a newer build having applied an extra migration."""
with sqlite3.connect(db_path) as connection:
connection.execute(
"INSERT INTO _yoyo_migration (migration_hash, migration_id, applied_at_utc) "
"VALUES (?, ?, CURRENT_TIMESTAMP)",
(f"hash-{migration_id}", migration_id),
)
def test_assert_schema_not_ahead_noop_for_fresh_and_memory_db(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir)
# Non-existent file, in-memory, and never-migrated DBs must all pass quietly.
migrator.assert_schema_not_ahead(str(tmp_path / "missing.db"), migrations_dir)
migrator.assert_schema_not_ahead(":memory:", migrations_dir)
def test_apply_pending_is_idempotent_when_db_matches_code(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir)
db_path = tmp_path / "sessions.db"
first = apply_pending(str(db_path), migrations_dir)
second = apply_pending(str(db_path), migrations_dir)
assert first == ["V001__demo"]
assert second == [] # no SchemaAheadError — every applied id is known
def test_apply_pending_raises_when_database_is_ahead_of_code(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir)
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
# A newer build applied V999 that this code's migration set does not contain.
_record_applied_migration(db_path, "V999__from_the_future")
with pytest.raises(migrator.SchemaAheadError, match="V999__from_the_future"):
apply_pending(str(db_path), migrations_dir)
def test_read_applied_migration_ids_handles_missing_ledger(tmp_path: Path) -> None:
db_path = tmp_path / "empty.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE unrelated (id INTEGER PRIMARY KEY)")
# No yoyo ledger table → empty set (not None, not an error).
assert migrator._read_applied_migration_ids(db_path) == set()
# ---------------------------------------------------------------------------
# Pending plan must be computed inside yoyo's lock (concurrency regressions)
# ---------------------------------------------------------------------------
def test_apply_pending_process_lock_precedes_sqlite_open(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from opensquilla.recovery.errors import ProfileLockBusyError
from opensquilla.recovery.locking import ProfileOperationLock
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir)
db_path = tmp_path / "sessions.db"
acquired = threading.Event()
release = threading.Event()
def hold_database_migration_lock() -> None:
with ProfileOperationLock(db_path):
acquired.set()
release.wait(timeout=10)
holder = threading.Thread(target=hold_database_migration_lock)
holder.start()
assert acquired.wait(timeout=5)
monkeypatch.setattr(migrator, "_MIGRATION_PROCESS_LOCK_TIMEOUT_SECONDS", 0.0)
try:
with pytest.raises(ProfileLockBusyError):
apply_pending(str(db_path), migrations_dir)
assert not db_path.exists(), "SQLite must not open before the external lock"
finally:
release.set()
holder.join(timeout=5)
assert not holder.is_alive()
def test_apply_pending_recomputes_pending_under_lock_after_losing_race(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A pre-lock pending plan must be discarded once the lock is acquired.
Simulates the exact interleaving of the production race deterministically:
a competing migrator applies the full chain (including a recreate-and-copy
migration) and seeds data while this caller is waiting for yoyo's lock.
The caller must recompute the plan under the lock and apply nothing; a
stale plan would re-run V001/V002 and either crash or drop the
later-added column.
"""
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
(migrations_dir / "V001__base.py").write_text(
"from yoyo import step\n"
"__depends__ = set()\n"
"steps = [step('CREATE TABLE runs (id INTEGER PRIMARY KEY, name TEXT)')]\n",
encoding="utf-8",
)
(migrations_dir / "V002__recreate.py").write_text(
"from yoyo import step\n"
"__depends__ = {'V001__base'}\n"
"steps = [\n"
" step('CREATE TABLE runs_new "
"(id INTEGER PRIMARY KEY, name TEXT, usage_json TEXT)'),\n"
" step('INSERT INTO runs_new (id, name) SELECT id, name FROM runs'),\n"
" step('DROP TABLE runs'),\n"
" step('ALTER TABLE runs_new RENAME TO runs'),\n"
"]\n",
encoding="utf-8",
)
db_path = tmp_path / "sessions.db"
real_get_backend = migrator.get_backend
raced: list[bool] = []
# Pre-create yoyo's internal schema so the only lock acquisition left in
# the run below is the migration lock itself (yoyo otherwise takes the
# lock once during its internal-schema upgrade, which would fire the race
# hook before to_apply in either ordering and mask the regression).
seed_backend = real_get_backend(migrator._to_yoyo_url(str(db_path)))
try:
with seed_backend.lock():
seed_backend.get_applied_migration_hashes()
finally:
seed_backend.connection.close()
def competing_migrator_wins(yoyo_url: str) -> None:
other = real_get_backend(yoyo_url)
try:
migrations = migrator.read_migrations(str(migrations_dir))
with other.lock():
other.apply_migrations(other.to_apply(migrations))
finally:
other.connection.close()
with sqlite3.connect(db_path) as connection:
connection.execute(
"INSERT INTO runs (id, name, usage_json) VALUES (1, 'keep', '{}')"
)
def racing_get_backend(yoyo_url: str):
backend = real_get_backend(yoyo_url)
real_lock = backend.lock
@contextlib.contextmanager
def racing_lock(timeout: float = 10):
if not raced:
raced.append(True)
competing_migrator_wins(yoyo_url)
with real_lock(timeout=timeout):
yield
backend.lock = racing_lock # type: ignore[method-assign]
return backend
monkeypatch.setattr(migrator, "get_backend", racing_get_backend)
applied = apply_pending(str(db_path), migrations_dir)
assert applied == [] # the plan was recomputed under the lock
assert raced == [True]
with sqlite3.connect(db_path) as connection:
ledger = sorted(
row[0]
for row in connection.execute("SELECT migration_id FROM _yoyo_migration")
)
rows = connection.execute("SELECT id, name, usage_json FROM runs").fetchall()
assert ledger == ["V001__base", "V002__recreate"] # each applied exactly once
assert rows == [(1, "keep", "{}")] # seeded data survived; column intact
_CONCURRENT_WORKER = """
import json
import sys
import time
from pathlib import Path
from opensquilla.persistence.migrator import apply_pending
db_url, migrations_dir, go_file = sys.argv[1], sys.argv[2], sys.argv[3]
print("READY", flush=True)
deadline = time.monotonic() + 30
while not Path(go_file).exists():
if time.monotonic() > deadline:
raise SystemExit("no go signal")
time.sleep(0.01)
applied = apply_pending(db_url, Path(migrations_dir))
print("APPLIED:" + json.dumps(applied), flush=True)
"""
def test_apply_pending_two_concurrent_callers_real_migrations(tmp_path: Path) -> None:
"""Two boot-style processes race on one DB using the repo migration chain."""
from opensquilla.recovery.locking import ProfileOperationLock
assert _REPO_MIGRATIONS_DIR.is_dir()
expected = sorted(entry.stem for entry in _REPO_MIGRATIONS_DIR.glob("V*.py"))
assert expected
db_path = tmp_path / "sessions.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE user_data (id INTEGER PRIMARY KEY, note TEXT)")
connection.execute("INSERT INTO user_data VALUES (1, 'precious')")
# Runtime bootstrap has already established the external lock authority
# before migrations start. Seed the same authority here so this test
# isolates simultaneous migration ownership rather than also racing the
# first creation of the OS user-state directory tree.
with ProfileOperationLock(db_path):
pass
go_file = tmp_path / "go"
workers = [
subprocess.Popen(
[
sys.executable,
"-c",
_CONCURRENT_WORKER,
str(db_path),
str(_REPO_MIGRATIONS_DIR),
str(go_file),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(tmp_path),
)
for _ in range(2)
]
try:
for worker in workers:
assert worker.stdout is not None
assert worker.stdout.readline().strip() == "READY"
go_file.touch() # release both workers as close to simultaneously as possible
applied_per_worker: list[list[str]] = []
for worker in workers:
stdout, stderr = worker.communicate(timeout=120)
assert worker.returncode == 0, stderr
payload = [line for line in stdout.splitlines() if line.startswith("APPLIED:")]
assert payload, stdout
applied_per_worker.append(json.loads(payload[0].removeprefix("APPLIED:")))
finally:
for worker in workers:
if worker.poll() is None:
worker.kill()
combined = sorted(applied_per_worker[0] + applied_per_worker[1])
assert combined == expected # every id applied exactly once across the callers
with sqlite3.connect(db_path) as connection:
ledger = sorted(
row[0]
for row in connection.execute("SELECT migration_id FROM _yoyo_migration")
)
rows = connection.execute("SELECT note FROM user_data").fetchall()
assert ledger == expected
assert rows == [("precious",)]
# ---------------------------------------------------------------------------
# Migration discovery: glob metacharacters and fail-loud empty discovery
# ---------------------------------------------------------------------------
def test_apply_pending_handles_glob_metacharacters_in_paths(tmp_path: Path) -> None:
weird = tmp_path / "weird [x] dir"
weird.mkdir()
migrations_dir = weird / "migrations"
_write_demo_migration(migrations_dir, name="V001__globsafe")
db_path = weird / "sessions.db"
first = apply_pending(str(db_path), migrations_dir)
second = apply_pending(str(db_path), migrations_dir)
assert first == ["V001__globsafe"]
assert second == [] # no spurious SchemaAheadError from empty discovery
with sqlite3.connect(db_path) as connection:
ledger = [
row[0]
for row in connection.execute("SELECT migration_id FROM _yoyo_migration")
]
assert ledger == ["V001__globsafe"]
def test_apply_pending_fails_loud_when_discovery_finds_nothing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir)
monkeypatch.setattr(migrator, "read_migrations", lambda _source: [])
with pytest.raises(RuntimeError, match="zero migrations") as excinfo:
apply_pending(str(tmp_path / "sessions.db"), migrations_dir)
assert str(migrations_dir) in str(excinfo.value)
# ---------------------------------------------------------------------------
# URL normalisation: yoyo must migrate the exact file the guards inspect
# ---------------------------------------------------------------------------
def _expected_yoyo_url(raw: str) -> str:
return "sqlite:///" + quote(Path(raw).resolve().as_posix(), safe="/:")
def test_to_yoyo_url_percent_encodes_url_metacharacters() -> None:
assert migrator._to_yoyo_url("/tmp/a#b/demo.db") == _expected_yoyo_url(
"/tmp/a#b/demo.db"
)
assert migrator._to_yoyo_url("/tmp/a?b/demo.db") == _expected_yoyo_url(
"/tmp/a?b/demo.db"
)
# A literal '%41' must not decay into 'A' inside sqlite's URI parser.
assert migrator._to_yoyo_url("/tmp/pct%41/demo.db") == _expected_yoyo_url(
"/tmp/pct%41/demo.db"
)
def test_to_yoyo_url_round_trips_through_inspection_helper() -> None:
for raw in ("/tmp/a#b/demo.db", "/tmp/pct%41/demo.db", "/tmp/weird [x]/demo.db"):
assert migrator._sqlite_path_from_db_url(migrator._to_yoyo_url(raw)) == Path(raw).resolve()
@pytest.mark.parametrize("dirname", ["note#1", "pct%41dir"])
def test_apply_pending_migrates_the_exact_file_the_guard_inspects(
tmp_path: Path, dirname: str
) -> None:
base = tmp_path / dirname
base.mkdir()
migrations_dir = base / "migrations"
_write_demo_migration(migrations_dir, name="V001__exactfile")
db_path = base / "demo.db"
first = apply_pending(str(db_path), migrations_dir)
second = apply_pending(str(db_path), migrations_dir)
assert first == ["V001__exactfile"]
assert second == [] # the downgrade guard inspected the same file yoyo migrated
assert db_path.exists()
with sqlite3.connect(db_path) as connection:
ledger = [
row[0]
for row in connection.execute("SELECT migration_id FROM _yoyo_migration")
]
assert ledger == ["V001__exactfile"]
if "%41" in dirname:
decoded_variant = tmp_path / dirname.replace("%41", "A")
assert not decoded_variant.exists()
# ---------------------------------------------------------------------------
# Pre-migration backups
# ---------------------------------------------------------------------------
def test_apply_pending_skips_backup_on_fresh_install(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__fresh")
apply_pending(str(tmp_path / "sessions.db"), migrations_dir)
assert list(tmp_path.glob("*.bak")) == []
def test_apply_pending_backs_up_existing_db_before_applying(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__first")
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE user_data (id INTEGER PRIMARY KEY, note TEXT)")
connection.execute("INSERT INTO user_data VALUES (1, 'precious')")
_write_demo_migration(migrations_dir, name="V002__second")
applied = apply_pending(str(db_path), migrations_dir)
assert applied == ["V002__second"]
backup_path = tmp_path / "sessions.db.pre-V002__second.bak"
assert backup_path.exists()
with sqlite3.connect(backup_path) as connection:
rows = connection.execute("SELECT note FROM user_data").fetchall()
tables = {
row[0]
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
assert rows == [("precious",)]
assert "v001__first" in tables # pre-apply state is present
assert "v002__second" not in tables # snapshot taken BEFORE applying V002
if os.name != "nt":
assert stat.S_IMODE(backup_path.stat().st_mode) == 0o600
def test_apply_pending_rotates_backups_keeping_two_most_recent(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__m1")
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
for number in (2, 3, 4):
_write_demo_migration(migrations_dir, name=f"V00{number}__m{number}")
apply_pending(str(db_path), migrations_dir)
backups = sorted(entry.name for entry in tmp_path.glob("sessions.db.pre-*.bak"))
assert backups == [
"sessions.db.pre-V003__m3.bak",
"sessions.db.pre-V004__m4.bak",
]
def test_apply_pending_continues_when_backup_fails(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__first")
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
# A directory squatting on the backup path makes the snapshot fail.
blocker = tmp_path / "sessions.db.pre-V002__second.bak"
blocker.mkdir()
_write_demo_migration(migrations_dir, name="V002__second")
applied = apply_pending(str(db_path), migrations_dir)
assert applied == ["V002__second"] # backup failure never aborts boot
assert blocker.is_dir()
# ---------------------------------------------------------------------------
# Database file permissions
# ---------------------------------------------------------------------------
@pytest.mark.skipif(os.name == "nt", reason="POSIX file permissions only")
def test_apply_pending_restricts_database_file_permissions(tmp_path: Path) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__perms")
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
assert stat.S_IMODE(db_path.stat().st_mode) == 0o600
# ---------------------------------------------------------------------------
# Ledger self-check after apply
# ---------------------------------------------------------------------------
def test_apply_pending_fails_closed_when_ledger_missing_applied_ids(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "sessions.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE unrelated (id INTEGER PRIMARY KEY)")
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
applied: list[list[str]] = []
closed: list[str] = []
backends = [_FakeBackend(contextlib.nullcontext(), applied, closed)]
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
# The fake backend "applies" V001 but the real ledger never records it.
with pytest.raises(RuntimeError, match="ledger"):
apply_pending(str(db_path), migrations_dir)
def test_apply_pending_tolerates_uninspectable_ledger_after_apply(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "sessions.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE unrelated (id INTEGER PRIMARY KEY)")
migrations_dir = tmp_path / "migrations"
migrations_dir.mkdir()
applied: list[list[str]] = []
closed: list[str] = []
backends = [_FakeBackend(contextlib.nullcontext(), applied, closed)]
monkeypatch.setattr(migrator, "read_migrations", lambda _path: [SimpleNamespace(id="V001")])
monkeypatch.setattr(migrator, "get_backend", lambda _url: backends.pop(0))
monkeypatch.setattr(migrator, "_read_applied_migration_ids", lambda _path: None)
assert apply_pending(str(db_path), migrations_dir) == ["V001"]
# ---------------------------------------------------------------------------
# Audit username fallback
# ---------------------------------------------------------------------------
def test_apply_pending_audit_never_depends_on_fqdn_resolution(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__local_audit")
db_path = tmp_path / "sessions.db"
monkeypatch.setattr(
migrator.socket,
"getfqdn",
lambda *_args, **_kwargs: pytest.fail("migration audit must not resolve DNS"),
)
monkeypatch.setattr(migrator.socket, "gethostname", lambda: "synthetic-local-host")
applied = apply_pending(str(db_path), migrations_dir)
assert applied == ["V001__local_audit"]
with sqlite3.connect(db_path) as connection:
audit_rows = connection.execute(
"SELECT migration_id, username, hostname, operation FROM _yoyo_log"
).fetchall()
assert audit_rows == [
("V001__local_audit", getpass.getuser(), "synthetic-local-host", "apply")
]
@pytest.mark.skipif(os.name == "nt", reason="pwd module is POSIX only")
def test_apply_pending_survives_unresolvable_username(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import pwd
for var in ("LOGNAME", "USER", "LNAME", "USERNAME"):
monkeypatch.delenv(var, raising=False)
def no_passwd_entry(_uid: int):
raise KeyError("no passwd entry for uid")
monkeypatch.setattr(pwd, "getpwuid", no_passwd_entry)
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__nouser")
applied = apply_pending(str(tmp_path / "sessions.db"), migrations_dir)
assert applied == ["V001__nouser"]
assert getpass.getuser() == "opensquilla"
# ---------------------------------------------------------------------------
# Process liveness probes
# ---------------------------------------------------------------------------
def test_is_pid_alive_true_for_own_process() -> None:
assert migrator._is_pid_alive(os.getpid()) is True
def test_is_pid_alive_false_for_reaped_child() -> None:
child = subprocess.Popen([sys.executable, "-c", "pass"])
child.wait(timeout=30)
assert migrator._is_pid_alive(child.pid) is False
def test_is_pid_alive_rejects_nonpositive_pids() -> None:
assert migrator._is_pid_alive(0) is False
assert migrator._is_pid_alive(-5) is False
def _fake_ctypes(
open_result: int,
last_error: int,
calls: dict[str, object],
*,
exit_code: int = 259,
get_exit_code_result: int = 1,
) -> SimpleNamespace:
class FakeUInt32:
def __init__(self, value: int = 0) -> None:
self.value = value
def open_process(access: object, inherit: object, pid: object) -> int:
calls["open_process"] = (access, inherit, pid)
return open_result
def get_exit_code_process(handle: object, code_ref: FakeUInt32) -> int:
calls["get_exit_code_process"] = handle
code_ref.value = exit_code
return get_exit_code_result
def close_handle(handle: object) -> int:
calls["closed_handle"] = handle
return 1
def win_dll(name: str, use_last_error: bool = False) -> SimpleNamespace:
calls["win_dll"] = (name, use_last_error)
return SimpleNamespace(
OpenProcess=open_process,
GetExitCodeProcess=get_exit_code_process,
CloseHandle=close_handle,
)
def get_last_error() -> int:
calls["read_last_error"] = True
return last_error
def byref(value: FakeUInt32) -> FakeUInt32:
calls["byref"] = value
return value
return SimpleNamespace(
WinDLL=win_dll,
get_last_error=get_last_error,
byref=byref,
c_void_p=object(),
c_uint32=FakeUInt32,
c_int=object(),
)
def test_is_pid_alive_windows_open_handle_means_alive() -> None:
calls: dict[str, object] = {}
fake = _fake_ctypes(open_result=1234, last_error=0, calls=calls)
assert migrator._is_pid_alive_windows(4321, ctypes_module=fake) is True
assert calls["win_dll"] == ("kernel32", True) # use_last_error must be set
assert calls["get_exit_code_process"] == 1234
assert calls["closed_handle"] == 1234
def test_is_pid_alive_windows_exited_handle_means_dead() -> None:
calls: dict[str, object] = {}
fake = _fake_ctypes(open_result=1234, last_error=0, calls=calls, exit_code=0)
assert migrator._is_pid_alive_windows(4321, ctypes_module=fake) is False
assert calls["get_exit_code_process"] == 1234
assert calls["closed_handle"] == 1234
def test_is_pid_alive_windows_access_denied_means_alive() -> None:
calls: dict[str, object] = {}
fake = _fake_ctypes(open_result=0, last_error=5, calls=calls)
assert migrator._is_pid_alive_windows(4321, ctypes_module=fake) is True
assert calls["read_last_error"] is True
assert "closed_handle" not in calls
def test_is_pid_alive_windows_other_error_means_dead() -> None:
calls: dict[str, object] = {}
fake = _fake_ctypes(open_result=0, last_error=87, calls=calls)
assert migrator._is_pid_alive_windows(4321, ctypes_module=fake) is False
def test_is_pid_alive_dispatches_to_windows_probe_on_nt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
probed: list[int] = []
monkeypatch.setattr(migrator.os, "name", "nt")
monkeypatch.setattr(migrator, "_is_pid_alive_windows", lambda pid: probed.append(pid) or True)
assert migrator._is_pid_alive(777) is True
assert probed == [777]
@pytest.mark.skipif(os.name == "nt", reason="POSIX file permissions only")
def test_apply_pending_preserves_existing_database_permissions(tmp_path: Path) -> None:
"""Only databases created by this boot are tightened; deliberate operator
permissions on pre-existing files must survive every subsequent boot."""
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__perms_existing")
db_path = tmp_path / "sessions.db"
sqlite3.connect(db_path).close()
os.chmod(db_path, 0o644)
apply_pending(str(db_path), migrations_dir)
assert stat.S_IMODE(db_path.stat().st_mode) == 0o644
@pytest.mark.skipif(os.name == "nt", reason="chmod failure semantics POSIX only")
def test_backup_survives_chmod_failure_and_still_rotates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A filesystem that rejects chmod (CIFS, root-squashed NFS) must not
relabel a good snapshot as failed nor skip rotation."""
migrations_dir = tmp_path / "migrations"
_write_demo_migration(migrations_dir, name="V001__first")
db_path = tmp_path / "sessions.db"
apply_pending(str(db_path), migrations_dir)
# Pre-seed stale backups so rotation has something to prune.
for stale in ("sessions.db.pre-V000__a.bak", "sessions.db.pre-V000__b.bak"):
(tmp_path / stale).write_bytes(b"stale")
def _chmod_unsupported(path: object, mode: int) -> None:
raise OSError("chmod unsupported on this filesystem")
monkeypatch.setattr("opensquilla.persistence.migrator.os.chmod", _chmod_unsupported)
_write_demo_migration(migrations_dir, name="V002__second")
applied = apply_pending(str(db_path), migrations_dir)
assert applied == ["V002__second"]
backups = sorted(entry.name for entry in tmp_path.glob("sessions.db.pre-*.bak"))
# The fresh snapshot exists and rotation still enforced the keep bound.
assert "sessions.db.pre-V002__second.bak" in backups
assert len(backups) == 2
def test_read_applied_migration_ids_structural_failure_reads_as_empty(tmp_path: Path) -> None:
"""A ledger table whose migration_id column cannot be read is a schema-
tracking failure: report set() so the post-apply verifier fails closed
instead of tolerating a permanently blind downgrade guard."""
db_path = tmp_path / "structural.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE _yoyo_migration (id TEXT PRIMARY KEY)") # no migration_id
conn.commit()
conn.close()
assert migrator._read_applied_migration_ids(db_path) == set()
def test_read_applied_migration_ids_lock_contention_reads_as_uninspectable(
tmp_path: Path,
) -> None:
"""Transient 'database is locked' must map to None (tolerated), not to an
empty-but-readable ledger that would fail a healthy boot closed."""
db_path = tmp_path / "locked.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE _yoyo_migration (migration_id TEXT PRIMARY KEY)")
conn.commit()
holder = sqlite3.connect(db_path)
holder.execute("BEGIN EXCLUSIVE")
try:
assert migrator._read_applied_migration_ids(db_path) is None
finally:
holder.rollback()
holder.close()
conn.close()