"""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()