#!/usr/bin/env python3 """Tests for AuditManager and the audit capture hook / routes.""" import os import sys import json import threading from pathlib import Path from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) from audit_manager import AuditManager # ── manager fixture ─────────────────────────────────────────────────────────── @pytest.fixture def audit(tmp_path): return AuditManager(data_dir=str(tmp_path / 'data'), config_dir=str(tmp_path / 'config')) def _lines(audit): with open(audit._audit_file, 'r', encoding='utf-8') as f: return [l for l in f.read().splitlines() if l.strip()] # ── record / schema ─────────────────────────────────────────────────────────── def test_record_writes_one_jsonl_line(audit): entry = audit.record('admin', 'admin', '10.0.0.1', 'peer.create', 'peer', 'bob', 'created', 'success', 201, 'POST', '/api/peers', 'req-1') lines = _lines(audit) assert len(lines) == 1 parsed = json.loads(lines[0]) for field in ('ts', 'actor', 'role', 'ip', 'action', 'target_type', 'target_id', 'summary', 'result', 'status', 'method', 'path', 'request_id', 'seq', 'prev_hash', 'hash'): assert field in parsed assert parsed['actor'] == 'admin' assert parsed['action'] == 'peer.create' assert parsed['ts'].endswith('Z') # UTC ISO def test_result_derived_from_status(audit): e = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 500, 'POST', '/api/x', '') assert e['result'] == 'failure' e2 = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 200, 'POST', '/api/x', '') assert e2['result'] == 'success' # ── redaction ───────────────────────────────────────────────────────────────── def test_summarize_keys_lists_names_only(audit): summary = AuditManager.summarize_keys(['network.dns_port', 'email.smtp_password', 'wireguard.private_key']) # KEY NAMES are present (they are names, not values)... assert 'dns_port' in summary assert 'smtp_password' in summary # ...but no actual value material assert 'changed:' in summary def test_secret_values_never_appear(audit): secret_b64 = 'A' * 60 + '==' bcrypt = '$2b$12$abcdefghijklmnopqrstuv' age = 'AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQ' e = audit.record('admin', 'admin', '', 'config.update', 'config', '', f'token={secret_b64} hash={bcrypt} key={age}', 'success', 200, 'PUT', '/api/config', '') raw = _lines(audit)[0] assert secret_b64 not in raw assert bcrypt not in raw assert age not in raw assert 'REDACTED' in e['summary'] # ── append-only ─────────────────────────────────────────────────────────────── def test_append_only_prior_unchanged(audit): audit.record('a', 'admin', '', 'one', '', '', 's1', 'success', 200, 'POST', '/api/a', '') first = _lines(audit)[0] audit.record('b', 'admin', '', 'two', '', '', 's2', 'success', 200, 'POST', '/api/b', '') lines = _lines(audit) assert len(lines) == 2 assert lines[0] == first # prior line byte-for-byte unchanged assert json.loads(lines[1])['seq'] == 2 # ── hash chain ──────────────────────────────────────────────────────────────── def test_hash_chain_links(audit): e1 = audit.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '') e2 = audit.record('b', 'admin', '', 'two', '', '', '', 'success', 200, 'POST', '/api/b', '') assert e1['prev_hash'] == '' assert e2['prev_hash'] == e1['hash'] assert audit.verify_chain() == {'ok': True, 'broken_at_seq': None} def test_tamper_detected(audit): audit.record('a', 'admin', '', 'one', '', '', 'orig', 'success', 200, 'POST', '/api/a', '') audit.record('b', 'admin', '', 'two', '', '', 'orig2', 'success', 200, 'POST', '/api/b', '') lines = _lines(audit) tampered = json.loads(lines[0]) tampered['summary'] = 'HACKED' lines[0] = json.dumps(tampered) with open(audit._audit_file, 'w', encoding='utf-8') as f: f.write('\n'.join(lines) + '\n') res = audit.verify_chain() assert res['ok'] is False assert res['broken_at_seq'] == 1 def test_chain_can_be_disabled(tmp_path): a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c'), tamper_chain=False) e = a.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '') assert e['hash'] == '' assert a.verify_chain().get('disabled') is True # ── rotation ────────────────────────────────────────────────────────────────── def test_rotation_rolls_and_chain_continues(tmp_path): a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c')) a.MAX_FILE_SIZE = 2048 # tiny so a few records trigger rotation for i in range(60): a.record('admin', 'admin', '', f'act{i}', 'thing', str(i), 'x' * 40, 'success', 200, 'POST', '/api/x', '') assert os.path.exists(a._audit_file + '.1'), 'rotation did not occur' # Chain spans live + rotated segments and stays intact across rotation. assert a.verify_chain() == {'ok': True, 'broken_at_seq': None} q = a.query({}, limit=1000) seqs = [e['seq'] for e in q['entries']] # Newest-first ordering preserved across segment boundaries. assert seqs == sorted(seqs, reverse=True) # The newest record (seq 60) is always retained; order is never lost. assert seqs[0] == 60 # Retained seqs form a contiguous run ending at the newest (older entries # beyond BACKUP_COUNT segments are pruned, as designed). assert seqs == list(range(60, 60 - len(seqs), -1)) # ── concurrency ─────────────────────────────────────────────────────────────── def test_concurrent_records_intact(audit): N = 50 def worker(i): audit.record('admin', 'admin', '', f'act{i}', 'thing', str(i), '', 'success', 200, 'POST', '/api/x', '') threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)] for t in threads: t.start() for t in threads: t.join() lines = _lines(audit) assert len(lines) == N for l in lines: json.loads(l) # every line is valid JSON assert audit.verify_chain()['ok'] is True # ── filters + pagination ────────────────────────────────────────────────────── def test_filters_and_pagination(audit): for i in range(10): audit.record('admin' if i % 2 == 0 else 'alice', 'admin', '', 'peer.create' if i < 5 else 'peer.delete', 'peer', f'p{i}', '', 'success' if i != 3 else 'failure', 200, 'POST', '/api/peers', '') res = audit.query({'actor': 'alice'}) assert all(e['actor'] == 'alice' for e in res['entries']) res = audit.query({'action': 'peer.delete'}) assert res['total'] == 5 res = audit.query({'result': 'failure'}) assert res['total'] == 1 page = audit.query({}, limit=3, offset=0) assert len(page['entries']) == 3 assert page['total'] == 10 assert page['next_offset'] == 3 def test_export_csv(audit): audit.record('admin', 'admin', '1.2.3.4', 'peer.create', 'peer', 'bob', 'created', 'success', 201, 'POST', '/api/peers', 'r1') csv = audit.export_csv({}) lines = csv.strip().splitlines() assert lines[0].startswith('ts,actor,role,ip,action') assert 'peer.create' in csv assert 'bob' in csv def test_write_failure_does_not_raise(audit): with patch('os.open', side_effect=OSError('disk full')): result = audit.record('a', 'admin', '', 'x', '', '', '', 'success', 200, 'POST', '/api/x', '') assert result is None # swallowed, never raised