Files
microfish/backend/tests/test_audit_service.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

128 lines
4.1 KiB
Python

"""TDD gate: durable, redacted audit event recording.
Audit events must be tenant-scoped and never store secrets, tokens, password
hashes, or raw prompts. This gate proves an AuditService that records actions
and lists them scoped to an organization.
"""
import pytest
from sqlalchemy import create_engine
from app.db import Base, create_session_factory
from app.services.audit_service import AuditService
@pytest.fixture()
def session_factory():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
def test_audit_service_records_and_lists_scoped(session_factory):
session = session_factory()
try:
svc = AuditService(session)
entry_id = svc.record(
organization_id="org-a",
actor_user_id="user-1",
action="user.role_changed",
target_type="user",
target_id="user-2",
details={"role": "admin"},
)
assert entry_id
rows = svc.list_for_organization(organization_id="org-a")
assert len(rows) == 1
assert rows[0].action == "user.role_changed"
assert rows[0].actor_user_id == "user-1"
# Scoped list from another org does not see it.
other = svc.list_for_organization(organization_id="org-b")
assert other == []
finally:
session.close()
def test_audit_service_redacts_secrets_from_details(session_factory):
session = session_factory()
try:
svc = AuditService(session)
svc.record(
organization_id="org-a",
actor_user_id="user-1",
action="auth.password_changed",
target_type="user",
target_id="user-2",
details={"password": "hunter2", "token": "abc", "api_key": "sk-xyz"},
)
row = svc.list_for_organization(organization_id="org-a")[0]
details = row.details if isinstance(row.details, dict) else {}
blob = repr(details)
assert "hunter2" not in blob
assert "abc" not in blob
assert "sk-xyz" not in blob
# Redaction leaves non-sensitive context intact.
assert details == {}
finally:
session.close()
def test_login_endpoint_writes_audit_event(tmp_path, monkeypatch, session_factory):
"""A successful login records an auth.login audit event."""
import secrets
from flask import Flask, jsonify
from app.api.auth import auth_bp
from app.services.audit_service import AuditService
from app.services.identity import IdentityRepository, PasswordService
from app.utils.api_errors import ApiError
from app.utils.locale import t
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
app = Flask(__name__)
app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False)
app.config["SECRET_KEY"] = secrets.token_hex(32)
app.extensions["crowdsight_session_factory"] = factory
@app.errorhandler(ApiError)
def handle_api_error(error):
return jsonify(error.to_payload(t)), error.status_code
app.register_blueprint(auth_bp, url_prefix="/api/auth")
audit_email = "audit-login@example.com"
with factory() as session:
repo = IdentityRepository(session)
org = repo.create_organization(name="Audit Org", slug="audit-org")
user = repo.create_user(
email=audit_email,
password_hash=PasswordService.hash_password("correct-horse"),
)
repo.create_membership(user.id, org.id, "user")
session.commit()
user_id = user.id
org_id = org.id
client = app.test_client()
r = client.post(
"/api/auth/login",
json={"email": audit_email, "password": "correct-horse"},
)
assert r.status_code == 200
# The audit event exists and is redacted/org-scoped.
with factory() as session:
events = AuditService(session).list_for_organization(organization_id=org_id)
assert any(e.action == "auth.login" and e.actor_user_id == user_id for e in events)
engine.dispose()