- Added new modules for advisor compliance, KYC sessions, and script templates, enhancing the advisor agent's capabilities. - Implemented a comprehensive API structure under the `/api/advisor-agent` prefix, ensuring clear organization and access to new features. - Established database models and repositories for compliance rules and KYC sessions, facilitating robust data management. - Integrated exception handling and response models to improve error management and user feedback. - Updated settings to include new configurations for compliance and KYC features, ensuring flexibility and adaptability. This update significantly expands the advisor agent's functionality, providing essential tools for compliance and customer interaction while maintaining a structured API design.
209 lines
6.9 KiB
Python
209 lines
6.9 KiB
Python
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.advisor_db import AgentSessionLocal
|
|
from app.advisor_exceptions import OwnershipDeniedError
|
|
from app.api.advisor_auth_adapter import AdvisorAuthContext
|
|
from app.model.entities import AuditLog
|
|
from app.service.audit_service import AuditService, InMemoryAuditRepository
|
|
from app.service.ownership_service import OwnershipService
|
|
|
|
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 [
|
|
"pytest",
|
|
"httpx",
|
|
]:
|
|
assert package_name in requirements
|
|
|
|
def test_advisor_agent_migration_sql_exists():
|
|
path = ROOT / "scripts" / "agent" / "migrate-advisor-agent-sprint1-3.sql"
|
|
assert path.exists()
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "compliance_rule" in text
|
|
assert "kyc_session" in text
|
|
|
|
def test_core_reset_script_runs_core_seed_and_optional_neo4j_sync():
|
|
reset_script = (ROOT / "scripts" / "core" / "reset.ps1").read_text(encoding="utf-8")
|
|
|
|
assert "scripts/core/01-ddl.sql" in reset_script
|
|
assert "python scripts/sync/sync_advisor_rel.py" in reset_script
|
|
assert "[switch]$SkipNeo4j" in reset_script
|
|
assert "if (-not $SkipNeo4j)" in reset_script
|
|
assert "python scripts/sync/sync_neo4j.py" in reset_script
|
|
|
|
|
|
def test_core_reset_script_uses_run_sql_file_helper():
|
|
reset_script = (ROOT / "scripts" / "core" / "reset.ps1").read_text(encoding="utf-8")
|
|
|
|
assert "scripts/dev/run_sql_file.py" in reset_script
|
|
assert "fix_utf8_seed.py" 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_advisor_agent_migration_sql_exists",
|
|
"test_core_reset_script_runs_core_seed_and_optional_neo4j_sync",
|
|
"test_sqlalchemy_audit_repository_persists_append_only_event",
|
|
"test_trace_id_header_is_reused_in_response_and_rbac_audit_event",
|
|
"test_missing_trace_id_generates_response_header_on_unauthenticated_request",
|
|
"test_platform_compliance_suitability_unchanged",
|
|
]
|
|
|
|
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 = AdvisorAuthContext(
|
|
user_id="STAFF-10086",
|
|
token_type="staff",
|
|
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 = AdvisorAuthContext(
|
|
user_id="STAFF-10086",
|
|
token_type="staff",
|
|
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_unknown_staff_login_is_rejected():
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
response = TestClient(app).post(
|
|
"/api/auth/login",
|
|
json={"actor_id": "STAFF-UNKNOWN-ACTOR", "token_type": "staff"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_trace_id_header_is_reused_in_response_and_rbac_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/auth/login",
|
|
json={"actor_id": "STAFF-10086", "token_type": "staff"},
|
|
)
|
|
token = login.json()["data"]["access_token"]
|
|
|
|
response = TestClient(app).post(
|
|
"/api/advisor-agent/compliance/rules",
|
|
json={
|
|
"rule_type": "keyword",
|
|
"pattern": "trace-id-rbac-test",
|
|
"severity": "block",
|
|
"category": "return_promise",
|
|
"suggestion": "no promise",
|
|
},
|
|
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 == "STAFF-10086"
|
|
assert event.decision == "compliance:rule:write"
|
|
|
|
def test_missing_trace_id_generates_response_header_on_unauthenticated_request():
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
response = TestClient(app).get("/api/advisor-agent/compliance/ping")
|
|
|
|
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
|