fischer-agentkit/tests/unit/test_auth_audit.py

209 lines
7.5 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.

"""Audit service 单元测试 (U4 — RBAC + deprovisioning 审计, KTD-8)。
验证:
- RBAC 角色变更审计记录actor/target/role_before/role_after
- Deprovisioning 审计记录actor/target/reason/source
- 审计记录持久化到 auth_audit_log 表
- 按目标用户查询审计历史
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import aiosqlite
import pytest
from agentkit.server.auth.audit import (
AuditService,
EVENT_DEPROVISION,
EVENT_ROLE_CHANGE,
SOURCE_BREAKGLASS,
SOURCE_MANUAL,
get_audit_service,
set_audit_service,
)
from agentkit.server.auth.models import audit_log_row_to_dict, init_auth_db
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def auth_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
db_path = tmp_path / "auth.db"
await init_auth_db(db_path)
monkeypatch.setenv("AGENTKIT_AUTH_DB", str(db_path))
return db_path
@pytest.fixture
def audit_service(auth_db: Path) -> AuditService:
svc = AuditService(db_path=auth_db)
set_audit_service(svc)
return svc
@pytest.fixture
async def seed_users(auth_db: Path) -> dict[str, str]:
"""插入两个用户admin + target用于审计测试。"""
now = datetime.now(timezone.utc).isoformat()
admin_id = str(uuid4())
target_id = str(uuid4())
async with aiosqlite.connect(str(auth_db)) as db:
await db.execute(
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(admin_id, "admin_alice", "alice@example.com", "hash", "admin", 1, 0, 0, now, now),
)
await db.execute(
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(target_id, "target_bob", "bob@example.com", "hash", "member", 1, 0, 0, now, now),
)
await db.commit()
return {"admin_id": admin_id, "target_id": target_id}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestRoleChangeAudit:
async def test_records_role_change(
self, audit_service: AuditService, seed_users: dict[str, str]
) -> None:
record = await audit_service.record_role_change(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=seed_users["target_id"],
target_username="target_bob",
role_before="member",
role_after="operator",
reason="晋升",
source=SOURCE_MANUAL,
)
assert record["event_type"] == EVENT_ROLE_CHANGE
assert record["role_before"] == "member"
assert record["role_after"] == "operator"
assert record["actor_username"] == "admin_alice"
assert record["target_username"] == "target_bob"
assert record["reason"] == "晋升"
async def test_persists_to_db(
self, audit_service: AuditService, auth_db: Path, seed_users: dict[str, str]
) -> None:
await audit_service.record_role_change(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=seed_users["target_id"],
target_username="target_bob",
role_before="member",
role_after="admin",
reason="提升管理员",
)
async with aiosqlite.connect(str(auth_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM auth_audit_log WHERE event_type = ? AND target_user_id = ?",
(EVENT_ROLE_CHANGE, seed_users["target_id"]),
)
rows = await cursor.fetchall()
assert len(rows) == 1
row = audit_log_row_to_dict(rows[0])
assert row["role_before"] == "member"
assert row["role_after"] == "admin"
class TestDeprovisionAudit:
async def test_records_deprovision(
self, audit_service: AuditService, seed_users: dict[str, str]
) -> None:
record = await audit_service.record_deprovision(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=seed_users["target_id"],
target_username="target_bob",
reason="离职",
source=SOURCE_MANUAL,
)
assert record["event_type"] == EVENT_DEPROVISION
assert record["reason"] == "离职"
assert record["source"] == SOURCE_MANUAL
async def test_break_glass_source(
self, audit_service: AuditService, seed_users: dict[str, str]
) -> None:
"""break_glass 应急通道 — source 记录为 break_glass。"""
record = await audit_service.record_deprovision(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=seed_users["target_id"],
target_username="target_bob",
reason="应急禁用",
source=SOURCE_BREAKGLASS,
)
assert record["source"] == SOURCE_BREAKGLASS
async def test_persists_to_db(
self, audit_service: AuditService, auth_db: Path, seed_users: dict[str, str]
) -> None:
await audit_service.record_deprovision(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=seed_users["target_id"],
target_username="target_bob",
reason="审计测试",
)
async with aiosqlite.connect(str(auth_db)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM auth_audit_log WHERE event_type = ?",
(EVENT_DEPROVISION,),
)
rows = await cursor.fetchall()
assert len(rows) == 1
class TestListForTarget:
async def test_lists_audit_history(
self, audit_service: AuditService, seed_users: dict[str, str]
) -> None:
"""按目标用户查询审计历史admin 视图)。"""
target_id = seed_users["target_id"]
await audit_service.record_role_change(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=target_id,
target_username="target_bob",
role_before="member",
role_after="operator",
reason="第一次",
)
await audit_service.record_deprovision(
actor_user_id=seed_users["admin_id"],
actor_username="admin_alice",
target_user_id=target_id,
target_username="target_bob",
reason="第二次",
)
records = await audit_service.list_for_target(target_id)
assert len(records) == 2
# 按 created_at DESC 排序 — deprovision 在前
assert records[0]["event_type"] == EVENT_DEPROVISION
assert records[1]["event_type"] == EVENT_ROLE_CHANGE
class TestGetAuditService:
def test_singleton(self) -> None:
svc1 = get_audit_service()
svc2 = get_audit_service()
assert svc1 is svc2