arm-core/api/tests/test_audit_service.py
2026-10-03 23:30:27 +04:00

443 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис аудита (spec audit-trail, задача 21): системное событие, откат, append-only, статический запрет UPDATE/DELETE.
Быстрые тесты (валидация, сканер, статический обход `src/`) идут без БД; интеграционные — на `db_session`.
"""
import ast
import datetime
import inspect
import re
import uuid
from decimal import Decimal
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.directories import RegionalOffice
from src.models.enums import AuditAction, AuditSection
from src.services.audit import AuditService, to_jsonable
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
# ---------------------------------------------------------------------------------------------------------------------
# Статический сканер: обращения, изменяющие или удаляющие audit_event
# ---------------------------------------------------------------------------------------------------------------------
MUTATING_CALLS = {"update", "delete"}
AUDIT_FIELDS = {
"occurred_at",
"user_id",
"action",
"section",
"object_type",
"object_id",
"consolidation_version_id",
"description",
"before_value",
"after_value",
"comment",
"result",
"changed_field",
"ip_address",
"user_agent",
"id",
}
RAW_SQL_MUTATION = re.compile(
r"\b(update|delete\s+from|truncate(\s+table)?)\s+(only\s+)?(\"?dkb\"?\.)?\"?audit_event\b", re.I
)
def _call_name(node: ast.Call) -> str | None:
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return None
def _audit_event_aliases(tree: ast.AST) -> set[str]:
"""Имена AuditEvent в модуле: сам `AuditEvent`, `from ... import AuditEvent as AE`, `AE2 = AE` (до фикспойнта)."""
aliases = {"AuditEvent"}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
aliases.update(alias.asname or alias.name for alias in node.names if alias.name == "AuditEvent")
changed = True
while changed:
changed = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and _mentions(node.value, aliases) and not isinstance(node.value, ast.Call):
for target in node.targets:
if isinstance(target, ast.Name) and target.id not in aliases:
aliases.add(target.id)
changed = True
return aliases
def _mutating_names(tree: ast.AST) -> set[str]:
"""`update`/`delete` и их импортные псевдонимы (`from sqlalchemy import update as upd`)."""
names = set(MUTATING_CALLS)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
names.update(alias.asname for alias in node.names if alias.name in MUTATING_CALLS and alias.asname)
return names
def _mentions(node: ast.AST, aliases: set[str]) -> bool:
return any(
(isinstance(sub, ast.Name) and sub.id in aliases) or (isinstance(sub, ast.Attribute) and sub.attr in aliases)
for sub in ast.walk(node)
)
def _is_audit_event_expr(node: ast.AST | None, aliases: set[str]) -> bool:
"""Выражение, дающее экземпляр AuditEvent: `AuditEvent(...)` / `mod.AuditEvent(...)` / `AE(...)`."""
return isinstance(node, ast.Call) and _mentions(node.func, aliases)
def _tainted_names(tree: ast.AST, aliases: set[str]) -> set[str]:
"""Имена, связанные с экземплярами AuditEvent: присваивание `AuditEvent(...)`, аннотация `: AuditEvent`."""
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and _is_audit_event_expr(node.value, aliases):
names.update(t.id for t in node.targets if isinstance(t, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if _mentions(node.annotation, aliases) or _is_audit_event_expr(node.value, aliases):
names.add(node.target.id)
elif isinstance(node, ast.arg) and node.annotation is not None and _mentions(node.annotation, aliases):
names.add(node.arg)
return names
def find_audit_event_mutations(source: str, filename: str = "<src>") -> list[str]:
"""Находки вида `file:line: описание` — любое изменение/удаление audit_event в исходнике.
Ловит (best-effort, статически, в пределах одного модуля):
- `update(AuditEvent...)` / `delete(AuditEvent...)`, в т.ч. `sa.update`, `update as upd`, `AuditEvent.__table__`
как аргумент; псевдонимы `from ... import AuditEvent as AE` и `AE = AuditEvent`;
- вызов `.update()`/`.delete()`, где приёмник упоминает AuditEvent: `AuditEvent.__table__.update()`,
`session.query(AuditEvent).update(...)/.delete()`;
- `.delete(x)`/`delete(x)`, где x — экземпляр AuditEvent (имя связано с `AuditEvent(...)` или аннотацией);
- присваивание/удаление атрибутов таких экземпляров и `AuditEvent.<поле> = ...`;
- сырой SQL UPDATE/DELETE/TRUNCATE по audit_event в строковой константе.
НЕ ловит: объекты, загруженные из БД без аннотации (`obj = (await s.execute(select(...))).scalar_one()`),
`setattr`, `session.merge()`, SQL, собранный конкатенацией/f-строкой, межмодульный поток типов. Защита на уровне
БД (триггер/REVOKE UPDATE, DELETE) в Срез 0 не входит.
"""
tree = ast.parse(source, filename=filename)
aliases = _audit_event_aliases(tree)
tainted = _tainted_names(tree, aliases)
mutating = _mutating_names(tree)
findings: list[str] = []
def add(node: ast.AST, message: str) -> None:
findings.append(f"{filename}:{getattr(node, 'lineno', 0)}: {message}")
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = _call_name(node)
args: list[ast.AST] = [*node.args, *(kw.value for kw in node.keywords)]
if name in mutating:
receiver = node.func.value if isinstance(node.func, ast.Attribute) else None
if any(_mentions(arg, aliases) for arg in args):
add(node, f"{name}() по AuditEvent")
elif receiver is not None and _mentions(receiver, aliases):
add(node, f".{name}() на выражении с AuditEvent")
elif any(isinstance(arg, ast.Name) and arg.id in tainted for arg in args):
add(node, f"{name}() экземпляра AuditEvent")
elif isinstance(node, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Delete):
targets = node.targets if isinstance(node, ast.Assign | ast.Delete) else [node.target]
for target in targets:
if isinstance(target, ast.Attribute) and target.attr in AUDIT_FIELDS:
base = target.value
if (
isinstance(base, ast.Name) and (base.id in tainted or base.id in aliases)
) or _is_audit_event_expr(base, aliases):
add(node, f"изменение поля {target.attr} у AuditEvent")
elif isinstance(node, ast.Constant) and isinstance(node.value, str) and RAW_SQL_MUTATION.search(node.value):
add(node, "сырой SQL, изменяющий audit_event")
return findings
def scan_directory(root: Path) -> list[str]:
findings: list[str] = []
for path in sorted(root.rglob("*.py")):
findings.extend(find_audit_event_mutations(path.read_text(encoding="utf-8"), str(path.relative_to(root))))
return findings
def test_src_has_no_audit_event_update_or_delete() -> None:
assert SRC_DIR.is_dir()
assert list(SRC_DIR.rglob("audit.py")), "сканер должен видеть src/services/audit.py"
assert scan_directory(SRC_DIR) == []
@pytest.mark.parametrize(
"source",
[
"from sqlalchemy import update\nstmt = update(AuditEvent).values(comment='x')",
"import sqlalchemy as sa\nstmt = sa.delete(AuditEvent).where(AuditEvent.id == 1)",
"stmt = update(AuditEvent.__table__).values(result='x')",
"stmt = delete(models.audit_ex.AuditEvent)",
"async def f(session):\n event = AuditEvent(object_type='x')\n await session.delete(event)",
"def f(event: AuditEvent):\n event.result = 'changed'",
"def f(s):\n e = AuditEvent(object_type='x')\n e.comment = 'x'",
"def f(s):\n e = AuditEvent(object_type='x')\n del e.comment",
"AuditEvent.comment = 'x'",
"stmt = text('UPDATE dkb.audit_event SET comment = 1')",
"stmt = text('delete from audit_event')",
"stmt = text('TRUNCATE TABLE dkb.audit_event')",
# обходы через псевдонимы
"from src.models.audit_ex import AuditEvent as AE\nstmt = delete(AE)",
"from src.models.audit_ex import AuditEvent as AE\nstmt = sa.update(AE).values(result='x')",
"from src.models.audit_ex import AuditEvent as AE\nAE.comment = 'x'",
"from src.models.audit_ex import AuditEvent as AE\ndef f(s):\n e = AE(object_type='x')\n s.delete(e)",
"AE = AuditEvent\nstmt = update(AE)",
"from sqlalchemy import update as upd\nstmt = upd(AuditEvent)",
# __table__ как приёмник
"stmt = AuditEvent.__table__.update().values(result='x')",
"stmt = AuditEvent.__table__.delete()",
# устаревший Query API
"def f(s):\n s.query(AuditEvent).update({'result': 'x'})",
"def f(s):\n s.query(AuditEvent).filter(AuditEvent.id == 1).delete()",
"def f(s):\n from src.models.audit_ex import AuditEvent as AE\n s.query(AE).delete()",
],
)
def test_scanner_detects_violation(source: str) -> None:
assert find_audit_event_mutations(source), f"сканер пропустил нарушение: {source!r}"
@pytest.mark.parametrize(
"source",
[
"stmt = select(AuditEvent).where(AuditEvent.id == 1)",
"event = AuditEvent(object_type='x')\nsession.add(event)",
"stmt = update(RegionalOffice).values(name='x')",
"async def f(session, office):\n await session.delete(office)",
"def f(office):\n office.comment = 'x'",
"stmt = text('select * from audit_event')",
"from src.models.audit_ex import AuditEvent as AE\nstmt = select(AE).where(AE.id == 1)",
"stmt = select(AuditEvent.__table__)",
"def f(s):\n return s.query(AuditEvent).filter(AuditEvent.id == 1).all()",
"def f(s):\n s.query(RegionalOffice).update({'name': 'x'})\n s.query(RegionalOffice).delete()",
"async def f(session):\n await AuditService(session).record(action=A, section=S, object_type='x')",
"from src.services.audit import AuditService\nservice = AuditService(session)\nservice.update_cache()",
"def f(d):\n d.update({'a': 1})\n d.get('x').update({})",
],
)
def test_scanner_passes_allowed_code(source: str) -> None:
assert find_audit_event_mutations(source) == []
def test_scan_directory_fails_on_synthetic_violation(tmp_path: Path) -> None:
(tmp_path / "ok.py").write_text("x = 1\n", encoding="utf-8")
sub = tmp_path / "services"
sub.mkdir()
(sub / "bad.py").write_text("from sqlalchemy import update\nstmt = update(AuditEvent)\n", encoding="utf-8")
findings = scan_directory(tmp_path)
assert len(findings) == 1
assert findings[0].startswith("services/bad.py:2:")
# ---------------------------------------------------------------------------------------------------------------------
# Append-only и валидация (без БД)
# ---------------------------------------------------------------------------------------------------------------------
def test_service_exposes_only_record() -> None:
public = {name for name in dir(AuditService) if not name.startswith("_")}
assert public == {"record"}
async def test_record_rejects_empty_object_type() -> None:
service = AuditService(AsyncMock(spec=AsyncSession))
for bad in ("", " "):
with pytest.raises(ValueError, match="object_type"):
await service.record(action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type=bad)
async def test_record_rejects_string_enums() -> None:
session = AsyncMock(spec=AsyncSession)
service = AuditService(session)
with pytest.raises(TypeError, match="action"):
await service.record(action="LOGIN", section=AuditSection.SESSION, object_type="app_user") # type: ignore[arg-type]
with pytest.raises(TypeError, match="section"):
await service.record(action=AuditAction.LOGIN, section="SESSION", object_type="app_user") # type: ignore[arg-type]
session.add.assert_not_called()
async def test_record_rejects_non_mapping_payload_and_unknown_types() -> None:
service = AuditService(AsyncMock(spec=AsyncSession))
with pytest.raises(TypeError, match="before_value"):
await service.record(
action=AuditAction.LOGIN,
section=AuditSection.SESSION,
object_type="app_user",
before_value=["x"], # type: ignore[arg-type]
)
with pytest.raises(TypeError, match="object"):
await service.record(
action=AuditAction.LOGIN,
section=AuditSection.SESSION,
object_type="app_user",
after_value={"x": object()},
)
def test_record_does_not_accept_occurred_at() -> None:
assert "occurred_at" not in inspect.signature(AuditService.record).parameters
def test_to_jsonable_types() -> None:
uid = uuid.UUID("12345678-1234-5678-1234-567812345678")
moment = datetime.datetime(2026, 10, 3, 12, 0, tzinfo=datetime.UTC)
result = to_jsonable(
{
"sum": Decimal("106311116.46"),
"tiny": Decimal("1E+2"),
"id": uid,
"at": moment,
"day": datetime.date(2026, 10, 3),
"action": AuditAction.LOGIN,
"nested": [{"k": Decimal("0.10")}, (1, 2)],
3: None,
}
)
assert result == {
"sum": "106311116.46",
"tiny": "100",
"id": str(uid),
"at": moment.isoformat(),
"day": "2026-10-03",
"action": "LOGIN",
"nested": [{"k": "0.10"}, [1, 2]],
"3": None,
}
with pytest.raises(ValueError, match="NaN"):
to_jsonable({"x": float("nan")})
# ---------------------------------------------------------------------------------------------------------------------
# Интеграция: запись в текущую транзакцию
# ---------------------------------------------------------------------------------------------------------------------
async def _count_events(session: AsyncSession, object_id: uuid.UUID) -> int:
return (
await session.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.object_id == object_id)) or 0
)
async def _count_offices(session: AsyncSession, name: str) -> int:
return (
await session.scalar(select(func.count()).select_from(RegionalOffice).where(RegionalOffice.name == name)) or 0
)
@pytest.mark.integration
async def test_system_event_without_user(db_session: AsyncSession) -> None:
# action/section выбраны произвольно: тест проверяет user_id=NULL и jsonb, а не предметный сценарий (по R-7
# обмен recognition_job audit_event в Срезе 0 не пишет)
before = datetime.datetime.now(datetime.UTC)
event = await AuditService(db_session).record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type="recognition_job",
user_id=None,
before_value={"status": "queued"},
after_value={"status": "published"},
)
assert event.id is not None
await db_session.refresh(event) # перечитать строку из БД (jsonb round-trip)
row = (await db_session.execute(select(AuditEvent).where(AuditEvent.id == event.id))).scalar_one()
assert row.user_id is None
assert row.action is AuditAction.RECOGNIZED_DATA_EDIT
assert row.section is AuditSection.RECOGNITION
assert row.before_value == {"status": "queued"}
assert row.after_value == {"status": "published"}
assert row.occurred_at.tzinfo is not None
assert row.occurred_at.utcoffset() == datetime.timedelta(0)
assert (
before - datetime.timedelta(seconds=5)
<= row.occurred_at
<= datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=5)
)
@pytest.mark.integration
async def test_payload_decimal_uuid_datetime_stored_as_strings(db_session: AsyncSession) -> None:
uid = uuid.uuid4()
moment = datetime.datetime(2026, 10, 3, 9, 30, 15, tzinfo=datetime.UTC)
event = await AuditService(db_session).record(
action=AuditAction.CORRECTION_CREATE,
section=AuditSection.CONSOLIDATION,
object_type="correction",
object_id=uid,
after_value={"amount": Decimal("106311116.46"), "ref": uid, "at": moment, "kind": AuditSection.KFO},
changed_field="amount",
comment="ручная корректировка",
result="ok",
ip_address="10.0.0.1",
user_agent="pytest",
description="тест",
)
await db_session.refresh(event)
assert event.after_value == {
"amount": "106311116.46",
"ref": str(uid),
"at": moment.isoformat(),
"kind": "KFO",
}
assert isinstance(event.after_value["amount"], str)
assert (event.object_id, event.changed_field, event.comment, event.result) == (
uid,
"amount",
"ручная корректировка",
"ok",
)
assert (event.ip_address, event.user_agent, event.description) == ("10.0.0.1", "pytest", "тест")
@pytest.mark.integration
async def test_rollback_removes_audit_with_data(db_session: AsyncSession) -> None:
office_name = f"Филиал-аудит-{uuid.uuid4().hex[:8]}"
object_id = uuid.uuid4()
with pytest.raises(RuntimeError, match="отмена"):
async with db_session.begin_nested(): # SAVEPOINT: откат при исключении, как откат транзакции запроса
db_session.add(RegionalOffice(name=office_name))
event = await AuditService(db_session).record(
action=AuditAction.DIRECTORY_IMPORT,
section=AuditSection.DIRECTORIES,
object_type="regional_office",
object_id=object_id,
)
# внутри транзакции запись и данные видны
assert event.occurred_at is not None
seen = await _count_events(db_session, object_id)
assert seen == 1
assert await _count_offices(db_session, office_name) == 1
raise RuntimeError("отмена")
assert await _count_events(db_session, object_id) == 0
assert await _count_offices(db_session, office_name) == 0
@pytest.mark.integration
async def test_record_does_not_commit_and_external_rollback_discards(db_session: AsyncSession) -> None:
object_id = uuid.uuid4()
await AuditService(db_session).record(
action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type="app_user", object_id=object_id
)
assert db_session.in_transaction() # record не закрыл транзакцию (нет commit)
assert await _count_events(db_session, object_id) == 1
await db_session.rollback() # внешний откат (в фикстуре — к SAVEPOINT) уничтожает запись
assert await _count_events(db_session, object_id) == 0