Merge pull request 'Sprint 0 / #20: хранилище состояния — SQLite WAL, миграции, audit log, атомарный apply/rollback' (#50) from feat/20-storage into main
Sprint 0 / #20: хранилище состояния — SQLite WAL, миграции, audit log, атомарный apply/rollback Правки по ревью QA (#54 critical, #55 D-1/D-2) внесены и приняты: RLock на чтения, BaseException → revert + failed в аудите, old_value внутри транзакции. Гейт на merge-состоянии (main + PR): ruff/format чисто, mypy strict 21 файл, pytest 107 passed / coverage 97.80% (порог 70%), 55/55 AC-проб, 28/28 security-проб, мутационная проверка тестов пройдена. Ревью: qa-engineer (APPROVE) + TechLead. Refs #20, #54, #55, #52.
This commit was merged in pull request #50.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""Тесты атомарного применения: validate -> stage -> apply -> rollback (issue #20)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from awg_api.store import (
|
||||
AUDIT_STATUS_FAILED,
|
||||
AUDIT_STATUS_OK,
|
||||
AUDIT_STATUS_STAGED,
|
||||
ApplyEngine,
|
||||
ApplyError,
|
||||
Database,
|
||||
DesiredState,
|
||||
Entity,
|
||||
GeoIpSchedule,
|
||||
Mode,
|
||||
NullApplier,
|
||||
Rule,
|
||||
RuleException,
|
||||
Store,
|
||||
ValidationError,
|
||||
capture_state,
|
||||
)
|
||||
|
||||
|
||||
class MockIptables:
|
||||
"""Мок внешнего применителя: умеет «падать» как реальный iptables."""
|
||||
|
||||
def __init__(self, *, fail_apply: bool = False, fail_revert: bool = False) -> None:
|
||||
self.applied: list[dict[str, Any]] = []
|
||||
self.reverted: list[dict[str, Any]] = []
|
||||
self.fail_apply = fail_apply
|
||||
self.fail_revert = fail_revert
|
||||
|
||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.fail_apply:
|
||||
self.applied.append(dict(payload))
|
||||
raise RuntimeError("mock iptables: exit 4")
|
||||
self.applied.append(dict(payload))
|
||||
|
||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.fail_revert:
|
||||
raise RuntimeError("mock iptables: revert failed")
|
||||
self.reverted.append(dict(payload))
|
||||
|
||||
|
||||
def make_engine(
|
||||
tmp_path: Path, applier: MockIptables | None = None
|
||||
) -> tuple[Database, Store, ApplyEngine, MockIptables]:
|
||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
||||
store = Store(db)
|
||||
mock = applier if applier is not None else MockIptables()
|
||||
return db, store, ApplyEngine(store, mock, actor="ui:admin"), mock
|
||||
|
||||
|
||||
def desired(*rules: Rule, mode: Mode = Mode.SPLIT) -> DesiredState:
|
||||
return DesiredState(mode=mode, rules=rules, geoip=GeoIpSchedule())
|
||||
|
||||
|
||||
# -- validate ------------------------------------------------------------------
|
||||
def test_validate_accepts_good_state(tmp_path: Path) -> None:
|
||||
db, _store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
state = desired(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"))
|
||||
assert engine.validate(state) is state
|
||||
|
||||
|
||||
def test_validate_rejects_bad_state(tmp_path: Path) -> None:
|
||||
db, _store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
cases: list[DesiredState] = [
|
||||
DesiredState(mode="turbo"), # type: ignore[arg-type]
|
||||
desired(Rule(name="dup", kind="domain", match="a.example.com", target="tun0"),
|
||||
Rule(name="dup", kind="domain", match="b.example.com", target="tun0")),
|
||||
desired(Rule(name="x", kind="cidr", match="10.0.0.0/99", target="tun0")),
|
||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(cron="всякий раз")),
|
||||
DesiredState(
|
||||
mode=Mode.SPLIT,
|
||||
rules=(),
|
||||
geoip=GeoIpSchedule(),
|
||||
exceptions=(RuleException(match="!!!"),),
|
||||
),
|
||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(provider="!")),
|
||||
]
|
||||
for case in cases:
|
||||
with pytest.raises(ValidationError):
|
||||
engine.validate(case)
|
||||
|
||||
|
||||
def test_validate_does_not_touch_database(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
with pytest.raises(ValidationError):
|
||||
engine.apply_payload({"mode": "split", "rules": [{"name": "x", "kind": "cidr",
|
||||
"match": "bad", "target": "tun0"}]})
|
||||
assert store.counts() == {
|
||||
"rules": 0,
|
||||
"exceptions": 0,
|
||||
"configs": 0,
|
||||
"devices": 0,
|
||||
"audit": 0,
|
||||
"snapshots": 0,
|
||||
}
|
||||
|
||||
|
||||
# -- stage ---------------------------------------------------------------------
|
||||
def test_stage_saves_snapshot_and_staged_audit(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
store.upsert_rule(Rule(name="old", kind="domain", match="old.example.com", target="tun0"),
|
||||
actor="system")
|
||||
store.set_mode(Mode.OFF, actor="system")
|
||||
before = capture_state(db.connection)
|
||||
|
||||
plan = engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
||||
target="tun0")), reason="тест")
|
||||
|
||||
assert plan.previous == before
|
||||
assert plan.desired.mode == Mode.SPLIT
|
||||
# состояние ещё не изменено
|
||||
assert capture_state(db.connection) == before
|
||||
assert [rule.name for rule in store.list_rules()] == ["old"]
|
||||
snapshots = store.counts()["snapshots"]
|
||||
assert snapshots == 1
|
||||
staged = store.list_audit(entity=Entity.APPLY.value)[0]
|
||||
assert staged.status == AUDIT_STATUS_STAGED
|
||||
assert staged.action == "stage"
|
||||
assert staged.detail == "тест"
|
||||
assert staged.old_value == before
|
||||
|
||||
|
||||
def test_stage_rejects_invalid_state_without_snapshot(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
with pytest.raises(ValidationError):
|
||||
engine.stage(DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule(cron="bad")))
|
||||
assert store.counts()["snapshots"] == 0
|
||||
|
||||
|
||||
# -- apply ---------------------------------------------------------------------
|
||||
def test_apply_writes_state_and_audit(tmp_path: Path) -> None:
|
||||
db, store, engine, mock = make_engine(tmp_path)
|
||||
with db:
|
||||
plan = engine.stage(desired(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
||||
mode=Mode.FULL), reason="включение")
|
||||
result = engine.apply(plan)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.plan_id == plan.plan_id
|
||||
assert result.audit_id > 0
|
||||
assert store.get_mode() == Mode.FULL
|
||||
assert [rule.name for rule in store.list_rules()] == ["yt"]
|
||||
assert mock.applied == [plan.desired.to_payload()]
|
||||
entry = store.list_audit(entity=Entity.APPLY.value)[0]
|
||||
assert entry.status == AUDIT_STATUS_OK
|
||||
assert entry.action == "apply"
|
||||
assert entry.old_value == plan.previous
|
||||
assert entry.new_value == plan.desired.to_payload()
|
||||
|
||||
|
||||
def test_failed_apply_restores_previous_state(tmp_path: Path) -> None:
|
||||
"""GIVEN ошибочный apply (мок падения iptables), THEN состояние прежнее + ApplyError."""
|
||||
db, store, engine, mock = make_engine(tmp_path, MockIptables(fail_apply=True))
|
||||
with db:
|
||||
store.upsert_rule(Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
||||
actor="system")
|
||||
store.set_mode(Mode.SPLIT, actor="system")
|
||||
store.set_geoip_schedule(cron="0 5 * * *", actor="system")
|
||||
before = capture_state(db.connection)
|
||||
|
||||
plan = engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
||||
target="tun0"), mode=Mode.FULL), reason="сломается")
|
||||
|
||||
with pytest.raises(ApplyError) as excinfo:
|
||||
engine.apply(plan)
|
||||
|
||||
error = excinfo.value
|
||||
assert error.restored is True
|
||||
assert error.audit_id is not None and error.audit_id > 0
|
||||
# состояние, а не «частично применённая конфигурация»
|
||||
assert capture_state(db.connection) == before
|
||||
assert [rule.name for rule in store.list_rules()] == ["keep"]
|
||||
assert store.get_mode() == Mode.SPLIT
|
||||
assert store.get_geoip_schedule().cron == "0 5 * * *"
|
||||
|
||||
assert mock.reverted == [before]
|
||||
failed = store.list_audit(entity=Entity.APPLY.value)[0]
|
||||
assert failed.status == AUDIT_STATUS_FAILED
|
||||
assert failed.id == error.audit_id
|
||||
assert failed.new_value == before
|
||||
assert "mock iptables" in (failed.detail or "")
|
||||
|
||||
|
||||
def test_failed_apply_with_failing_revert_still_restores_db(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(
|
||||
tmp_path, MockIptables(fail_apply=True, fail_revert=True)
|
||||
)
|
||||
with db:
|
||||
before = capture_state(db.connection)
|
||||
plan = engine.stage(desired(Rule(name="x", kind="domain", match="x.example.com", target="tun0")))
|
||||
with pytest.raises(ApplyError) as excinfo:
|
||||
engine.apply(plan)
|
||||
assert excinfo.value.restored is True
|
||||
assert capture_state(db.connection) == before
|
||||
|
||||
|
||||
def test_apply_can_succeed_after_failed_apply(tmp_path: Path) -> None:
|
||||
db, store, engine, mock = make_engine(tmp_path, MockIptables(fail_apply=True))
|
||||
with db:
|
||||
bad = engine.stage(desired(Rule(name="bad", kind="domain", match="bad.example.com",
|
||||
target="tun0")))
|
||||
with pytest.raises(ApplyError):
|
||||
engine.apply(bad)
|
||||
|
||||
mock.fail_apply = False
|
||||
good_rule = Rule(name="good", kind="domain", match="good.example.com", target="tun0")
|
||||
result = engine.apply(engine.stage(desired(good_rule, mode=Mode.FULL)))
|
||||
assert result.ok is True
|
||||
assert [rule.name for rule in store.list_rules()] == ["good"]
|
||||
assert store.get_mode() == Mode.FULL
|
||||
|
||||
|
||||
# -- rollback ------------------------------------------------------------------
|
||||
def test_rollback_returns_previous_state(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
store.upsert_rule(Rule(name="old", kind="domain", match="old.example.com", target="tun0"),
|
||||
actor="ui:admin")
|
||||
before = capture_state(db.connection)
|
||||
|
||||
result = engine.apply(
|
||||
engine.stage(desired(Rule(name="new", kind="domain", match="new.example.com",
|
||||
target="tun0"), mode=Mode.FULL), reason="применение"),
|
||||
)
|
||||
assert result.ok is True
|
||||
|
||||
snapshot = engine.rollback(result.plan_id, reason="откат оператором")
|
||||
assert snapshot.id == result.plan_id
|
||||
assert snapshot.payload == before
|
||||
assert capture_state(db.connection) == before
|
||||
assert [rule.name for rule in store.list_rules()] == ["old"]
|
||||
assert store.get_mode() == Mode.OFF
|
||||
|
||||
rollback_entry = store.list_audit(entity=Entity.APPLY.value)[0]
|
||||
assert rollback_entry.action == "rollback"
|
||||
assert rollback_entry.detail == "откат оператором"
|
||||
assert rollback_entry.new_value == before
|
||||
|
||||
|
||||
def test_apply_payload_sugar_and_null_applier(tmp_path: Path) -> None:
|
||||
db, store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
result = engine.apply_payload(
|
||||
{
|
||||
"mode": "split",
|
||||
"rules": [{"name": "yt", "kind": "domain", "match": "youtube.com", "target": "tun0"}],
|
||||
"geoip": {"enabled": False, "cron": "0 3 * * *", "provider": "ripe"},
|
||||
"exceptions": [{"match": "bank.example.com", "reason": "банк"}],
|
||||
},
|
||||
reason="api: применить",
|
||||
)
|
||||
assert result.ok is True
|
||||
assert store.get_mode() == Mode.SPLIT
|
||||
assert store.get_geoip_schedule().enabled is False
|
||||
assert store.list_exceptions()[0].match == "bank.example.com"
|
||||
|
||||
engine_null = ApplyEngine(store, NullApplier(), actor="system")
|
||||
assert engine_null.apply_payload({"mode": "off"}).ok is True
|
||||
assert store.get_mode() == Mode.OFF
|
||||
|
||||
|
||||
def test_desired_state_payload_roundtrip() -> None:
|
||||
state = DesiredState(
|
||||
mode=Mode.FULL,
|
||||
rules=(Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=7),),
|
||||
geoip=GeoIpSchedule(enabled=False, cron="0 1 * * *", provider="maxmind"),
|
||||
exceptions=(RuleException(match="bank.example.com", reason="банк", enabled=False),),
|
||||
)
|
||||
restored = DesiredState.from_payload(state.to_payload())
|
||||
assert restored.mode == Mode.FULL
|
||||
assert restored.rules == state.rules
|
||||
assert restored.geoip == state.geoip
|
||||
assert restored.exceptions == state.exceptions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"rules": "не список"},
|
||||
{"rules": [{"kind": "domain", "match": "a.example.com", "target": "tun0"}]},
|
||||
{"rules": ["строка"]},
|
||||
{"exceptions": "не список"},
|
||||
{"exceptions": [{"reason": "нет match"}]},
|
||||
{"geoip": "не объект"},
|
||||
{"mode": "turbo"},
|
||||
],
|
||||
)
|
||||
def test_desired_state_from_payload_rejects_bad_payload(payload: dict[str, Any]) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
DesiredState.from_payload(payload)
|
||||
|
||||
|
||||
def test_apply_engine_requires_store(tmp_path: Path) -> None:
|
||||
db, _store, engine, _mock = make_engine(tmp_path)
|
||||
with db:
|
||||
assert engine.validate(desired()) == desired()
|
||||
@@ -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) == []
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Тесты потокобезопасности слоя хранения (#54, D-1/D-2 из #55; issue #20).
|
||||
|
||||
Контракт (задокументирован в ``backend/awg_api/store/README.md``): одно
|
||||
соединение SQLite, все операции — чтения и записи — сериализуются одним
|
||||
``RLock``. Читатель никогда не видит незафиксированных строк и состояния,
|
||||
которое затем будет откачено, а конкурентные мутации дают согласованный
|
||||
журнал аудита.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from awg_api.store import (
|
||||
AUDIT_STATUS_FAILED,
|
||||
ApplyEngine,
|
||||
ApplyError,
|
||||
Database,
|
||||
DesiredState,
|
||||
Entity,
|
||||
Mode,
|
||||
Rule,
|
||||
Store,
|
||||
capture_state,
|
||||
)
|
||||
|
||||
THREADS = 4
|
||||
PER_THREAD = 25
|
||||
JOIN_TIMEOUT = 30.0
|
||||
# окно наблюдения: за это время незаблокированное чтение гарантированно успело бы вернуться
|
||||
OBSERVE_SECONDS = 0.2
|
||||
|
||||
RULE_INSERT = (
|
||||
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
|
||||
|
||||
def open_store(tmp_path: Path) -> tuple[Database, Store]:
|
||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
||||
return db, Store(db)
|
||||
|
||||
|
||||
def rule(tid: int, idx: int) -> Rule:
|
||||
return Rule(
|
||||
name=f"r{tid}x{idx}",
|
||||
kind="domain",
|
||||
match=f"t{tid}r{idx}.example.com",
|
||||
target="tun0",
|
||||
)
|
||||
|
||||
|
||||
def run_threads(workers: list[Any]) -> list[threading.Thread]:
|
||||
threads = [threading.Thread(target=worker) for worker in workers]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(JOIN_TIMEOUT)
|
||||
assert not thread.is_alive(), f"поток {thread.name} не завершился"
|
||||
return threads
|
||||
|
||||
|
||||
# -- #54: конкурентные мутации вместе с чтениями ---------------------------------
|
||||
def test_concurrent_upserts_and_reads_are_consistent(tmp_path: Path) -> None:
|
||||
"""N потоков × M upsert с чтениями: без ошибок, rules == audit == N*M."""
|
||||
db, store = open_store(tmp_path)
|
||||
with db:
|
||||
errors: list[str] = []
|
||||
barrier = threading.Barrier(THREADS)
|
||||
|
||||
def worker(tid: int) -> None:
|
||||
try:
|
||||
barrier.wait(timeout=JOIN_TIMEOUT)
|
||||
for idx in range(PER_THREAD):
|
||||
store.upsert_rule(rule(tid, idx), actor=f"thread{tid}")
|
||||
store.list_rules()
|
||||
store.get_mode()
|
||||
store.get_rule_by_name(f"r{tid}x{idx}")
|
||||
except BaseException as exc: # noqa: BLE001 - фиксируем любое падение потока
|
||||
errors.append(f"thread{tid}: {type(exc).__name__}: {exc}")
|
||||
|
||||
run_threads([lambda tid=tid: worker(tid) for tid in range(THREADS)])
|
||||
|
||||
total = THREADS * PER_THREAD
|
||||
assert errors == []
|
||||
assert store.counts()["rules"] == total
|
||||
assert store.counts()["audit"] == total
|
||||
assert len(store.list_rules()) == total
|
||||
assert len(store.list_audit(limit=total * 2)) == total
|
||||
|
||||
|
||||
# -- #54: чтение не видит незафиксированных строк -------------------------------
|
||||
def test_reads_never_see_uncommitted_rows(tmp_path: Path) -> None:
|
||||
"""Пока транзакция другого потока открыта, читатель ждёт коммита, а не читает грязь."""
|
||||
db, store = open_store(tmp_path)
|
||||
with db:
|
||||
store.upsert_rule(
|
||||
Rule(name="baseline", kind="domain", match="baseline.example.com", target="tun0"),
|
||||
actor="system",
|
||||
)
|
||||
transaction_open = threading.Event()
|
||||
reader_started = threading.Event()
|
||||
release = threading.Event()
|
||||
observed: list[list[str]] = []
|
||||
writer_errors: list[str] = []
|
||||
|
||||
def writer() -> None:
|
||||
try:
|
||||
with db.transaction() as conn:
|
||||
conn.execute(
|
||||
RULE_INSERT,
|
||||
(
|
||||
"uncommitted",
|
||||
"domain",
|
||||
"uncommitted.example.com",
|
||||
"tun0",
|
||||
100,
|
||||
1,
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2026-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
transaction_open.set()
|
||||
release.wait(JOIN_TIMEOUT)
|
||||
except BaseException as exc: # noqa: BLE001 - фиксируем падение писателя
|
||||
writer_errors.append(f"{type(exc).__name__}: {exc}")
|
||||
|
||||
def reader() -> None:
|
||||
reader_started.set()
|
||||
observed.append([item.name for item in store.list_rules()])
|
||||
|
||||
writer_thread = threading.Thread(target=writer)
|
||||
writer_thread.start()
|
||||
assert transaction_open.wait(JOIN_TIMEOUT)
|
||||
reader_thread = threading.Thread(target=reader)
|
||||
reader_thread.start()
|
||||
assert reader_started.wait(JOIN_TIMEOUT)
|
||||
time.sleep(OBSERVE_SECONDS)
|
||||
# незафиксированная строка наружу не видна: чтение ждёт завершения транзакции
|
||||
assert observed == []
|
||||
|
||||
release.set()
|
||||
writer_thread.join(JOIN_TIMEOUT)
|
||||
reader_thread.join(JOIN_TIMEOUT)
|
||||
assert writer_errors == []
|
||||
assert observed == [["baseline", "uncommitted"]]
|
||||
|
||||
|
||||
# -- #54 п.4: во время падающего apply наружу не отдаётся откаченное состояние ----
|
||||
class BlockingApplier:
|
||||
"""Применитель, который «висит» внутри apply и затем падает как iptables."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.reverted: list[dict[str, Any]] = []
|
||||
|
||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
||||
self.entered.set()
|
||||
assert self.release.wait(JOIN_TIMEOUT)
|
||||
raise RuntimeError("mock iptables: exit 4")
|
||||
|
||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
||||
self.reverted.append(dict(payload))
|
||||
|
||||
|
||||
def test_read_during_failing_apply_never_sees_discarded_state(tmp_path: Path) -> None:
|
||||
"""AC #20: снаружи не видно «полуприменённой» конфигурации, которую затем откатят."""
|
||||
db, store = open_store(tmp_path)
|
||||
applier = BlockingApplier()
|
||||
engine = ApplyEngine(store, applier, actor="ui:admin")
|
||||
with db:
|
||||
store.upsert_rule(
|
||||
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
||||
actor="system",
|
||||
)
|
||||
before = capture_state(db.connection)
|
||||
plan = engine.stage(
|
||||
DesiredState(
|
||||
mode=Mode.FULL,
|
||||
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
|
||||
),
|
||||
reason="сломается",
|
||||
)
|
||||
errors: list[BaseException] = []
|
||||
observed: list[list[str]] = []
|
||||
reader_started = threading.Event()
|
||||
|
||||
def apply_worker() -> None:
|
||||
try:
|
||||
engine.apply(plan)
|
||||
except BaseException as exc: # noqa: BLE001 - ожидаем ApplyError
|
||||
errors.append(exc)
|
||||
|
||||
def reader() -> None:
|
||||
reader_started.set()
|
||||
observed.append([item.name for item in store.list_rules()])
|
||||
|
||||
apply_thread = threading.Thread(target=apply_worker)
|
||||
apply_thread.start()
|
||||
assert applier.entered.wait(JOIN_TIMEOUT)
|
||||
reader_thread = threading.Thread(target=reader)
|
||||
reader_thread.start()
|
||||
assert reader_started.wait(JOIN_TIMEOUT)
|
||||
time.sleep(OBSERVE_SECONDS)
|
||||
assert observed == [], "чтение вернуло состояние, которого ещё нет в БД"
|
||||
|
||||
applier.release.set()
|
||||
apply_thread.join(JOIN_TIMEOUT)
|
||||
reader_thread.join(JOIN_TIMEOUT)
|
||||
|
||||
assert len(errors) == 1
|
||||
error = errors[0]
|
||||
assert isinstance(error, ApplyError)
|
||||
assert error.restored is True
|
||||
assert observed == [["keep"]]
|
||||
assert [item.name for item in store.list_rules()] == ["keep"]
|
||||
assert capture_state(db.connection) == before
|
||||
assert applier.reverted == [before]
|
||||
|
||||
|
||||
# -- #55 D-1: BaseException не должен оставлять staged без исхода ---------------
|
||||
class InterruptingApplier:
|
||||
"""Применитель, прерывающий apply исключением из ``BaseException``."""
|
||||
|
||||
def __init__(self, exc_type: type[BaseException]) -> None:
|
||||
self.exc_type = exc_type
|
||||
self.applied = 0
|
||||
self.reverted: list[dict[str, Any]] = []
|
||||
|
||||
def apply(self, payload: Mapping[str, Any]) -> None:
|
||||
self.applied += 1
|
||||
raise self.exc_type("внешний применитель прерван")
|
||||
|
||||
def revert(self, payload: Mapping[str, Any]) -> None:
|
||||
self.reverted.append(dict(payload))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit, asyncio.CancelledError])
|
||||
def test_base_exception_from_applier_reverts_and_records_failure(
|
||||
tmp_path: Path, exc_type: type[BaseException]
|
||||
) -> None:
|
||||
"""Отмена/прерывание apply → revert внешней подсистемы + failed в audit_log."""
|
||||
db, store = open_store(tmp_path)
|
||||
applier = InterruptingApplier(exc_type)
|
||||
engine = ApplyEngine(store, applier, actor="ui:admin")
|
||||
with db:
|
||||
store.upsert_rule(
|
||||
Rule(name="keep", kind="domain", match="keep.example.com", target="tun0"),
|
||||
actor="system",
|
||||
)
|
||||
before = capture_state(db.connection)
|
||||
plan = engine.stage(
|
||||
DesiredState(
|
||||
mode=Mode.FULL,
|
||||
rules=(Rule(name="new", kind="domain", match="new.example.com", target="tun0"),),
|
||||
),
|
||||
reason="отмена",
|
||||
)
|
||||
|
||||
with pytest.raises(exc_type):
|
||||
engine.apply(plan)
|
||||
|
||||
assert applier.applied == 1
|
||||
assert applier.reverted == [before]
|
||||
assert capture_state(db.connection) == before
|
||||
assert [item.name for item in store.list_rules()] == ["keep"]
|
||||
|
||||
entries = store.list_audit(entity=Entity.APPLY.value, entity_id=plan.plan_id, limit=10)
|
||||
assert entries[0].status == AUDIT_STATUS_FAILED
|
||||
assert exc_type.__name__ in (entries[0].detail or "")
|
||||
assert entries[0].new_value == before
|
||||
|
||||
|
||||
# -- #55 D-2: конкурентные set_geoip_schedule дают связный журнал ---------------
|
||||
def test_concurrent_geoip_updates_keep_audit_chain_consistent(tmp_path: Path) -> None:
|
||||
"""Каждое изменение пишет корректный old_value: цепочка old→new не рвётся."""
|
||||
db, store = open_store(tmp_path)
|
||||
with db:
|
||||
initial = store.get_geoip_schedule()
|
||||
crons = ("0 1 * * *", "0 2 * * *")
|
||||
errors: list[str] = []
|
||||
barrier = threading.Barrier(len(crons))
|
||||
|
||||
def worker(cron: str) -> None:
|
||||
try:
|
||||
barrier.wait(timeout=JOIN_TIMEOUT)
|
||||
store.set_geoip_schedule(cron=cron, actor=f"ui:{cron}")
|
||||
except BaseException as exc: # noqa: BLE001 - фиксируем падение потока
|
||||
errors.append(f"{cron}: {type(exc).__name__}: {exc}")
|
||||
|
||||
run_threads([lambda cron=cron: worker(cron) for cron in crons])
|
||||
|
||||
assert errors == []
|
||||
entries = list(reversed(store.list_audit(entity=Entity.GEOIP_SCHEDULE.value, limit=10)))
|
||||
assert len(entries) == len(crons)
|
||||
first, second = entries
|
||||
assert first.old_value["cron"] == initial.cron
|
||||
assert first.new_value["cron"] == second.old_value["cron"]
|
||||
assert second.new_value["cron"] == store.get_geoip_schedule().cron
|
||||
assert {first.new_value["cron"], second.new_value["cron"]} == set(crons)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Тесты схемы, миграций и проверки версии при старте (issue #20)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from awg_api.store import (
|
||||
EXPECTED_TABLES,
|
||||
LATEST_VERSION,
|
||||
MIGRATIONS,
|
||||
Database,
|
||||
Migration,
|
||||
SchemaError,
|
||||
StorageError,
|
||||
current_version,
|
||||
migrate,
|
||||
resolve_db_path,
|
||||
verify_schema,
|
||||
)
|
||||
from awg_api.store.migrations import applied_migrations, table_columns, table_names
|
||||
|
||||
|
||||
def test_empty_data_dir_creates_schema(tmp_path: Path) -> None:
|
||||
"""GIVEN пустой /data, WHEN старт, THEN схема v1 создана автоматически."""
|
||||
db_path = tmp_path / "data" / "awg-dash.db"
|
||||
assert not db_path.parent.exists()
|
||||
|
||||
with Database(db_path) as db:
|
||||
assert db.schema_version == LATEST_VERSION == 1
|
||||
assert db.migration_report is not None
|
||||
assert db.migration_report.applied == (1,)
|
||||
names = table_names(db.connection)
|
||||
assert set(EXPECTED_TABLES) <= names
|
||||
for table, columns in EXPECTED_TABLES.items():
|
||||
assert columns <= table_columns(db.connection, table), table
|
||||
|
||||
|
||||
def test_wal_mode_is_enabled(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
assert db.journal_mode.lower() == "wal"
|
||||
assert db.is_wal is True
|
||||
|
||||
|
||||
def test_seed_rows_present(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
row = db.connection.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
||||
assert row is not None and row["mode"] == "off"
|
||||
geoip = db.connection.execute("SELECT cron, provider FROM geoip_schedule WHERE id = 1").fetchone()
|
||||
assert geoip is not None
|
||||
assert geoip["provider"] == "ripe"
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
report = db.migrate()
|
||||
assert report.applied == ()
|
||||
assert report.changed is False
|
||||
assert report.from_version == report.to_version == LATEST_VERSION
|
||||
|
||||
|
||||
def test_applied_migrations_map(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
assert applied_migrations(db.connection) == {1: "initial-state-schema"}
|
||||
|
||||
|
||||
def test_auto_migrate_false_verifies_existing_schema(tmp_path: Path) -> None:
|
||||
path = tmp_path / "awg-dash.db"
|
||||
with Database(path):
|
||||
pass
|
||||
with Database(path, auto_migrate=False) as db:
|
||||
assert db.migration_report is None
|
||||
assert current_version(db.connection) == LATEST_VERSION
|
||||
|
||||
|
||||
def test_auto_migrate_false_on_unmigrated_db_raises(tmp_path: Path) -> None:
|
||||
conn = sqlite3.connect(str(tmp_path / "empty.db"))
|
||||
try:
|
||||
with pytest.raises(SchemaError, match="не мигрирована"):
|
||||
verify_schema(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_verify_schema_detects_missing_table(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
db.connection.execute("DROP TABLE routing_rules")
|
||||
with pytest.raises(SchemaError, match="routing_rules"):
|
||||
db.verify_schema()
|
||||
|
||||
|
||||
def test_verify_schema_detects_missing_column(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
db.connection.execute("ALTER TABLE devices DROP COLUMN address")
|
||||
with pytest.raises(SchemaError, match="address"):
|
||||
db.verify_schema()
|
||||
|
||||
|
||||
def test_verify_schema_rejects_newer_schema(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
db.connection.execute(
|
||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||
(99, "from-the-future", "2099-01-01T00:00:00.000Z"),
|
||||
)
|
||||
with pytest.raises(SchemaError, match="новее"):
|
||||
db.verify_schema()
|
||||
with pytest.raises(SchemaError, match="новее"):
|
||||
db.migrate()
|
||||
|
||||
|
||||
def test_failed_migration_leaves_schema_untouched(tmp_path: Path) -> None:
|
||||
broken = MIGRATIONS + (
|
||||
Migration(version=2, name="broken", statements=("CREATE TABLE t2 (id INTEGER)", "NOT SQL AT ALL")),
|
||||
)
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
with pytest.raises(StorageError, match="миграция v2"):
|
||||
migrate(db.connection, migrations=broken)
|
||||
assert current_version(db.connection) == 1
|
||||
assert "t2" not in table_names(db.connection)
|
||||
|
||||
|
||||
def test_migrate_rejects_downgrade(tmp_path: Path) -> None:
|
||||
with Database(tmp_path / "awg-dash.db") as db:
|
||||
db.connection.execute(
|
||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||
(2, "newer", "2099-01-01T00:00:00.000Z"),
|
||||
)
|
||||
with pytest.raises(SchemaError, match="новее"):
|
||||
migrate(db.connection, migrations=MIGRATIONS)
|
||||
|
||||
|
||||
def test_resolve_db_path_defaults_and_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("AWG_DASH_DB", raising=False)
|
||||
assert resolve_db_path() == Path("/data/awg-dash.db")
|
||||
monkeypatch.setenv("AWG_DASH_DB", str(tmp_path / "custom.db"))
|
||||
assert resolve_db_path() == tmp_path / "custom.db"
|
||||
assert resolve_db_path(tmp_path / "explicit.db") == tmp_path / "explicit.db"
|
||||
|
||||
|
||||
def test_database_uses_env_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AWG_DASH_DB", str(tmp_path / "nested" / "env.db"))
|
||||
with Database() as db:
|
||||
assert db.path == tmp_path / "nested" / "env.db"
|
||||
assert db.path.exists()
|
||||
|
||||
|
||||
def test_unwritable_path_raises_storage_error(tmp_path: Path) -> None:
|
||||
blocker = tmp_path / "blocked"
|
||||
blocker.write_text("not a directory")
|
||||
with pytest.raises(StorageError, match="каталог"):
|
||||
Database(blocker / "awg-dash.db")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Персистентность: рестарт «контейнера» сохраняет правила, конфиг и аудит (issue #20)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from awg_api.store import (
|
||||
ConfigMeta,
|
||||
Database,
|
||||
DesiredState,
|
||||
Device,
|
||||
GeoIpSchedule,
|
||||
Mode,
|
||||
Rule,
|
||||
StorageError,
|
||||
Store,
|
||||
ValidationError,
|
||||
open_storage,
|
||||
rollback_to,
|
||||
)
|
||||
from awg_api.store import validation
|
||||
|
||||
|
||||
def test_state_survives_container_restart(tmp_path: Path) -> None:
|
||||
"""GIVEN рестарт контейнера, WHEN он завершён, THEN правила и конфиг на месте."""
|
||||
db_path = tmp_path / "data" / "awg-dash.db"
|
||||
|
||||
with Database(db_path) as first:
|
||||
store = Store(first)
|
||||
store.set_mode(Mode.SPLIT, actor="ui:admin")
|
||||
store.set_geoip_schedule(cron="0 3 * * *", actor="ui:admin")
|
||||
store.upsert_rule(
|
||||
Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=10),
|
||||
actor="ui:admin",
|
||||
)
|
||||
store.upsert_rule(
|
||||
Rule(name="lan", kind="cidr", match="10.0.0.0/8", target="direct", priority=200),
|
||||
actor="ui:admin",
|
||||
)
|
||||
store.upsert_config(
|
||||
ConfigMeta(name="peer1", path="/config/peer1.conf", checksum="deadbeef", size_bytes=1024),
|
||||
actor="ui:admin",
|
||||
)
|
||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="notebook"), actor="ui:admin")
|
||||
audit_before = store.counts()["audit"]
|
||||
assert audit_before == 6
|
||||
|
||||
# «рестарт контейнера»: новое соединение к тому же /data
|
||||
with Database(db_path) as second:
|
||||
store = Store(second)
|
||||
assert second.is_wal is True
|
||||
assert store.get_mode() == Mode.SPLIT
|
||||
assert store.get_geoip_schedule().cron == "0 3 * * *"
|
||||
assert [(rule.name, rule.target) for rule in store.list_rules()] == [
|
||||
("yt", "tun0"),
|
||||
("lan", "direct"),
|
||||
]
|
||||
config = store.get_config("peer1")
|
||||
assert config is not None
|
||||
assert config.checksum == "deadbeef"
|
||||
assert store.get_device("aa:bb:cc:dd:ee:ff") is not None
|
||||
assert store.counts()["audit"] == audit_before
|
||||
assert second.migration_report is not None # миграции уже применены, повтор не нужен
|
||||
assert second.migration_report.applied == ()
|
||||
|
||||
|
||||
def test_wal_file_next_to_database(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "data" / "awg-dash.db"
|
||||
with Database(db_path) as db:
|
||||
Store(db).set_mode(Mode.FULL, actor="system")
|
||||
assert db_path.with_name("awg-dash.db-wal").exists()
|
||||
assert db_path.with_name("awg-dash.db-shm").exists()
|
||||
|
||||
|
||||
def test_open_storage_builds_full_stack(tmp_path: Path) -> None:
|
||||
with open_storage(tmp_path / "data" / "awg-dash.db", actor="api:token") as storage:
|
||||
storage.store.set_mode(Mode.SPLIT, actor="api:token")
|
||||
plan = storage.engine.stage(
|
||||
DesiredState(mode=Mode.SPLIT, rules=(), geoip=GeoIpSchedule()),
|
||||
reason="через storage",
|
||||
)
|
||||
result = storage.engine.apply(plan)
|
||||
assert result.ok is True
|
||||
assert storage.store.counts()["snapshots"] == 1
|
||||
assert storage.db.schema_version == 1
|
||||
|
||||
|
||||
def test_rollback_helper_and_missing_snapshot(tmp_path: Path) -> None:
|
||||
with open_storage(tmp_path / "data" / "awg-dash.db") as storage:
|
||||
storage.store.set_mode(Mode.OFF, actor="system")
|
||||
plan = storage.engine.stage(DesiredState(mode=Mode.FULL))
|
||||
storage.engine.apply(plan)
|
||||
assert storage.store.get_mode() == Mode.FULL
|
||||
|
||||
snapshot = rollback_to(storage.engine, plan.plan_id, reason="ручной откат")
|
||||
assert snapshot.payload["mode"] == "off"
|
||||
assert storage.store.get_mode() == Mode.OFF
|
||||
|
||||
with pytest.raises(StorageError, match="не найден"):
|
||||
storage.engine.rollback(999999)
|
||||
|
||||
|
||||
def test_transactions_are_isolated_per_connection(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "data" / "awg-dash.db"
|
||||
with Database(db_path) as writer, Database(db_path) as reader:
|
||||
Store(writer).set_mode(Mode.SPLIT, actor="system")
|
||||
assert Store(reader).get_mode() == Mode.SPLIT
|
||||
with pytest.raises(RuntimeError, match="откат"), writer.transaction() as conn:
|
||||
conn.execute("UPDATE routing_mode SET mode = 'full' WHERE id = 1")
|
||||
raise RuntimeError("откат")
|
||||
assert Store(reader).get_mode() == Mode.SPLIT
|
||||
|
||||
|
||||
# -- валидация (чистые функции) -------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("AA:BB:CC:DD:EE:FF", "aa:bb:cc:dd:ee:ff"),
|
||||
("aa-bb-cc-dd-ee-ff", "aa:bb:cc:dd:ee:ff"),
|
||||
("aabbccddeeff", "aa:bb:cc:dd:ee:ff"),
|
||||
],
|
||||
)
|
||||
def test_normalize_mac(raw: str, expected: str) -> None:
|
||||
assert validation.normalize_mac(raw) == expected
|
||||
|
||||
|
||||
def test_normalize_mac_rejects_garbage() -> None:
|
||||
with pytest.raises(ValidationError, match="MAC"):
|
||||
validation.normalize_mac("not-a-mac")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["ok", "peer_1", "rule-2", "a.b:c"])
|
||||
def test_validate_name_accepts(value: str) -> None:
|
||||
assert validation.validate_name("name", f" {value} ") == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "UPPER", "-lead", "with space", "юникод", "x" * 64])
|
||||
def test_validate_name_rejects(value: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
validation.validate_name("name", value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["/config/peer1.conf", "/etc/awg-dash/rules.conf"])
|
||||
def test_validate_config_path_accepts(value: str) -> None:
|
||||
assert validation.validate_config_path(value) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["relative.conf", "/config/../../etc/shadow", ""])
|
||||
def test_validate_config_path_rejects(value: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
validation.validate_config_path(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("value", "expected"), [("RIPE", "ripe"), ("maxmind", "maxmind")])
|
||||
def test_validate_provider(value: str, expected: str) -> None:
|
||||
assert validation.validate_provider(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["tunnel", "direct", "block", "default"])
|
||||
def test_validate_device_policy_accepts(value: str) -> None:
|
||||
assert validation.validate_device_policy(value) == value
|
||||
|
||||
|
||||
def test_validate_device_policy_rejects() -> None:
|
||||
with pytest.raises(ValidationError, match="policy"):
|
||||
validation.validate_device_policy("root")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["*.example.com", "youtube.com", "a.b.c.example.co.uk"])
|
||||
def test_validate_match_domain(value: str) -> None:
|
||||
assert validation.validate_match("domain", value) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["10.0.0.0/8", "2001:db8::/32", "192.168.1.7"])
|
||||
def test_validate_match_cidr(value: str) -> None:
|
||||
assert validation.validate_match("cidr", value) == value
|
||||
|
||||
|
||||
def test_validate_match_ipset_and_empty() -> None:
|
||||
assert validation.validate_match("ipset", "awg_ru") == "awg_ru"
|
||||
with pytest.raises(ValidationError, match="пустым"):
|
||||
validation.validate_match("domain", " ")
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Тесты репозитория: CRUD-мутации с аудитом «old→new» (issue #20)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from awg_api.store import (
|
||||
ConfigMeta,
|
||||
Database,
|
||||
Device,
|
||||
Entity,
|
||||
GeoIpSchedule,
|
||||
Mode,
|
||||
Rule,
|
||||
RuleException,
|
||||
StorageError,
|
||||
Store,
|
||||
ValidationError,
|
||||
)
|
||||
from awg_api.store import audit
|
||||
from awg_api.store.validation import validate_priority
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> tuple[Database, Store]:
|
||||
db = Database(tmp_path / "data" / "awg-dash.db")
|
||||
return db, Store(db)
|
||||
|
||||
|
||||
# -- правила -----------------------------------------------------------------
|
||||
def test_upsert_rule_creates_with_audit(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
saved = store.upsert_rule(
|
||||
Rule(name="yt", kind="domain", match="youtube.com", target="tun0", priority=10),
|
||||
actor="ui:admin",
|
||||
)
|
||||
assert saved.id is not None and saved.created_at == saved.updated_at
|
||||
assert store.get_rule(saved.id) == saved
|
||||
assert store.get_rule_by_name("yt") == saved
|
||||
entries = store.list_audit(entity=Entity.RULE.value)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].action == "create"
|
||||
assert entries[0].old_value is None
|
||||
assert entries[0].new_value is not None
|
||||
assert entries[0].new_value["name"] == "yt"
|
||||
|
||||
|
||||
def test_upsert_rule_updates_by_name_and_records_old_new(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
first = store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
||||
actor="ui:admin")
|
||||
second = store.upsert_rule(Rule(name="yt", kind="domain", match="youtu.be", target="tun1"),
|
||||
actor="ui:admin")
|
||||
assert second.id == first.id
|
||||
assert second.match == "youtu.be" and second.target == "tun1"
|
||||
assert store.counts()["rules"] == 1
|
||||
entries = store.list_audit(entity=Entity.RULE.value)
|
||||
assert [entry.action for entry in entries] == ["update", "create"]
|
||||
assert entries[0].old_value["match"] == "youtube.com"
|
||||
assert entries[0].new_value["match"] == "youtu.be"
|
||||
assert entries[0].old_value["target"] == "tun0"
|
||||
assert entries[0].new_value["target"] == "tun1"
|
||||
|
||||
|
||||
def test_upsert_rule_by_id(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
saved = store.upsert_rule(Rule(name="a", kind="cidr", match="10.0.0.0/8", target="tun0"),
|
||||
actor="system")
|
||||
updated = store.upsert_rule(
|
||||
Rule(id=saved.id, name="a", kind="cidr", match="10.1.0.0/16", target="tun0", enabled=False),
|
||||
actor="system",
|
||||
)
|
||||
assert updated.id == saved.id
|
||||
assert updated.match == "10.1.0.0/16"
|
||||
assert updated.enabled is False
|
||||
assert store.list_rules(enabled_only=True) == []
|
||||
|
||||
|
||||
def test_rule_validation_rejects_bad_input(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
cases = [
|
||||
Rule(name="Bad Name", kind="domain", match="youtube.com", target="tun0"),
|
||||
Rule(name="ok", kind="wrong", match="youtube.com", target="tun0"),
|
||||
Rule(name="ok2", kind="domain", match="not a domain", target="tun0"),
|
||||
Rule(name="ok3", kind="cidr", match="10.0.0.0/99", target="tun0"),
|
||||
Rule(name="ok4", kind="domain", match="youtube.com", target="bad target!"),
|
||||
Rule(name="ok5", kind="domain", match="youtube.com", target="tun0", priority=-1),
|
||||
]
|
||||
for rule in cases:
|
||||
with pytest.raises(ValidationError):
|
||||
store.upsert_rule(rule, actor="system")
|
||||
assert store.counts()["rules"] == 0
|
||||
assert audit.count(db.connection) == 0
|
||||
|
||||
|
||||
def test_priority_must_be_int() -> None:
|
||||
with pytest.raises(ValidationError, match="целое"):
|
||||
validate_priority(True) # bool не является допустимым приоритетом
|
||||
with pytest.raises(ValidationError, match="диапазона"):
|
||||
validate_priority(70000)
|
||||
|
||||
|
||||
def test_delete_rule(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
saved = store.upsert_rule(Rule(name="yt", kind="domain", match="youtube.com", target="tun0"),
|
||||
actor="ui:admin")
|
||||
assert saved.id is not None
|
||||
assert store.delete_rule(saved.id, actor="ui:admin") is True
|
||||
assert store.delete_rule(saved.id, actor="ui:admin") is False
|
||||
assert store.get_rule(saved.id) is None
|
||||
entries = store.list_audit(entity=Entity.RULE.value)
|
||||
assert [entry.action for entry in entries] == ["delete", "create"]
|
||||
assert entries[0].new_value is None
|
||||
assert entries[0].old_value["name"] == "yt"
|
||||
|
||||
|
||||
def test_rules_sorted_by_priority(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
store.upsert_rule(Rule(name="b", kind="domain", match="b.example.com", target="tun0",
|
||||
priority=200), actor="system")
|
||||
store.upsert_rule(Rule(name="a", kind="domain", match="a.example.com", target="tun0",
|
||||
priority=5), actor="system")
|
||||
assert [rule.name for rule in store.list_rules()] == ["a", "b"]
|
||||
|
||||
|
||||
# -- исключения ---------------------------------------------------------------
|
||||
def test_exception_upsert_update_delete(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
created = store.upsert_exception(RuleException(match="bank.example.com", reason="банк"),
|
||||
actor="ui:admin")
|
||||
assert created.id is not None
|
||||
assert store.get_exception("bank.example.com") == created
|
||||
|
||||
updated = store.upsert_exception(RuleException(match="bank.example.com", reason="банк2"),
|
||||
actor="ui:admin")
|
||||
assert updated.id == created.id and updated.reason == "банк2"
|
||||
assert store.counts()["exceptions"] == 1
|
||||
|
||||
assert store.list_exceptions() == [updated]
|
||||
assert store.delete_exception(updated.id or 0, actor="ui:admin") is True
|
||||
assert store.delete_exception(updated.id or 0, actor="ui:admin") is False
|
||||
|
||||
actions = [entry.action for entry in store.list_audit(entity=Entity.EXCEPTION.value)]
|
||||
assert actions == ["delete", "update", "create"]
|
||||
update_entry = store.list_audit(entity=Entity.EXCEPTION.value)[1]
|
||||
assert update_entry.old_value["reason"] == "банк"
|
||||
assert update_entry.new_value["reason"] == "банк2"
|
||||
|
||||
|
||||
def test_exception_accepts_cidr_and_rejects_garbage(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
assert store.upsert_exception(RuleException(match="192.168.1.0/24"), actor="system").match == (
|
||||
"192.168.1.0/24"
|
||||
)
|
||||
assert store.upsert_exception(RuleException(match="10.0.0.1"), actor="system").match == "10.0.0.1"
|
||||
with pytest.raises(ValidationError, match="домен"):
|
||||
store.upsert_exception(RuleException(match="!!!"), actor="system")
|
||||
with pytest.raises(ValidationError, match="пустым"):
|
||||
store.upsert_exception(RuleException(match=" "), actor="system")
|
||||
|
||||
|
||||
# -- режим и GeoIP -------------------------------------------------------------
|
||||
def test_set_mode_defaults_off_and_audits(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
assert store.get_mode() == Mode.OFF
|
||||
assert store.set_mode("split", actor="ui:admin") == Mode.SPLIT
|
||||
assert store.get_mode() == Mode.SPLIT
|
||||
assert store.set_mode(Mode("full"), actor="ui:admin") == Mode.FULL
|
||||
entries = store.list_audit(entity=Entity.MODE.value)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].old_value == {"mode": "split"}
|
||||
assert entries[0].new_value == {"mode": "full"}
|
||||
assert entries[0].actor == "ui:admin"
|
||||
|
||||
|
||||
def test_set_mode_rejects_unknown(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
with pytest.raises(ValidationError, match="mode"):
|
||||
store.set_mode("turbo", actor="ui:admin")
|
||||
assert store.get_mode() == Mode.OFF
|
||||
|
||||
|
||||
def test_geoip_schedule_defaults_and_partial_update(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
default = store.get_geoip_schedule()
|
||||
assert default == GeoIpSchedule(enabled=True, cron="0 4 * * *", provider="ripe",
|
||||
updated_at=default.updated_at)
|
||||
updated = store.set_geoip_schedule(cron="*/30 * * * *", actor="ui:admin")
|
||||
assert updated.cron == "*/30 * * * *"
|
||||
assert updated.provider == "ripe" and updated.enabled is True
|
||||
|
||||
disabled = store.set_geoip_schedule(enabled=False, provider="MAXMIND", actor="ui:admin")
|
||||
assert disabled.enabled is False and disabled.provider == "maxmind"
|
||||
assert store.get_geoip_schedule() == disabled
|
||||
|
||||
with pytest.raises(ValidationError, match="cron"):
|
||||
store.set_geoip_schedule(cron="ежечасно", actor="ui:admin")
|
||||
with pytest.raises(ValidationError, match="provider"):
|
||||
store.set_geoip_schedule(provider="!", actor="ui:admin")
|
||||
|
||||
entries = store.list_audit(entity=Entity.GEOIP_SCHEDULE.value)
|
||||
assert [entry.action for entry in entries] == ["set_geoip_schedule"] * 2
|
||||
assert entries[0].old_value["provider"] == "ripe"
|
||||
assert entries[0].new_value["provider"] == "maxmind"
|
||||
|
||||
|
||||
# -- конфиги ------------------------------------------------------------------
|
||||
def test_config_upsert_and_mark_applied(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
created = store.upsert_config(
|
||||
ConfigMeta(name="client-peer1", path="/config/peer1.conf", version=1, checksum="abc",
|
||||
size_bytes=512), actor="ui:admin",
|
||||
)
|
||||
assert created.id is not None
|
||||
assert store.get_config("client-peer1") == created
|
||||
|
||||
updated = store.upsert_config(
|
||||
ConfigMeta(name="client-peer1", path="/config/peer1.conf", version=2, checksum="def",
|
||||
size_bytes=640, applied=True), actor="ui:admin",
|
||||
)
|
||||
assert updated.version == 2 and updated.applied is True
|
||||
assert store.counts()["configs"] == 1
|
||||
|
||||
marked = store.mark_config_applied("client-peer1", applied=False, actor="ui:admin")
|
||||
assert marked.applied is False
|
||||
|
||||
entries = store.list_audit(entity=Entity.CONFIG.value)
|
||||
assert [entry.action for entry in entries] == ["set_applied", "update", "create"]
|
||||
assert entries[0].old_value["applied"] is True
|
||||
assert entries[0].new_value["applied"] is False
|
||||
assert entries[1].old_value["version"] == 1
|
||||
assert entries[1].new_value["version"] == 2
|
||||
|
||||
|
||||
def test_config_validation_and_missing(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
with pytest.raises(ValidationError, match="path"):
|
||||
store.upsert_config(ConfigMeta(name="x", path="relative.conf"), actor="system")
|
||||
with pytest.raises(ValidationError, match="path"):
|
||||
store.upsert_config(ConfigMeta(name="x", path="/config/../etc/passwd"), actor="system")
|
||||
with pytest.raises(ValidationError, match="name"):
|
||||
store.upsert_config(ConfigMeta(name="Bad Name", path="/config/x.conf"), actor="system")
|
||||
with pytest.raises(StorageError, match="не найден"):
|
||||
store.mark_config_applied("ghost", applied=True, actor="system")
|
||||
|
||||
|
||||
# -- устройства -----------------------------------------------------------------
|
||||
def test_device_register_normalizes_mac_and_audits(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
created = store.register_device(
|
||||
Device(mac="AA-BB-CC-DD-EE-FF", name="notebook", address="192.168.1.50"),
|
||||
actor="ui:admin",
|
||||
)
|
||||
assert created.mac == "aa:bb:cc:dd:ee:ff"
|
||||
assert store.get_device("AA:BB:CC:DD:EE:FF") == created
|
||||
|
||||
updated = store.register_device(
|
||||
Device(mac="aabbccddeeff", name="notebook", policy="direct", last_seen="2026-01-01T00:00:00.000Z"),
|
||||
actor="ui:admin",
|
||||
)
|
||||
assert updated.id == created.id and updated.policy == "direct"
|
||||
assert store.counts()["devices"] == 1
|
||||
|
||||
assert store.delete_device("aa:bb:cc:dd:ee:ff", actor="ui:admin") is True
|
||||
assert store.delete_device("aa:bb:cc:dd:ee:ff", actor="ui:admin") is False
|
||||
assert store.list_devices() == []
|
||||
|
||||
actions = [entry.action for entry in store.list_audit(entity=Entity.DEVICE.value)]
|
||||
assert actions == ["delete", "update", "create"]
|
||||
update_entry = store.list_audit(entity=Entity.DEVICE.value)[1]
|
||||
assert update_entry.old_value["policy"] == "default"
|
||||
assert update_entry.new_value["policy"] == "direct"
|
||||
|
||||
|
||||
def test_device_validation(tmp_path: Path) -> None:
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
with pytest.raises(ValidationError, match="MAC"):
|
||||
store.register_device(Device(mac="zz:zz", name="x"), actor="system")
|
||||
with pytest.raises(ValidationError, match="policy"):
|
||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="x", policy="кибератака"),
|
||||
actor="system")
|
||||
with pytest.raises(ValidationError, match="name"):
|
||||
store.register_device(Device(mac="aa:bb:cc:dd:ee:ff", name="Not A Name"), actor="system")
|
||||
|
||||
|
||||
def test_audit_actor_recorded_per_mutation(tmp_path: Path) -> None:
|
||||
"""GIVEN изменение через сервисный слой, THEN в audit_log есть actor и old/new."""
|
||||
db, store = make_store(tmp_path)
|
||||
with db:
|
||||
store.set_mode(Mode.SPLIT, actor="api:token-42")
|
||||
entry = store.list_audit(entity=Entity.MODE.value)[0]
|
||||
assert entry.actor == "api:token-42"
|
||||
assert entry.ts.endswith("Z")
|
||||
assert entry.old_value == {"mode": "off"}
|
||||
assert entry.new_value == {"mode": "split"}
|
||||
assert entry.status == "ok"
|
||||
Reference in New Issue
Block a user