209 lines
7.5 KiB
Python
209 lines
7.5 KiB
Python
"""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
|