Files
group_xinghuo_jinrong/tests/test_sprint0_infrastructure.py
T

247 lines
7.9 KiB
Python

from pathlib import Path
from uuid import uuid4
import pytest
from app.config.database import AgentSessionLocal
from app.model.entities import AuditLog
from app.model.schemas import AuthContext
from app.service.audit_service import AuditService, InMemoryAuditRepository
from app.service.ownership_service import OwnershipService
from app.utils.exceptions import OwnershipDeniedError
ROOT = Path(__file__).resolve().parents[1]
def test_requirements_include_sprint0_approved_dependencies():
requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
for package_name in [
"alembic",
"pytest",
"pytest-asyncio",
"ruff",
"apscheduler",
"reportlab",
]:
assert package_name in requirements
def test_alembic_scaffold_exists_for_jinrong_agent_migrations():
assert (ROOT / "alembic.ini").exists()
assert (ROOT / "alembic" / "env.py").exists()
assert (ROOT / "alembic" / "versions").is_dir()
def test_core_reset_script_supports_core_only_mode_before_sync_tasks():
reset_script = (ROOT / "scripts" / "core" / "reset.ps1").read_text(encoding="utf-8")
assert "[switch]$SkipSync" in reset_script
assert "if (-not $SkipSync)" in reset_script
assert "python scripts/sync/sync_advisor_rel.py" in reset_script
assert "python scripts/sync/sync_neo4j.py" in reset_script
def test_core_reset_script_supports_noninteractive_mysql_password_from_env():
reset_script = (ROOT / "scripts" / "core" / "reset.ps1").read_text(encoding="utf-8")
assert "[string]$MysqlPassword" in reset_script
assert "MYSQL_PASSWORD" in reset_script
assert "--password=$MysqlPassword" in reset_script
assert "mysql @MysqlArgs" in reset_script
def test_core_reset_script_preserves_utf8_sql_and_stops_on_mysql_failure():
reset_script = (ROOT / "scripts" / "core" / "reset.ps1").read_text(encoding="utf-8")
assert "$OutputEncoding" in reset_script
assert "default-character-set=utf8mb4" in reset_script
assert "if ($LASTEXITCODE -ne 0)" in reset_script
def test_sprint0_test_suite_covers_required_quality_gates():
tests_text = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted((ROOT / "tests").glob("test_sprint0_*.py"))
)
required_checks = [
"test_requirements_include_sprint0_approved_dependencies",
"test_alembic_scaffold_exists_for_jinrong_agent_migrations",
"test_core_reset_script_supports_core_only_mode_before_sync_tasks",
"test_sqlalchemy_audit_repository_persists_append_only_event",
"test_trace_id_header_is_reused_in_response_and_mysql_audit_event",
"test_missing_trace_id_generates_response_header_and_mysql_audit_event",
"test_rbac_blocks_advisor_from_admin_audit_endpoint",
"test_market_short_path_is_not_the_formal_api_skeleton",
]
for check_name in required_checks:
assert check_name in tests_text
class FakeCoreRepo:
def __init__(self, assigned: bool) -> None:
self.assigned = assigned
def is_advisor_assigned(self, advisor_id: str, customer_id: str) -> bool:
return self.assigned and advisor_id == "ADV-TEST-001" and customer_id == "CUST-001"
def test_ownership_guard_allows_assigned_advisor_customer():
service = OwnershipService(FakeCoreRepo(assigned=True))
auth = AuthContext(
user_id="advisor_test",
display_name="测试顾问",
roles=["advisor"],
permissions=["advisor:workspace"],
advisor_id="ADV-TEST-001",
trace_id="trace-owner-ok",
)
service.assert_customer_access(auth, "CUST-001")
def test_ownership_guard_denies_unassigned_advisor_customer():
service = OwnershipService(FakeCoreRepo(assigned=False))
auth = AuthContext(
user_id="advisor_test",
display_name="测试顾问",
roles=["advisor"],
permissions=["advisor:workspace"],
advisor_id="ADV-TEST-001",
trace_id="trace-owner-deny",
)
with pytest.raises(OwnershipDeniedError):
service.assert_customer_access(auth, "CUST-999")
def test_audit_service_appends_events_without_mutating_prior_records():
repository = InMemoryAuditRepository()
service = AuditService(repository)
service.record(
trace_id="trace-audit-001",
event_type="auth_login",
actor_id="advisor_test",
decision="success",
)
service.record(
trace_id="trace-audit-002",
event_type="rbac_denied",
actor_id="advisor_test",
decision="audit:read",
)
events = service.list_events()
assert len(events) == 2
assert events[0].trace_id == "trace-audit-001"
assert events[1].trace_id == "trace-audit-002"
def test_sqlalchemy_audit_repository_persists_append_only_event():
from app.service import audit_service as audit_module
trace_id = f"trace-db-{uuid4().hex}"
repository_class = getattr(audit_module, "SqlAlchemyAuditRepository", None)
assert repository_class is not None
repository = repository_class(AgentSessionLocal)
service = AuditService(repository)
service.record(
trace_id=trace_id,
event_type="audit_repository_test",
actor_id="test_runner",
decision="persisted",
input_summary={"source": "pytest"},
)
with AgentSessionLocal() as session:
event = session.query(AuditLog).filter(AuditLog.trace_id == trace_id).one()
assert event.event_type == "audit_repository_test"
assert event.actor_id == "test_runner"
assert event.decision == "persisted"
assert event.input_summary == {"source": "pytest"}
def test_failed_dev_login_records_audit_event_in_mysql():
from fastapi.testclient import TestClient
from app.main import app
trace_id = f"trace-login-failed-{uuid4().hex}"
response = TestClient(app).post(
"/api/v1/auth/login",
json={"username": "advisor_test", "password": "wrong-password"},
headers={"X-Trace-Id": trace_id},
)
assert response.status_code == 401
with AgentSessionLocal() as session:
event = (
session.query(AuditLog)
.filter(AuditLog.trace_id == trace_id, AuditLog.event_type == "auth_login_failed")
.one()
)
assert event.actor_id == "advisor_test"
assert event.decision == "invalid_credentials"
def test_trace_id_header_is_reused_in_response_and_mysql_audit_event():
from fastapi.testclient import TestClient
from app.main import app
trace_id = f"trace-rbac-db-{uuid4().hex}"
login = TestClient(app).post(
"/api/v1/auth/login",
json={"username": "advisor_test", "password": "advisor_test"},
)
token = login.json()["data"]["access_token"]
response = TestClient(app).get(
"/api/v1/admin/audit-logs",
headers={"Authorization": f"Bearer {token}", "X-Trace-Id": trace_id},
)
assert response.status_code == 403
assert response.headers["X-Trace-Id"] == trace_id
assert response.json()["trace_id"] == trace_id
with AgentSessionLocal() as session:
event = (
session.query(AuditLog)
.filter(AuditLog.trace_id == trace_id, AuditLog.event_type == "rbac_denied")
.one()
)
assert event.actor_id == "advisor_test"
assert event.decision == "audit:read"
def test_missing_trace_id_generates_response_header_and_mysql_audit_event():
from fastapi.testclient import TestClient
from app.main import app
response = TestClient(app).get("/api/v1/auth/me")
assert response.status_code == 401
generated_trace_id = response.headers["X-Trace-Id"]
assert generated_trace_id
assert response.json()["trace_id"] == generated_trace_id
with AgentSessionLocal() as session:
event = (
session.query(AuditLog)
.filter(AuditLog.trace_id == generated_trace_id, AuditLog.event_type == "auth_failed")
.one()
)
assert event.actor_id == "anonymous"
assert event.decision == "missing_token"