"""Тесты журнала аудита «кто/когда/что/old→new» (issue #20).""" from __future__ import annotations from pathlib import Path import pytest from awg_api.store import ( AUDIT_STATUS_FAILED, AUDIT_STATUS_OK, Database, Entity, Mode, Rule, Store, ) from awg_api.store import audit def make_db(tmp_path: Path) -> Database: return Database(tmp_path / "data" / "awg-dash.db") def test_record_and_read_back(tmp_path: Path) -> None: with make_db(tmp_path) as db: with db.transaction() as conn: entry_id = audit.record( conn, actor="ui:admin", action="update", entity=Entity.RULE.value, entity_id=7, old_value={"match": "old.example.com"}, new_value={"match": "new.example.com"}, ) assert entry_id > 0 entries = audit.list_entries(db.connection) assert len(entries) == 1 entry = entries[0] assert entry.actor == "ui:admin" assert entry.entity == "rule" assert entry.entity_id == "7" assert entry.old_value == {"match": "old.example.com"} assert entry.new_value == {"match": "new.example.com"} assert entry.status == AUDIT_STATUS_OK assert entry.ts.endswith("Z") assert entry.as_dict()["entity_id"] == "7" def test_actor_is_required(tmp_path: Path) -> None: with make_db(tmp_path) as db: with db.transaction() as conn, pytest.raises(ValueError, match="actor"): audit.record( conn, actor="", action="update", entity=Entity.RULE.value, new_value={"x": 1}, ) def test_old_and_new_can_be_absent(tmp_path: Path) -> None: with make_db(tmp_path) as db: with db.transaction() as conn: audit.record(conn, actor="system", action="noop", entity=Entity.APPLY.value) entry = audit.last_entry(db.connection) assert entry is not None assert entry.old_value is None assert entry.new_value is None assert entry.entity_id is None assert entry.detail is None def test_filters_limit_and_ordering(tmp_path: Path) -> None: with make_db(tmp_path) as db: store = Store(db) store.set_mode(Mode.SPLIT, actor="ui:admin") store.set_mode(Mode.FULL, actor="ui:admin") store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"), actor="ui:admin") mode_entries = audit.list_entries(db.connection, entity=Entity.MODE.value) assert len(mode_entries) == 2 assert mode_entries[0].new_value == {"mode": "full"} # свежие первыми assert mode_entries[0].old_value == {"mode": "split"} by_id = audit.list_entries( db.connection, entity=Entity.MODE.value, entity_id=1, limit=1 ) assert len(by_id) == 1 assert audit.count(db.connection) == 3 assert audit.count(db.connection, entity=Entity.RULE.value) == 1 assert audit.last_entry(db.connection, entity=Entity.RULE.value) is not None def test_ts_can_be_overridden(tmp_path: Path) -> None: with make_db(tmp_path) as db: with db.transaction() as conn: audit.record( conn, actor="system", action="stage", entity=Entity.APPLY.value, ts="2026-01-02T03:04:05.678Z", ) entry = audit.last_entry(db.connection) assert entry is not None and entry.ts == "2026-01-02T03:04:05.678Z" def test_scalar_and_list_payloads_roundtrip(tmp_path: Path) -> None: with make_db(tmp_path) as db: with db.transaction() as conn: audit.record( conn, actor="system", action="apply", entity=Entity.APPLY.value, old_value=["a", "b"], new_value="plain", status=AUDIT_STATUS_FAILED, detail="iptables: exit 4", ) entry = audit.last_entry(db.connection) assert entry is not None assert entry.old_value == ["a", "b"] assert entry.new_value == "plain" assert entry.status == AUDIT_STATUS_FAILED assert entry.detail == "iptables: exit 4" def test_no_audit_row_when_transaction_rolls_back(tmp_path: Path) -> None: """Мутация и её аудит коммитятся вместе: откат транзакции не оставляет следа.""" with make_db(tmp_path) as db: with pytest.raises(RuntimeError), db.transaction() as conn: audit.record(conn, actor="system", action="update", entity=Entity.RULE.value) raise RuntimeError("мутация не удалась") assert audit.count(db.connection) == 0 def test_last_entry_returns_none_on_empty_log(tmp_path: Path) -> None: with make_db(tmp_path) as db: assert audit.last_entry(db.connection) is None assert audit.list_entries(db.connection) == []