247 lines
7.9 KiB
Python
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"
|