Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
331
tests/test_audit.py
Normal file
331
tests/test_audit.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the Loi 25 audit system.
|
||||
|
||||
Covers:
|
||||
- audit_access(): adds to session, does NOT commit
|
||||
- audit_login(): commits independently
|
||||
- audit_failed_login(): commits, uses email_hash (not plain email)
|
||||
- _is_recent_duplicate(): deduplication window
|
||||
- get_access_logs() / get_auth_logs(): pagination and filters
|
||||
- Admin API endpoints: /api/admin/audit/status, /access, /auth
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
|
||||
# Add the parent directory to the path to import app
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||
os.environ.setdefault('ENABLE_AUDIT_LOG', 'true')
|
||||
os.environ['ENABLE_AUDIT_LOG'] = 'true'
|
||||
|
||||
from src.app import app, db
|
||||
from src.models import User
|
||||
from src.models.access_log import AccessLog
|
||||
from src.models.auth_log import AuthLog
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_user(username, is_admin=False):
|
||||
user = User(username=username, email=f"{username}@test.local", is_admin=is_admin)
|
||||
user.set_password("TestPass1!")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login_client(client, user):
|
||||
"""Push a real Flask-Login session for a user via test request context."""
|
||||
from flask_login import login_user
|
||||
with client.session_transaction() as sess:
|
||||
pass
|
||||
# Use the test client's post to login
|
||||
with app.test_request_context():
|
||||
login_user(user)
|
||||
# Directly inject the user_id into the session cookie
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service-level tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_audit_access_no_commit():
|
||||
"""audit_access() adds to session but does NOT commit."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = _make_user("audit_no_commit")
|
||||
try:
|
||||
initial_count = AccessLog.query.count()
|
||||
|
||||
from src.services.audit import audit_access
|
||||
with app.test_request_context():
|
||||
from flask_login import login_user
|
||||
login_user(user)
|
||||
log = audit_access('edit', 'recording', 1, user_id=user.id)
|
||||
|
||||
# Log object returned but not yet in DB (no commit happened)
|
||||
assert log is not None, "audit_access should return a log object"
|
||||
# The session hasn't been committed, so count is still the same
|
||||
# (SQLite in :memory: — flush to verify it's in session)
|
||||
db.session.flush()
|
||||
assert AccessLog.query.count() == initial_count + 1, "Log should be in session after flush"
|
||||
db.session.rollback() # undo — simulates caller rollback
|
||||
assert AccessLog.query.count() == initial_count, "Log should be gone after rollback"
|
||||
|
||||
print("✅ audit_access() does not commit")
|
||||
return True
|
||||
finally:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_audit_login_commits():
|
||||
"""audit_login() commits its own transaction."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = _make_user("audit_login_user")
|
||||
try:
|
||||
initial_count = AuthLog.query.count()
|
||||
|
||||
from src.services.audit import audit_login
|
||||
with app.test_request_context():
|
||||
audit_login(user.id)
|
||||
|
||||
# Should be committed — visible in a fresh query
|
||||
assert AuthLog.query.count() == initial_count + 1, "auth log should be committed"
|
||||
log = AuthLog.query.order_by(AuthLog.id.desc()).first()
|
||||
assert log.action == 'login'
|
||||
assert log.user_id == user.id
|
||||
|
||||
print("✅ audit_login() commits independently")
|
||||
return True
|
||||
finally:
|
||||
AuthLog.query.filter_by(user_id=user.id).delete()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_audit_failed_login_uses_email_hash():
|
||||
"""audit_failed_login() stores email_hash, not plain email."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
initial_count = AuthLog.query.count()
|
||||
email = "attacker-target@example.com"
|
||||
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
|
||||
|
||||
from src.services.audit import audit_failed_login
|
||||
with app.test_request_context():
|
||||
audit_failed_login(details={'email_hash': email_hash, 'reason': 'wrong_password'})
|
||||
|
||||
assert AuthLog.query.count() == initial_count + 1
|
||||
log = AuthLog.query.order_by(AuthLog.id.desc()).first()
|
||||
assert log.action == 'failed_login'
|
||||
assert log.details is not None
|
||||
assert 'email_hash' in log.details, "Should store email_hash, not plain email"
|
||||
assert 'email' not in log.details, "Should NOT store plain email"
|
||||
assert log.details['email_hash'] == email_hash
|
||||
|
||||
# Cleanup
|
||||
db.session.delete(log)
|
||||
db.session.commit()
|
||||
|
||||
print("✅ audit_failed_login() stores email_hash, not plain email")
|
||||
return True
|
||||
|
||||
|
||||
def test_audit_view_deduplication():
|
||||
"""audit_view() on the same resource within 5 min creates only one log entry."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = _make_user("audit_dedup_user")
|
||||
try:
|
||||
initial_count = AccessLog.query.count()
|
||||
|
||||
from src.services.audit import audit_view
|
||||
with app.test_request_context():
|
||||
from flask_login import login_user
|
||||
login_user(user)
|
||||
# First view — should log
|
||||
log1 = audit_view('recording', 42, user_id=user.id)
|
||||
db.session.commit()
|
||||
# Second view within 5 min — should be deduped
|
||||
log2 = audit_view('recording', 42, user_id=user.id)
|
||||
if log2 is not None:
|
||||
db.session.commit()
|
||||
|
||||
count_after = AccessLog.query.filter_by(
|
||||
user_id=user.id, action='view', resource_type='recording', resource_id=42
|
||||
).count()
|
||||
assert count_after == 1, f"Expected 1 log entry, got {count_after} (dedup failed)"
|
||||
|
||||
print("✅ audit_view() deduplication works (5-min window)")
|
||||
return True
|
||||
finally:
|
||||
AccessLog.query.filter_by(user_id=user.id).delete()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_get_access_logs_pagination():
|
||||
"""get_access_logs() returns paginated results."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = _make_user("audit_pag_user")
|
||||
try:
|
||||
# Create 5 access log entries
|
||||
for i in range(5):
|
||||
log = AccessLog.log_access(
|
||||
action='view', resource_type='recording', resource_id=i,
|
||||
user_id=user.id, status='success',
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
|
||||
from src.services.audit import get_access_logs
|
||||
page1 = get_access_logs(page=1, per_page=3, user_id=user.id)
|
||||
assert page1.total >= 5
|
||||
assert len(page1.items) == 3
|
||||
|
||||
page2 = get_access_logs(page=2, per_page=3, user_id=user.id)
|
||||
assert len(page2.items) >= 2
|
||||
|
||||
print("✅ get_access_logs() pagination works")
|
||||
return True
|
||||
finally:
|
||||
AccessLog.query.filter_by(user_id=user.id).delete()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_audit_status_requires_admin():
|
||||
"""GET /api/admin/audit/status: 401 anon, 403 non-admin, 200 admin."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
regular = _make_user("audit_status_regular")
|
||||
admin = _make_user("audit_status_admin", is_admin=True)
|
||||
client = app.test_client()
|
||||
|
||||
try:
|
||||
# Anonymous — should redirect to login (302) or 401
|
||||
resp = client.get('/api/admin/audit/status')
|
||||
assert resp.status_code in (401, 302), f"Expected 401/302 for anon, got {resp.status_code}"
|
||||
|
||||
# Regular user — should get 403
|
||||
_login_client(client, regular)
|
||||
resp = client.get('/api/admin/audit/status')
|
||||
assert resp.status_code == 403, f"Expected 403 for non-admin, got {resp.status_code}"
|
||||
|
||||
# Admin — should get 200
|
||||
_login_client(client, admin)
|
||||
resp = client.get('/api/admin/audit/status')
|
||||
assert resp.status_code == 200, f"Expected 200 for admin, got {resp.status_code}"
|
||||
data = resp.get_json()
|
||||
assert 'enabled' in data
|
||||
|
||||
print("✅ /api/admin/audit/status access control works")
|
||||
return True
|
||||
finally:
|
||||
db.session.delete(regular)
|
||||
db.session.delete(admin)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_audit_access_logs_endpoint():
|
||||
"""GET /api/admin/audit/access returns paginated logs for admin."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
admin = _make_user("audit_access_ep_admin", is_admin=True)
|
||||
client = app.test_client()
|
||||
|
||||
try:
|
||||
_login_client(client, admin)
|
||||
resp = client.get('/api/admin/audit/access?per_page=10')
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||
data = resp.get_json()
|
||||
assert 'logs' in data
|
||||
assert 'total' in data
|
||||
assert 'page' in data
|
||||
|
||||
print("✅ /api/admin/audit/access returns correct structure")
|
||||
return True
|
||||
finally:
|
||||
db.session.delete(admin)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_audit_auth_logs_endpoint():
|
||||
"""GET /api/admin/audit/auth returns paginated auth logs for admin."""
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
admin = _make_user("audit_auth_ep_admin", is_admin=True)
|
||||
client = app.test_client()
|
||||
|
||||
try:
|
||||
_login_client(client, admin)
|
||||
resp = client.get('/api/admin/audit/auth?per_page=10')
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||
data = resp.get_json()
|
||||
assert 'logs' in data
|
||||
assert 'total' in data
|
||||
|
||||
print("✅ /api/admin/audit/auth returns correct structure")
|
||||
return True
|
||||
finally:
|
||||
db.session.delete(admin)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("🚀 Running audit system tests...\n")
|
||||
tests = [
|
||||
test_audit_access_no_commit,
|
||||
test_audit_login_commits,
|
||||
test_audit_failed_login_uses_email_hash,
|
||||
test_audit_view_deduplication,
|
||||
test_get_access_logs_pagination,
|
||||
test_audit_status_requires_admin,
|
||||
test_audit_access_logs_endpoint,
|
||||
test_audit_auth_logs_endpoint,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
result = test()
|
||||
if result:
|
||||
passed += 1
|
||||
else:
|
||||
print(f"❌ {test.__name__} returned False")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"❌ {test.__name__} raised: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*40}")
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
print("✅ ALL PASS" if failed == 0 else "❌ SOME FAILED")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user