Files
Cerbero-Bite/tests/unit/test_state_db.py
Adriano 263470786d Phase 2: persistence + safety controls
Aggiunge la persistenza SQLite, l'audit log a hash chain, il kill
switch coordinato e i CLI di gestione documentati in
docs/05-data-model.md e docs/07-risk-controls.md. 197 test pass,
1 skipped (sqlite3 CLI mancante), copertura totale 97%.

State (`state/`):
- 0001_init.sql con positions, instructions, decisions, dvol_history,
  manual_actions, system_state.
- db.py: connect con WAL + foreign_keys + transaction ctx, runner
  forward-only basato su PRAGMA user_version.
- models.py: record Pydantic, Decimal preservato come TEXT.
- repository.py: CRUD typed con singola connessione passata, cache
  aware, posizioni concorrenti.

Safety (`safety/`):
- audit_log.py: AuditLog append-only con SHA-256 chain e fsync,
  verify_chain riconosce ogni manomissione (payload, prev_hash,
  hash, JSON, separatori).
- kill_switch.py: arm/disarm transazionali, idempotenti, accoppiati
  all'audit chain.

Config (`config/loader.py` + `strategy.yaml`):
- Loader YAML con deep-merge di strategy.local.yaml.
- Verifica config_hash SHA-256 (riga config_hash esclusa).
- File golden strategy.yaml + esempio override.

Scripts:
- dead_man.sh: watchdog shell indipendente da Python.
- backup.py: VACUUM INTO orario con retention 30 giorni.

CLI:
- audit verify (exit 2 su tampering).
- kill-switch arm/disarm/status su SQLite reale.
- state inspect con tabella posizioni aperte.
- config hash, config validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:35:35 +02:00

110 lines
3.2 KiB
Python

"""Migration runner + SQLite pragma sanity tests."""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from cerbero_bite.state.db import (
connect,
current_version,
list_migrations,
run_migrations,
transaction,
)
def test_list_migrations_is_ordered_and_starts_with_0001() -> None:
migs = list_migrations()
assert migs, "expected at least one migration file"
versions = [m[0] for m in migs]
assert versions == sorted(versions)
assert versions[0] == 1
def test_run_migrations_creates_full_schema(tmp_path: Path) -> None:
db_path = tmp_path / "state.sqlite"
conn = connect(db_path)
try:
new_version = run_migrations(conn)
assert new_version == max(m[0] for m in list_migrations())
# Sanity: every documented table is present.
existing = {
row["name"]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
assert {
"positions",
"instructions",
"decisions",
"dvol_history",
"manual_actions",
"system_state",
} <= existing
finally:
conn.close()
def test_run_migrations_is_idempotent(tmp_path: Path) -> None:
db_path = tmp_path / "state.sqlite"
conn = connect(db_path)
try:
run_migrations(conn)
first = current_version(conn)
run_migrations(conn) # second call must be a no-op
assert current_version(conn) == first
finally:
conn.close()
def test_pragmas_applied(tmp_path: Path) -> None:
db_path = tmp_path / "state.sqlite"
conn = connect(db_path)
try:
run_migrations(conn)
assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1
# WAL is sticky on the file: confirm via the journal_mode pragma.
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode.lower() == "wal"
finally:
conn.close()
def test_transaction_rolls_back_on_exception(tmp_path: Path) -> None:
db_path = tmp_path / "state.sqlite"
conn = connect(db_path)
try:
run_migrations(conn)
with pytest.raises(RuntimeError, match="boom"), transaction(conn):
conn.execute(
"INSERT INTO system_state(id, last_health_check, "
"config_version, started_at) VALUES (1, '2026-04-27', "
"'1.0.0', '2026-04-27')"
)
raise RuntimeError("boom")
# Row was rolled back.
rows = conn.execute("SELECT COUNT(*) FROM system_state").fetchone()[0]
assert rows == 0
finally:
conn.close()
def test_foreign_key_enforcement(tmp_path: Path) -> None:
db_path = tmp_path / "state.sqlite"
conn = connect(db_path)
try:
run_migrations(conn)
# Inserting an instruction without a parent position must fail.
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO instructions(instruction_id, proposal_id, kind, "
"payload_json, sent_at) VALUES (?, ?, ?, ?, ?)",
("i1", "missing", "open_combo", "{}", "2026-04-27"),
)
finally:
conn.close()