test(store): миграции, аудит, атомарный apply/rollback (#20)
This commit is contained in:
@@ -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) == []
|
||||
Reference in New Issue
Block a user