test(store): миграции, аудит, атомарный apply/rollback (#20)

This commit is contained in:
2026-10-21 23:45:06 +03:00
parent 238d39a274
commit 89e5a5a883
3 changed files with 609 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
"""Тесты журнала аудита «кто/когда/что/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) == []