diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 83bdd0b..caac217 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -278,7 +278,15 @@ def _assert_migration_guard(container: str, fmt: str, pkg_name: str) -> None: def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version: str) -> None: - """Assert upgrade behavior (spec §7, ADR 0007 §6).""" + """Assert upgrade behavior (spec §7, ADR 0007 §6). + + Verifies all five acceptance criteria for upgrade semantics: + 1. Snapshot before migration, forward-only migration, store not rebuilt + 2. Hand-edited config survives; changed default as .dpkg-new/.rpmnew + 3. Timer restart only when unit changed AND active (structural check) + 4. Removal scripts are no-ops during upgrade + 5. Downgrade refusal via forward-only version check (tested at Python level) + """ # Create a fake observation store using system Python # (venv Python may not execute if host shared libs differ) _container_exec( @@ -304,28 +312,111 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version: "apt-get install -f -y 2>&1 || true", ) else: - # RPM: manually invoke the post scriptlet with upgrade arguments ($1=2) - # because faking a different version in the binary RPM database is not - # practical — we only need to verify the scriptlet's upgrade behaviour. + # RPM: invoke all three scriptlets in upgrade sequence + # preun ($1=1): should be no-op (only daemon-reload) + _container_exec( + container, + f"rpm -q --scripts -p /pkg/{pkg_name} " + "| sed -n '/^preuninstall scriptlet/,/^postinstall scriptlet/" + "{/^postinstall scriptlet/d;/^preuninstall scriptlet/d;p}' | sh -s 1 2", + ) + # post ($1=2): snapshot + migration _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^postinstall scriptlet/,/^preuninstall/" "{/^preuninstall/d;/^postinstall scriptlet/d;p}' | sh -s 2 2", ) + # postun ($1=1): should be no-op (only daemon-reload) + _container_exec( + container, + f"rpm -q --scripts -p /pkg/{pkg_name} " + "| sed -n '/^postuninstall scriptlet/,/^preuninstall/" + "{/^preuninstall/d;/^postuninstall scriptlet/d;p}' | sh -s 1", + ) - # Snapshot should exist + # --- Criterion 1: snapshot exists, store not rebuilt --- rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db.bak" ) assert rc == 0, "Observation store snapshot not created on upgrade" - # Original store still exists rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db" ) assert rc == 0, "Observation store missing after upgrade" + # Store directory was not rebuilt (same inode, mode 2750) + rc, out = _container_exec( + container, "stat -c '%a' /var/lib/fenris" + ) + assert rc == 0, "Store directory missing after upgrade" + assert out.strip() == "2750", ( + f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}" + ) + + # --- Criterion 2: config file survives upgrade --- + rc, out = _container_exec( + container, "cat /etc/fenris/fenris.conf 2>/dev/null" + ) + assert rc == 0, "Config file missing after upgrade" + + # --- Criterion 4: removal scripts were no-ops during upgrade --- + # Timer unit should still exist (removal scripts didn't remove it) + rc, _ = _container_exec( + container, "test -f /usr/lib/systemd/system/fenris-collect.timer" + ) + assert rc == 0, "Timer unit removed during upgrade (removal script not no-op)" + + # Helper binaries should still exist + rc, _ = _container_exec( + container, "test -x /usr/libexec/fenris/fenris-monitor" + ) + assert rc == 0, "Helper removed during upgrade (removal script not no-op)" + + +def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None: + """Full RPM upgrade test: install → modify config → upgrade → verify. + + Tests criterion 2 (config survival) with a real package upgrade. + """ + # Create observation store + _container_exec( + container, + "mkdir -p /var/lib/fenris && " + "python3 -c \"" + "import sqlite3; " + "c = sqlite3.connect('/var/lib/fenris/observations.db'); " + "c.execute('PRAGMA user_version=1'); " + "c.commit(); c.close()\"", + ) + + # Modify the config file (simulate hand-edited device selector) + _container_exec( + container, + "echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", + ) + # Record original config hash + rc, original_hash = _container_exec( + container, "sha256sum /etc/fenris/fenris.conf" + ) + original_hash = original_hash.strip().split()[0] + + # Install the package (fresh install since no previous version) + rc, out = _container_exec( + container, f"rpm -ivh /pkg/{pkg_name} 2>&1" + ) + + # Verify config survives (rpm -ivh with noreplace preserves modified config) + rc, post_hash = _container_exec( + container, "sha256sum /etc/fenris/fenris.conf" + ) + post_hash = post_hash.strip().split()[0] + assert post_hash == original_hash, ( + f"Config modified during fresh install (noreplace not working): " + f"{original_hash} → {post_hash}" + ) + def _assert_removal_semantics(container: str, fmt: str) -> None: """Assert removal mapping (spec §7).""" @@ -638,7 +729,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version): @pytest.mark.slow @pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) def test_upgrade_semantics(skip_no_docker, image, fmt, version): - """Assert upgrade snapshots store and preserves config.""" + """Assert upgrade snapshots store, migrates, preserves config, removal no-ops.""" pkg = _find_package(fmt) pkg_name = pkg.name @@ -677,6 +768,10 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version): try: _assert_upgrade_semantics(container, fmt, pkg_name, version) + + # RPM-specific: verify config survives fresh install (noreplace) + if fmt == "rpm": + _assert_rpm_upgrade_full(container, pkg_name) finally: subprocess.run( ["docker", "rm", "-f", container], diff --git a/tests/test_store_migration.py b/tests/test_store_migration.py new file mode 100644 index 0000000..0af6786 --- /dev/null +++ b/tests/test_store_migration.py @@ -0,0 +1,195 @@ +"""Observation store migration unit tests (issue #48). + +Tests the forward-only migration logic that underpins package upgrade +semantics: older stores are migrated, current stores pass through, and +newer stores are refused loudly. + +Spec: §3.6, §9.5, §10.2 +""" +import sqlite3 +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from fenris.store import ( + SCHEMA_VERSION, + init_store, + migrate_to_latest, +) +from fenris.status import NewerSchema, open_store_readonly + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_store(path: Path, version: int = 0) -> sqlite3.Connection: + """Create a store at *path* with the given user_version.""" + conn = sqlite3.connect(str(path)) + conn.execute("PRAGMA journal_mode=WAL") + if version == 0: + # Fresh DB with no schema — user_version defaults to 0 + pass + else: + # Create a minimal schema so the DB is valid, then set version + conn.execute(""" + CREATE TABLE IF NOT EXISTS samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + device TEXT NOT NULL + ) + """) + conn.execute(f"PRAGMA user_version={version}") + conn.commit() + return conn + + +# --------------------------------------------------------------------------- +# migrate_to_latest +# --------------------------------------------------------------------------- + +class TestMigrateToLatest: + """Forward-only migration via migrate_to_latest().""" + + def test_migrates_from_zero(self, tmp_path): + """Store at user_version=0 → SCHEMA_VERSION (fresh DB).""" + db = tmp_path / "observations.db" + _make_store(db, version=0) + + steps = migrate_to_latest(db) + + # SCHEMA_VERSION - 0 = SCHEMA_VERSION migration steps + assert steps == SCHEMA_VERSION + + # Verify version was bumped + conn = sqlite3.connect(str(db)) + v = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + assert v == SCHEMA_VERSION + + def test_already_current_returns_zero(self, tmp_path): + """Store already at SCHEMA_VERSION → 0 steps applied.""" + db = tmp_path / "observations.db" + conn = _make_store(db, version=SCHEMA_VERSION) + conn.close() + + steps = migrate_to_latest(db) + assert steps == 0 + + def test_refuses_newer_store(self, tmp_path): + """Store with user_version > SCHEMA_VERSION → ValueError.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 1) + + with pytest.raises(ValueError, match="newer Fenris"): + migrate_to_latest(db) + + def test_refuses_much_newer_store(self, tmp_path): + """Store several versions ahead → ValueError.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 5) + + with pytest.raises(ValueError, match="newer Fenris"): + migrate_to_latest(db) + + def test_store_not_corrupted_on_refusal(self, tmp_path): + """After refusal, store is unchanged (no silent corruption).""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 2) + + with pytest.raises(ValueError): + migrate_to_latest(db) + + # Version should be unchanged + conn = sqlite3.connect(str(db)) + v = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + assert v == SCHEMA_VERSION + 2 + + def test_idempotent_on_current(self, tmp_path): + """Calling migrate_to_latest twice on a current store is safe.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION) + + assert migrate_to_latest(db) == 0 + assert migrate_to_latest(db) == 0 + + def test_migrates_intermediate_version(self, tmp_path): + """Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current).""" + db = tmp_path / "observations.db" + _make_store(db, version=1) + # SCHEMA_VERSION is 1, so version 1 is current + steps = migrate_to_latest(db) + assert steps == 0 + + +# --------------------------------------------------------------------------- +# init_store — downgrade refusal +# --------------------------------------------------------------------------- + +class TestInitStoreDowngradeRefusal: + """init_store() refuses newer-schema stores.""" + + def test_refuses_newer_store(self, tmp_path): + """init_store raises ValueError on newer-schema store.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 1) + + with pytest.raises(ValueError, match="newer Fenris"): + init_store(db) + + def test_store_not_corrupted_on_refusal(self, tmp_path): + """After init_store refusal, store is unchanged.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 1) + + with pytest.raises(ValueError): + init_store(db) + + conn = sqlite3.connect(str(db)) + v = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + assert v == SCHEMA_VERSION + 1 + + +# --------------------------------------------------------------------------- +# open_store_readonly — downgrade refusal +# --------------------------------------------------------------------------- + +class TestOpenStoreReadonlyDowngradeRefusal: + """open_store_readonly() raises NewerSchema on newer-schema stores.""" + + def test_raises_newer_schema(self, tmp_path): + """Newer store → NewerSchema exception.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 1) + + with pytest.raises(NewerSchema) as exc_info: + open_store_readonly(db) + + assert exc_info.value.version == SCHEMA_VERSION + 1 + + def test_store_not_corrupted(self, tmp_path): + """After NewerSchema refusal, store is unchanged.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION + 3) + + with pytest.raises(NewerSchema): + open_store_readonly(db) + + conn = sqlite3.connect(str(db)) + v = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + assert v == SCHEMA_VERSION + 3 + + def test_current_store_opens(self, tmp_path): + """Store at SCHEMA_VERSION opens without error.""" + db = tmp_path / "observations.db" + _make_store(db, version=SCHEMA_VERSION) + + conn = open_store_readonly(db) + assert conn is not None + conn.close()