Files
cve-dashboard/docs/testing/run-audit-tests.sh

1079 lines
40 KiB
Bash
Executable File

#!/bin/bash
# =============================================================================
# Audit Logging Feature - Automated Test Script
# Covers test plan sections 1-7, 12-14 (backend + database)
# Sections 8-11 (frontend UI) require manual browser testing
# =============================================================================
#
# Usage:
# ./run_audit_tests.sh [options]
#
# Options:
# --api-base URL Backend API base URL (default: http://localhost:3001/api)
# --db PATH Path to SQLite database file (default: backend/cve_database.db)
# --admin-pass PASS Admin password (default: admin123)
# --help Show this help message
#
# Prerequisites:
# - curl, jq, sqlite3 must be installed on this machine
# - Backend must be running with the audit feature branch deployed
# - Admin account "admin" must exist
# =============================================================================
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────────────
API_BASE="http://localhost:3001/api"
DB_PATH="backend/cve_database.db"
ADMIN_PASS="admin123"
# ── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case $1 in
--api-base) API_BASE="$2"; shift 2 ;;
--db) DB_PATH="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--help)
sed -n '2,/^# ====.*$/p' "$0" | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# ── Colours & counters ───────────────────────────────────────────────────────
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
TOTAL=0
TMPDIR_TEST=$(mktemp -d)
ADMIN_JAR="$TMPDIR_TEST/admin.cookies"
EDITOR_JAR="$TMPDIR_TEST/editor.cookies"
VIEWER_JAR="$TMPDIR_TEST/viewer.cookies"
cleanup() {
rm -rf "$TMPDIR_TEST"
}
trap cleanup EXIT
# ── Helpers ──────────────────────────────────────────────────────────────────
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
TOTAL=$((TOTAL + 1))
echo -e " ${GREEN}PASS${NC} $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
TOTAL=$((TOTAL + 1))
echo -e " ${RED}FAIL${NC} $1"
if [[ -n "${2:-}" ]]; then
echo -e " ${RED}-> $2${NC}"
fi
}
skip() {
SKIP_COUNT=$((SKIP_COUNT + 1))
TOTAL=$((TOTAL + 1))
echo -e " ${YELLOW}SKIP${NC} $1 -- $2"
}
section() {
echo ""
echo -e "${CYAN}${BOLD}── $1 ──${NC}"
}
sql() {
sqlite3 "$DB_PATH" "$1"
}
# Login and store session cookie in a jar file
# Usage: login_as <username> <password> <cookie_jar>
# Returns: HTTP status code
login_as() {
local user="$1" pass="$2" jar="$3"
curl -s -o "$TMPDIR_TEST/login_resp.json" -w "%{http_code}" \
-c "$jar" \
-X POST "$API_BASE/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$user\",\"password\":\"$pass\"}"
}
# Authenticated GET request
# Usage: api_get <path> <cookie_jar> [extra_curl_args...]
api_get() {
local path="$1" jar="$2"
shift 2
curl -s -b "$jar" "$@" "$API_BASE$path"
}
# Authenticated POST request with JSON body
api_post() {
local path="$1" jar="$2" body="$3"
shift 3
curl -s -b "$jar" -X POST "$API_BASE$path" \
-H "Content-Type: application/json" \
-d "$body" "$@"
}
# Authenticated PATCH request with JSON body
api_patch() {
local path="$1" jar="$2" body="$3"
shift 3
curl -s -b "$jar" -X PATCH "$API_BASE$path" \
-H "Content-Type: application/json" \
-d "$body" "$@"
}
# Authenticated DELETE request
api_delete() {
local path="$1" jar="$2"
shift 2
curl -s -b "$jar" -X DELETE "$API_BASE$path" "$@"
}
# Get HTTP status code for an authenticated request
api_status() {
local method="$1" path="$2" jar="$3"
shift 3
curl -s -o /dev/null -w "%{http_code}" -b "$jar" -X "$method" "$API_BASE$path" "$@"
}
# Get the most recent audit log row matching a condition
latest_audit() {
local where="$1"
sql "SELECT id, user_id, username, action, entity_type, entity_id, details, ip_address FROM audit_logs WHERE $where ORDER BY id DESC LIMIT 1;"
}
# Count audit rows matching a condition
count_audit() {
local where="$1"
sql "SELECT COUNT(*) FROM audit_logs WHERE $where;"
}
# Record the current max audit log id so we can look at entries created after this point
mark_audit() {
AUDIT_MARK=$(sql "SELECT COALESCE(MAX(id),0) FROM audit_logs;")
}
# Get audit rows created after the mark
audit_since() {
local where="${1:-1=1}"
sql "SELECT id, user_id, username, action, entity_type, entity_id, details, ip_address FROM audit_logs WHERE id > $AUDIT_MARK AND ($where) ORDER BY id DESC;"
}
count_audit_since() {
local where="${1:-1=1}"
sql "SELECT COUNT(*) FROM audit_logs WHERE id > $AUDIT_MARK AND ($where);"
}
# =============================================================================
# PREFLIGHT CHECKS
# =============================================================================
echo ""
echo -e "${BOLD}Audit Logging Feature - Automated Test Runner${NC}"
echo "=============================================="
echo "API Base : $API_BASE"
echo "DB Path : $DB_PATH"
echo ""
PREFLIGHT_OK=true
for cmd in curl jq sqlite3; do
if ! command -v "$cmd" &>/dev/null; then
echo -e "${RED}Missing dependency: $cmd${NC}"
PREFLIGHT_OK=false
fi
done
if [[ ! -f "$DB_PATH" ]]; then
echo -e "${RED}Database file not found: $DB_PATH${NC}"
PREFLIGHT_OK=false
fi
# Check backend is reachable
if ! curl -s --max-time 5 -o /dev/null "$API_BASE/../" 2>/dev/null; then
# Try the base URL directly
if ! curl -s --max-time 5 -o /dev/null "${API_BASE%/api}" 2>/dev/null; then
echo -e "${RED}Backend not reachable at $API_BASE${NC}"
PREFLIGHT_OK=false
fi
fi
if [[ "$PREFLIGHT_OK" != "true" ]]; then
echo ""
echo "Fix the above issues and re-run."
exit 1
fi
echo -e "${GREEN}Preflight checks passed.${NC}"
# =============================================================================
# SECTION 1: Database & Schema
# =============================================================================
section "1. Database & Schema"
# 1.1 audit_logs table exists with correct columns
TABLE_SQL=$(sql "SELECT sql FROM sqlite_master WHERE name='audit_logs';")
if [[ -n "$TABLE_SQL" ]]; then
MISSING=""
for col in id user_id username action entity_type entity_id details ip_address created_at; do
if ! echo "$TABLE_SQL" | grep -qi "$col"; then
MISSING="$MISSING $col"
fi
done
if [[ -z "$MISSING" ]]; then
pass "1.1 audit_logs table exists with all columns"
else
fail "1.1 audit_logs table missing columns:$MISSING"
fi
else
fail "1.1 audit_logs table does not exist" "Table not found in sqlite_master"
fi
# 1.2 Indexes created
INDEXES=$(sql "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_audit%' ORDER BY name;")
EXPECTED_INDEXES="idx_audit_action idx_audit_created_at idx_audit_entity_type idx_audit_user_id"
FOUND_INDEXES=$(echo "$INDEXES" | tr '\n' ' ' | xargs)
if [[ "$FOUND_INDEXES" == "$EXPECTED_INDEXES" ]]; then
pass "1.2 All four audit indexes exist"
else
fail "1.2 Audit indexes" "Expected: $EXPECTED_INDEXES | Found: $FOUND_INDEXES"
fi
# 1.3 Migration idempotency - check that migrate script exists
if [[ -f "backend/migrate-audit-log.js" ]]; then
pass "1.3 Migration script exists (idempotency requires manual run)"
else
skip "1.3 Migration idempotency" "migrate-audit-log.js not found at expected path"
fi
# 1.4 Migration creates backups - check script exists (actual backup test is manual)
if [[ -f "backend/migrate-audit-log.js" ]] && grep -q "backup" "backend/migrate-audit-log.js"; then
pass "1.4 Migration script contains backup logic"
else
skip "1.4 Migration backup" "Cannot verify without running migration"
fi
# 1.5 Setup includes audit_logs
if [[ -f "backend/setup.js" ]] && grep -q "audit_logs" "backend/setup.js"; then
pass "1.5 setup.js references audit_logs table"
else
skip "1.5 Setup includes audit_logs" "setup.js not found or missing reference"
fi
# =============================================================================
# Login as admin for subsequent tests
# =============================================================================
section "Setup: Authenticate test accounts"
mark_audit
ADMIN_STATUS=$(login_as "admin" "$ADMIN_PASS" "$ADMIN_JAR")
if [[ "$ADMIN_STATUS" != "200" ]]; then
echo -e "${RED}FATAL: Cannot log in as admin (HTTP $ADMIN_STATUS). Aborting.${NC}"
exit 1
fi
echo -e " ${GREEN}OK${NC} Admin login successful"
# Create editor1 and viewer1 if they don't exist
EDITOR_EXISTS=$(sql "SELECT COUNT(*) FROM users WHERE username='_test_editor1';")
if [[ "$EDITOR_EXISTS" == "0" ]]; then
api_post "/users" "$ADMIN_JAR" '{"username":"_test_editor1","email":"editor1@test.local","password":"editor123","role":"editor"}' > /dev/null
echo " Created test account: _test_editor1 (editor)"
fi
VIEWER_EXISTS=$(sql "SELECT COUNT(*) FROM users WHERE username='_test_viewer1';")
if [[ "$VIEWER_EXISTS" == "0" ]]; then
api_post "/users" "$ADMIN_JAR" '{"username":"_test_viewer1","email":"viewer1@test.local","password":"viewer123","role":"viewer"}' > /dev/null
echo " Created test account: _test_viewer1 (viewer)"
fi
EDITOR_STATUS=$(login_as "_test_editor1" "editor123" "$EDITOR_JAR")
if [[ "$EDITOR_STATUS" != "200" ]]; then
echo -e "${RED}FATAL: Cannot log in as _test_editor1 (HTTP $EDITOR_STATUS). Aborting.${NC}"
exit 1
fi
echo -e " ${GREEN}OK${NC} Editor login successful"
VIEWER_STATUS=$(login_as "_test_viewer1" "viewer123" "$VIEWER_JAR")
if [[ "$VIEWER_STATUS" != "200" ]]; then
echo -e "${RED}FATAL: Cannot log in as _test_viewer1 (HTTP $VIEWER_STATUS). Aborting.${NC}"
exit 1
fi
echo -e " ${GREEN}OK${NC} Viewer login successful"
# Small sleep to let fire-and-forget audit inserts complete
sleep 0.5
# =============================================================================
# SECTION 2: Authentication Audit Logging
# =============================================================================
section "2. Authentication Audit Logging"
# 2.1 Successful login logged
ROW=$(latest_audit "action='login' AND username='admin'")
if [[ -n "$ROW" ]] && echo "$ROW" | grep -q "login"; then
# Check details contain role
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | jq -e '.role' &>/dev/null; then
pass "2.1 Successful login logged with role in details"
else
fail "2.1 Login logged but details missing role" "$DETAILS"
fi
else
fail "2.1 No login audit entry found for admin"
fi
# 2.2 Failed login - wrong password
mark_audit
login_as "admin" "wrongpass" "$TMPDIR_TEST/throwaway.cookies" > /dev/null
sleep 0.3
ROW=$(audit_since "action='login_failed' AND username='admin'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q "invalid_password"; then
pass "2.2 Failed login (wrong password) logged with reason"
else
fail "2.2 Failed login logged but reason missing" "$DETAILS"
fi
else
fail "2.2 No login_failed audit entry for wrong password"
fi
# 2.3 Failed login - unknown user
mark_audit
login_as "nonexistent_user_xyz" "anypass" "$TMPDIR_TEST/throwaway.cookies" > /dev/null
sleep 0.3
ROW=$(audit_since "action='login_failed' AND username='nonexistent_user_xyz'")
if [[ -n "$ROW" ]]; then
USER_ID_VAL=$(echo "$ROW" | awk -F'|' '{print $2}')
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if [[ -z "$USER_ID_VAL" ]] && echo "$DETAILS" | grep -q "user_not_found"; then
pass "2.3 Failed login (unknown user) logged with null user_id"
else
fail "2.3 Unknown user login logged but data unexpected" "user_id=$USER_ID_VAL details=$DETAILS"
fi
else
fail "2.3 No login_failed audit entry for unknown user"
fi
# 2.4 Failed login - disabled account
mark_audit
# Create a temp user, disable them, try to log in
api_post "/users" "$ADMIN_JAR" '{"username":"_test_disabled","email":"disabled@test.local","password":"test123","role":"viewer"}' > /dev/null
DISABLED_ID=$(sql "SELECT id FROM users WHERE username='_test_disabled';")
if [[ -n "$DISABLED_ID" ]]; then
api_patch "/users/$DISABLED_ID" "$ADMIN_JAR" '{"is_active":false}' > /dev/null
sleep 0.3
mark_audit
login_as "_test_disabled" "test123" "$TMPDIR_TEST/throwaway.cookies" > /dev/null
sleep 0.3
ROW=$(audit_since "action='login_failed' AND username='_test_disabled'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q "account_disabled"; then
pass "2.4 Failed login (disabled account) logged with reason"
else
fail "2.4 Disabled login logged but reason wrong" "$DETAILS"
fi
else
fail "2.4 No login_failed entry for disabled account"
fi
# Cleanup
api_delete "/users/$DISABLED_ID" "$ADMIN_JAR" > /dev/null
else
skip "2.4 Failed login - disabled account" "Could not create test user"
fi
# 2.5 Logout logged
mark_audit
# Login fresh, then logout
login_as "admin" "$ADMIN_PASS" "$TMPDIR_TEST/logout_test.cookies" > /dev/null
sleep 0.2
curl -s -b "$TMPDIR_TEST/logout_test.cookies" -X POST "$API_BASE/auth/logout" > /dev/null
sleep 0.3
ROW=$(audit_since "action='logout' AND username='admin'")
if [[ -n "$ROW" ]]; then
pass "2.5 Logout logged"
else
fail "2.5 No logout audit entry found"
fi
# 2.6 Fire-and-forget (tested more thoroughly in section 12)
skip "2.6 Login does not block on audit error" "Requires corrupting audit_logs table (see section 12)"
# =============================================================================
# SECTION 3: CVE Operation Audit Logging
# =============================================================================
section "3. CVE Operation Audit Logging"
TEST_CVE_ID="CVE-2025-AUDIT-$(date +%s)"
# 3.1 CVE create logged
mark_audit
api_post "/cves" "$ADMIN_JAR" "{\"cve_id\":\"$TEST_CVE_ID\",\"vendor\":\"TestVendor\",\"severity\":\"Critical\",\"description\":\"Automated test CVE\",\"published_date\":\"2025-01-01\"}" > /dev/null
sleep 0.3
ROW=$(audit_since "action='cve_create' AND entity_id='$TEST_CVE_ID'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | jq -e '.vendor' &>/dev/null && echo "$DETAILS" | jq -e '.severity' &>/dev/null; then
pass "3.1 CVE create logged with vendor and severity in details"
else
fail "3.1 CVE create logged but details incomplete" "$DETAILS"
fi
else
fail "3.1 No cve_create audit entry found"
fi
# 3.2 CVE status update logged
mark_audit
STATUS_RESP=$(api_patch "/cves/$TEST_CVE_ID/status" "$ADMIN_JAR" '{"status":"Addressed"}')
sleep 0.3
ROW=$(audit_since "action='cve_update_status' AND entity_id='$TEST_CVE_ID'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q "Addressed"; then
pass "3.2 CVE status update logged with status in details"
else
fail "3.2 CVE status update logged but status missing from details" "$DETAILS"
fi
else
# Check if the PATCH itself succeeded
if echo "$STATUS_RESP" | jq -e '.error' &>/dev/null 2>&1; then
fail "3.2 CVE status update" "PATCH failed: $(echo "$STATUS_RESP" | jq -r '.error')"
else
fail "3.2 No cve_update_status audit entry found"
fi
fi
# 3.3 CVE status update bug fix (no SQL error with vendor reference)
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" -b "$ADMIN_JAR" -X PATCH "$API_BASE/cves/$TEST_CVE_ID/status" \
-H "Content-Type: application/json" -d '{"status":"In Progress"}')
if [[ "$STATUS_CODE" == "200" ]]; then
pass "3.3 CVE status update completes without SQL error (vendor bug fixed)"
else
fail "3.3 CVE status update returned HTTP $STATUS_CODE"
fi
# 3.4 Audit captures acting user
mark_audit
EDITOR_CVE_ID="CVE-2025-EDIT-$(date +%s)"
api_post "/cves" "$EDITOR_JAR" "{\"cve_id\":\"$EDITOR_CVE_ID\",\"vendor\":\"EditorVendor\",\"severity\":\"Low\",\"description\":\"Editor test CVE\",\"published_date\":\"2025-01-01\"}" > /dev/null
sleep 0.3
ROW=$(audit_since "action='cve_create' AND entity_id='$EDITOR_CVE_ID'")
if [[ -n "$ROW" ]]; then
ACTOR=$(echo "$ROW" | awk -F'|' '{print $3}')
if [[ "$ACTOR" == "_test_editor1" ]]; then
pass "3.4 Audit captures acting user (_test_editor1)"
else
fail "3.4 Wrong acting user on CVE create" "Expected _test_editor1, got $ACTOR"
fi
else
fail "3.4 No cve_create audit entry for editor CVE"
fi
# =============================================================================
# SECTION 4: Document Operation Audit Logging
# =============================================================================
section "4. Document Operation Audit Logging"
# Create a small test file for upload
echo "test document content" > "$TMPDIR_TEST/test_advisory.txt"
# 4.1 Document upload logged
mark_audit
UPLOAD_RESP=$(curl -s -b "$ADMIN_JAR" -X POST "$API_BASE/cves/$TEST_CVE_ID/documents" \
-F "file=@$TMPDIR_TEST/test_advisory.txt" \
-F "type=advisory" \
-F "vendor=TestVendor" \
-F "notes=Automated test upload")
sleep 0.3
UPLOAD_DOC_ID=$(echo "$UPLOAD_RESP" | jq -r '.id // empty')
ROW=$(audit_since "action='document_upload' AND entity_id='$TEST_CVE_ID'")
if [[ -n "$ROW" ]]; then
pass "4.1 Document upload logged"
else
if [[ -z "$UPLOAD_DOC_ID" ]]; then
fail "4.1 Document upload" "Upload itself failed: $UPLOAD_RESP"
else
fail "4.1 No document_upload audit entry found"
fi
fi
# 4.2 Document delete logged
if [[ -n "$UPLOAD_DOC_ID" ]]; then
mark_audit
api_delete "/documents/$UPLOAD_DOC_ID" "$ADMIN_JAR" > /dev/null
sleep 0.3
ROW=$(audit_since "action='document_delete'")
if [[ -n "$ROW" ]]; then
pass "4.2 Document delete logged"
else
fail "4.2 No document_delete audit entry found"
fi
else
skip "4.2 Document delete logging" "No document ID from upload step"
fi
# 4.3 Upload captures file metadata
mark_audit
UPLOAD_RESP2=$(curl -s -b "$ADMIN_JAR" -X POST "$API_BASE/cves/$TEST_CVE_ID/documents" \
-F "file=@$TMPDIR_TEST/test_advisory.txt;filename=advisory.pdf" \
-F "type=advisory" \
-F "vendor=TestVendor" \
-F "notes=metadata test")
sleep 0.3
UPLOAD_DOC_ID2=$(echo "$UPLOAD_RESP2" | jq -r '.id // empty')
ROW=$(audit_since "action='document_upload' AND entity_id='$TEST_CVE_ID'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
HAS_VENDOR=$(echo "$DETAILS" | jq -e '.vendor' 2>/dev/null)
HAS_TYPE=$(echo "$DETAILS" | jq -e '.type' 2>/dev/null)
HAS_FILE=$(echo "$DETAILS" | jq -e '.filename' 2>/dev/null)
if [[ -n "$HAS_VENDOR" && -n "$HAS_TYPE" && -n "$HAS_FILE" ]]; then
pass "4.3 Upload details contain vendor, type, and filename"
else
fail "4.3 Upload details incomplete" "$DETAILS"
fi
else
fail "4.3 No document_upload audit entry for metadata test"
fi
# Cleanup doc
if [[ -n "$UPLOAD_DOC_ID2" ]]; then
api_delete "/documents/$UPLOAD_DOC_ID2" "$ADMIN_JAR" > /dev/null
fi
# =============================================================================
# SECTION 5: User Management Audit Logging
# =============================================================================
section "5. User Management Audit Logging"
# 5.1 User create logged
mark_audit
CREATE_RESP=$(api_post "/users" "$ADMIN_JAR" '{"username":"_test_audituser","email":"audituser@test.local","password":"pass123","role":"viewer"}')
TESTUSER_ID=$(echo "$CREATE_RESP" | jq -r '.user.id // empty')
sleep 0.3
ROW=$(audit_since "action='user_create'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q "_test_audituser" && echo "$DETAILS" | grep -q "viewer"; then
pass "5.1 User create logged with username and role"
else
fail "5.1 User create logged but details incomplete" "$DETAILS"
fi
else
fail "5.1 No user_create audit entry found"
fi
# 5.2 User role update logged
if [[ -n "$TESTUSER_ID" ]]; then
mark_audit
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"role":"editor"}' > /dev/null
sleep 0.3
ROW=$(audit_since "action='user_update'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q '"role":"editor"'; then
pass "5.2 User role update logged"
else
fail "5.2 User update logged but role missing from details" "$DETAILS"
fi
else
fail "5.2 No user_update audit entry found"
fi
else
skip "5.2 User role update" "No test user ID"
fi
# 5.3 User password change logged (password itself NOT logged)
if [[ -n "$TESTUSER_ID" ]]; then
mark_audit
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"password":"newpass456"}' > /dev/null
sleep 0.3
ROW=$(audit_since "action='user_update'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q '"password_changed":true'; then
# Ensure actual password is NOT in details
if echo "$DETAILS" | grep -q "newpass456"; then
fail "5.3 Password change logged but PASSWORD IS IN DETAILS (security issue)" "$DETAILS"
else
pass "5.3 Password change logged with password_changed:true (password not exposed)"
fi
else
fail "5.3 Password change logged but password_changed flag missing" "$DETAILS"
fi
else
fail "5.3 No user_update audit entry for password change"
fi
else
skip "5.3 Password change logging" "No test user ID"
fi
# 5.4 Multiple field update logged
if [[ -n "$TESTUSER_ID" ]]; then
mark_audit
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"username":"_test_audituser_renamed","role":"viewer"}' > /dev/null
sleep 0.3
ROW=$(audit_since "action='user_update'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
HAS_USERNAME=$(echo "$DETAILS" | jq -e '.username' 2>/dev/null)
HAS_ROLE=$(echo "$DETAILS" | jq -e '.role' 2>/dev/null)
if [[ -n "$HAS_USERNAME" && -n "$HAS_ROLE" ]]; then
pass "5.4 Multiple field update logged with all changed fields"
else
fail "5.4 Multi-field update logged but fields missing" "$DETAILS"
fi
else
fail "5.4 No user_update audit entry for multi-field change"
fi
# Rename back for later tests
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"username":"_test_audituser"}' > /dev/null
sleep 0.2
else
skip "5.4 Multiple field update" "No test user ID"
fi
# 5.6 User deactivation logged (testing before 5.5 delete so user still exists)
if [[ -n "$TESTUSER_ID" ]]; then
mark_audit
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"is_active":false}' > /dev/null
sleep 0.3
ROW=$(audit_since "action='user_update'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q '"is_active":false'; then
pass "5.6 User deactivation logged with is_active:false"
else
fail "5.6 Deactivation logged but is_active field missing" "$DETAILS"
fi
else
fail "5.6 No user_update audit entry for deactivation"
fi
# Re-activate for delete test
api_patch "/users/$TESTUSER_ID" "$ADMIN_JAR" '{"is_active":true}' > /dev/null
sleep 0.2
else
skip "5.6 User deactivation" "No test user ID"
fi
# 5.5 User delete logged
if [[ -n "$TESTUSER_ID" ]]; then
mark_audit
api_delete "/users/$TESTUSER_ID" "$ADMIN_JAR" > /dev/null
sleep 0.3
ROW=$(audit_since "action='user_delete'")
if [[ -n "$ROW" ]]; then
DETAILS=$(echo "$ROW" | awk -F'|' '{print $7}')
if echo "$DETAILS" | grep -q "_test_audituser"; then
pass "5.5 User delete logged with deleted_username"
else
fail "5.5 User delete logged but username missing from details" "$DETAILS"
fi
else
fail "5.5 No user_delete audit entry found"
fi
else
skip "5.5 User delete" "No test user ID"
fi
# 5.7 Self-delete prevented, no log
mark_audit
ADMIN_ID=$(sql "SELECT id FROM users WHERE username='admin';")
SELF_DEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -b "$ADMIN_JAR" -X DELETE "$API_BASE/users/$ADMIN_ID")
sleep 0.3
SELF_DEL_LOG=$(count_audit_since "action='user_delete' AND entity_id='$ADMIN_ID'")
if [[ "$SELF_DEL_STATUS" == "400" && "$SELF_DEL_LOG" == "0" ]]; then
pass "5.7 Self-delete prevented (400) with no audit entry"
elif [[ "$SELF_DEL_STATUS" != "400" ]]; then
fail "5.7 Self-delete returned HTTP $SELF_DEL_STATUS (expected 400)"
else
fail "5.7 Self-delete blocked but audit entry was still created"
fi
# =============================================================================
# SECTION 6: API Access Control
# =============================================================================
section "6. API Access Control"
# 6.1 Admin can query audit logs
RESP=$(api_get "/audit-logs" "$ADMIN_JAR")
if echo "$RESP" | jq -e '.logs' &>/dev/null && echo "$RESP" | jq -e '.pagination' &>/dev/null; then
pass "6.1 Admin can query audit logs (200 with logs + pagination)"
else
fail "6.1 Admin audit log query" "Response: $(echo "$RESP" | head -c 200)"
fi
# 6.2 Editor denied audit logs
EDITOR_AL_STATUS=$(api_status "GET" "/audit-logs" "$EDITOR_JAR")
if [[ "$EDITOR_AL_STATUS" == "403" ]]; then
pass "6.2 Editor denied audit logs (403)"
else
fail "6.2 Editor audit log access returned HTTP $EDITOR_AL_STATUS (expected 403)"
fi
# 6.3 Viewer denied audit logs
VIEWER_AL_STATUS=$(api_status "GET" "/audit-logs" "$VIEWER_JAR")
if [[ "$VIEWER_AL_STATUS" == "403" ]]; then
pass "6.3 Viewer denied audit logs (403)"
else
fail "6.3 Viewer audit log access returned HTTP $VIEWER_AL_STATUS (expected 403)"
fi
# 6.4 Unauthenticated denied
UNAUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_BASE/audit-logs")
if [[ "$UNAUTH_STATUS" == "401" ]]; then
pass "6.4 Unauthenticated denied audit logs (401)"
else
fail "6.4 Unauthenticated audit log access returned HTTP $UNAUTH_STATUS (expected 401)"
fi
# 6.5 Admin can get actions list
ACTIONS_RESP=$(api_get "/audit-logs/actions" "$ADMIN_JAR")
if echo "$ACTIONS_RESP" | jq -e 'type == "array"' &>/dev/null; then
pass "6.5 Admin can get actions list (array returned)"
else
fail "6.5 Actions list" "Response: $(echo "$ACTIONS_RESP" | head -c 200)"
fi
# 6.6 Non-admin denied actions list
EDITOR_ACTIONS_STATUS=$(api_status "GET" "/audit-logs/actions" "$EDITOR_JAR")
if [[ "$EDITOR_ACTIONS_STATUS" == "403" ]]; then
pass "6.6 Non-admin denied actions list (403)"
else
fail "6.6 Editor actions list returned HTTP $EDITOR_ACTIONS_STATUS (expected 403)"
fi
# =============================================================================
# SECTION 7: API Filtering & Pagination
# =============================================================================
section "7. API Filtering & Pagination"
# 7.1 Default pagination
RESP=$(api_get "/audit-logs" "$ADMIN_JAR")
PAGE=$(echo "$RESP" | jq -r '.pagination.page')
LIMIT=$(echo "$RESP" | jq -r '.pagination.limit')
LOG_COUNT=$(echo "$RESP" | jq '.logs | length')
TOTAL_COUNT=$(echo "$RESP" | jq -r '.pagination.total')
if [[ "$PAGE" == "1" && "$LIMIT" == "25" && "$LOG_COUNT" -le 25 ]]; then
pass "7.1 Default pagination (page=1, limit=25, logs<=$LIMIT)"
else
fail "7.1 Default pagination" "page=$PAGE limit=$LIMIT log_count=$LOG_COUNT"
fi
# 7.2 Custom page size
RESP=$(api_get "/audit-logs?limit=5" "$ADMIN_JAR")
LOG_COUNT=$(echo "$RESP" | jq '.logs | length')
LIMIT=$(echo "$RESP" | jq -r '.pagination.limit')
if [[ "$LIMIT" == "5" && "$LOG_COUNT" -le 5 ]]; then
pass "7.2 Custom page size (limit=5)"
else
fail "7.2 Custom page size" "limit=$LIMIT log_count=$LOG_COUNT"
fi
# 7.3 Page size capped at 100
RESP=$(api_get "/audit-logs?limit=999" "$ADMIN_JAR")
LIMIT=$(echo "$RESP" | jq -r '.pagination.limit')
if [[ "$LIMIT" -le 100 ]]; then
pass "7.3 Page size capped at 100 (got limit=$LIMIT)"
else
fail "7.3 Page size cap" "limit=$LIMIT (expected <=100)"
fi
# 7.4 Navigate to page 2
RESP_P1=$(api_get "/audit-logs?limit=5&page=1" "$ADMIN_JAR")
RESP_P2=$(api_get "/audit-logs?limit=5&page=2" "$ADMIN_JAR")
P1_FIRST=$(echo "$RESP_P1" | jq -r '.logs[0].id // empty')
P2_FIRST=$(echo "$RESP_P2" | jq -r '.logs[0].id // empty')
P2_PAGE=$(echo "$RESP_P2" | jq -r '.pagination.page')
if [[ -n "$P2_FIRST" && "$P1_FIRST" != "$P2_FIRST" && "$P2_PAGE" == "2" ]]; then
pass "7.4 Page 2 returns different entries"
elif [[ -z "$P2_FIRST" ]]; then
skip "7.4 Navigate to page 2" "Not enough entries for 2 pages at limit=5"
else
fail "7.4 Page 2 entries same as page 1"
fi
# 7.5 Filter by username
RESP=$(api_get "/audit-logs?user=admin" "$ADMIN_JAR")
NON_ADMIN=$(echo "$RESP" | jq '[.logs[] | select(.username != "admin" and (.username | test("admin") | not))] | length')
if [[ "$NON_ADMIN" == "0" ]]; then
pass "7.5 Filter by username=admin returns only admin entries"
else
fail "7.5 Username filter" "$NON_ADMIN non-admin entries returned"
fi
# 7.6 Partial username match
RESP=$(api_get "/audit-logs?user=adm" "$ADMIN_JAR")
HAS_ADMIN=$(echo "$RESP" | jq '[.logs[] | select(.username == "admin")] | length')
if [[ "$HAS_ADMIN" -gt 0 ]]; then
pass "7.6 Partial username match (adm matches admin)"
else
fail "7.6 Partial username match found no admin entries"
fi
# 7.7 Filter by action
RESP=$(api_get "/audit-logs?action=login" "$ADMIN_JAR")
NON_LOGIN=$(echo "$RESP" | jq '[.logs[] | select(.action != "login")] | length')
if [[ "$NON_LOGIN" == "0" ]]; then
pass "7.7 Filter by action=login returns only login entries"
else
fail "7.7 Action filter" "$NON_LOGIN non-login entries returned"
fi
# 7.8 Filter by entity type
RESP=$(api_get "/audit-logs?entityType=auth" "$ADMIN_JAR")
NON_AUTH=$(echo "$RESP" | jq '[.logs[] | select(.entity_type != "auth")] | length')
if [[ "$NON_AUTH" == "0" ]]; then
pass "7.8 Filter by entityType=auth returns only auth entries"
else
fail "7.8 Entity type filter" "$NON_AUTH non-auth entries returned"
fi
# 7.9 Filter by date range
TODAY=$(date +%Y-%m-%d)
RESP=$(api_get "/audit-logs?startDate=$TODAY&endDate=$TODAY" "$ADMIN_JAR")
LOG_COUNT=$(echo "$RESP" | jq '.logs | length')
if [[ "$LOG_COUNT" -gt 0 ]]; then
pass "7.9 Date range filter returns entries for today ($LOG_COUNT entries)"
else
fail "7.9 Date range filter returned 0 entries for today"
fi
# 7.10 Combined filters
RESP=$(api_get "/audit-logs?user=admin&action=login&entityType=auth" "$ADMIN_JAR")
ALL_MATCH=$(echo "$RESP" | jq '[.logs[] | select(.action == "login" and .entity_type == "auth" and (.username | test("admin")))] | length')
TOTAL_RETURNED=$(echo "$RESP" | jq '.logs | length')
if [[ "$TOTAL_RETURNED" -gt 0 && "$ALL_MATCH" == "$TOTAL_RETURNED" ]]; then
pass "7.10 Combined filters return only matching entries ($TOTAL_RETURNED)"
elif [[ "$TOTAL_RETURNED" == "0" ]]; then
fail "7.10 Combined filters returned 0 entries"
else
fail "7.10 Combined filters" "$ALL_MATCH of $TOTAL_RETURNED matched all criteria"
fi
# 7.11 Empty result set
RESP=$(api_get "/audit-logs?user=zzz_no_such_user_zzz" "$ADMIN_JAR")
LOG_COUNT=$(echo "$RESP" | jq '.logs | length')
TOTAL_VAL=$(echo "$RESP" | jq -r '.pagination.total')
if [[ "$LOG_COUNT" == "0" && "$TOTAL_VAL" == "0" ]]; then
pass "7.11 Empty result set returns logs:[] and total:0"
else
fail "7.11 Empty result set" "logs=$LOG_COUNT total=$TOTAL_VAL"
fi
# 7.12 Ordering (newest first)
RESP=$(api_get "/audit-logs?limit=5" "$ADMIN_JAR")
FIRST_ID=$(echo "$RESP" | jq -r '.logs[0].id')
LAST_ID=$(echo "$RESP" | jq -r '.logs[-1].id')
if [[ "$FIRST_ID" -gt "$LAST_ID" ]]; then
pass "7.12 Entries ordered DESC (newest first: id $FIRST_ID > $LAST_ID)"
else
fail "7.12 Ordering" "First id=$FIRST_ID, last id=$LAST_ID (expected DESC)"
fi
# =============================================================================
# SECTION 12: Fire-and-Forget Behavior
# =============================================================================
section "12. Fire-and-Forget Behavior"
skip "12.1 Audit failure does not break login" "Requires temporarily corrupting audit_logs table"
skip "12.2 Audit failure does not break CVE create" "Requires temporarily corrupting audit_logs table"
skip "12.3 Response not delayed by audit" "Requires timing measurement under load"
# =============================================================================
# SECTION 13: Data Integrity
# =============================================================================
section "13. Data Integrity"
# 13.1 Audit survives user deletion
# Create user, perform action, delete user, check audit still has username
mark_audit
CREATE_RESP=$(api_post "/users" "$ADMIN_JAR" '{"username":"_test_ephemeral","email":"ephemeral@test.local","password":"pass123","role":"viewer"}')
EPH_ID=$(echo "$CREATE_RESP" | jq -r '.user.id // empty')
sleep 0.3
if [[ -n "$EPH_ID" ]]; then
api_delete "/users/$EPH_ID" "$ADMIN_JAR" > /dev/null
sleep 0.3
SURVIVING=$(sql "SELECT COUNT(*) FROM audit_logs WHERE details LIKE '%_test_ephemeral%';")
if [[ "$SURVIVING" -gt 0 ]]; then
pass "13.1 Audit entries survive user deletion (denormalized username)"
else
fail "13.1 No audit entries found for deleted user"
fi
else
skip "13.1 Audit survives user deletion" "Could not create ephemeral user"
fi
# 13.2 Details stored as valid JSON
INVALID_JSON=$(sql "SELECT COUNT(*) FROM audit_logs WHERE details IS NOT NULL AND details != '' AND json_valid(details) = 0;")
if [[ "$INVALID_JSON" == "0" ]]; then
pass "13.2 All non-null details values are valid JSON"
else
fail "13.2 Found $INVALID_JSON rows with invalid JSON in details"
fi
# 13.3 IP address captured
HAS_IP=$(sql "SELECT COUNT(*) FROM audit_logs WHERE ip_address IS NOT NULL AND ip_address != '';")
if [[ "$HAS_IP" -gt 0 ]]; then
pass "13.3 IP addresses captured ($HAS_IP entries with IP)"
else
fail "13.3 No audit entries have an IP address"
fi
# 13.4 Timestamps auto-populated
NULL_TS=$(sql "SELECT COUNT(*) FROM audit_logs WHERE created_at IS NULL;")
if [[ "$NULL_TS" == "0" ]]; then
pass "13.4 All audit entries have non-null created_at"
else
fail "13.4 Found $NULL_TS entries with null created_at"
fi
# 13.5 Null entity_id for auth actions
AUTH_WITH_ENTITY=$(sql "SELECT COUNT(*) FROM audit_logs WHERE entity_type='auth' AND entity_id IS NOT NULL;")
if [[ "$AUTH_WITH_ENTITY" == "0" ]]; then
pass "13.5 Auth entries have null entity_id"
else
fail "13.5 Found $AUTH_WITH_ENTITY auth entries with non-null entity_id"
fi
# =============================================================================
# SECTION 14: End-to-End Workflow
# =============================================================================
section "14. End-to-End Workflow"
# 14.1 Full user lifecycle
mark_audit
# Step 1: admin creates user
CREATE_RESP=$(api_post "/users" "$ADMIN_JAR" '{"username":"_test_lifecycle","email":"lifecycle@test.local","password":"life123","role":"viewer"}')
LIFE_ID=$(echo "$CREATE_RESP" | jq -r '.user.id // empty')
sleep 0.2
if [[ -n "$LIFE_ID" ]]; then
# Step 2: lifecycle user logs in
login_as "_test_lifecycle" "life123" "$TMPDIR_TEST/lifecycle.cookies" > /dev/null
sleep 0.2
# Step 3: lifecycle user creates a CVE
LIFE_CVE="CVE-2025-LIFE-$(date +%s)"
api_post "/cves" "$TMPDIR_TEST/lifecycle.cookies" "{\"cve_id\":\"$LIFE_CVE\",\"vendor\":\"LifeVendor\",\"severity\":\"Medium\",\"description\":\"Lifecycle test\",\"published_date\":\"2025-01-01\"}" > /dev/null
sleep 0.2
# Step 4: admin updates role
api_patch "/users/$LIFE_ID" "$ADMIN_JAR" '{"role":"editor"}' > /dev/null
sleep 0.2
# Step 5: admin deletes user
api_delete "/users/$LIFE_ID" "$ADMIN_JAR" > /dev/null
sleep 0.3
# Verify all actions logged
LIFECYCLE_ACTIONS=$(sql "SELECT action FROM audit_logs WHERE id > $AUDIT_MARK ORDER BY id;" | tr '\n' ',')
EXPECTED_ACTIONS="user_create,login,cve_create,user_update,user_delete,"
# Check each expected action is present in order
ALL_FOUND=true
for ACT in user_create login cve_create user_update user_delete; do
if ! echo "$LIFECYCLE_ACTIONS" | grep -q "$ACT"; then
ALL_FOUND=false
break
fi
done
if [[ "$ALL_FOUND" == "true" ]]; then
pass "14.1 Full user lifecycle - all 5 actions logged"
else
fail "14.1 Full user lifecycle" "Expected: $EXPECTED_ACTIONS Got: $LIFECYCLE_ACTIONS"
fi
# 14.2 Filter by lifecycle user's own actions
LIFE_OWN=$(sql "SELECT COUNT(*) FROM audit_logs WHERE id > $AUDIT_MARK AND username='_test_lifecycle';")
if [[ "$LIFE_OWN" -ge 2 ]]; then
pass "14.2 Lifecycle user's own actions found ($LIFE_OWN entries)"
else
fail "14.2 Expected at least 2 entries for _test_lifecycle, found $LIFE_OWN"
fi
else
skip "14.1 Full user lifecycle" "Could not create lifecycle user"
skip "14.2 Filter by lifecycle user" "Depends on 14.1"
fi
# 14.3 Security audit trail - failed logins
mark_audit
for i in 1 2 3; do
login_as "admin" "badpass$i" "$TMPDIR_TEST/throwaway.cookies" > /dev/null
done
sleep 0.5
FAIL_COUNT_CHECK=$(count_audit_since "action='login_failed' AND username='admin'")
if [[ "$FAIL_COUNT_CHECK" -ge 3 ]]; then
pass "14.3 Security audit trail - $FAIL_COUNT_CHECK failed login attempts recorded"
else
fail "14.3 Expected 3+ failed login attempts, found $FAIL_COUNT_CHECK"
fi
# =============================================================================
# CLEANUP
# =============================================================================
section "Cleanup"
# Remove test CVEs (direct SQL since there's no delete CVE API)
sql "DELETE FROM cves WHERE cve_id LIKE 'CVE-2025-AUDIT-%' OR cve_id LIKE 'CVE-2025-EDIT-%' OR cve_id LIKE 'CVE-2025-LIFE-%';" 2>/dev/null
echo " Removed test CVEs"
# Remove test users (may already be deleted)
for u in _test_editor1 _test_viewer1 _test_disabled _test_audituser _test_ephemeral _test_lifecycle; do
UID_VAL=$(sql "SELECT id FROM users WHERE username='$u';" 2>/dev/null)
if [[ -n "$UID_VAL" ]]; then
sql "DELETE FROM sessions WHERE user_id=$UID_VAL;" 2>/dev/null
sql "DELETE FROM users WHERE id=$UID_VAL;" 2>/dev/null
fi
done
echo " Removed test users"
# Remove test audit entries
sql "DELETE FROM audit_logs WHERE username LIKE '_test_%' OR entity_id LIKE 'CVE-2025-AUDIT-%' OR entity_id LIKE 'CVE-2025-EDIT-%' OR entity_id LIKE 'CVE-2025-LIFE-%' OR details LIKE '%_test_%';" 2>/dev/null
# Also clean up the failed login attempts for nonexistent user
sql "DELETE FROM audit_logs WHERE username='nonexistent_user_xyz';" 2>/dev/null
echo " Removed test audit entries"
# =============================================================================
# SUMMARY
# =============================================================================
echo ""
echo "=============================================="
echo -e "${BOLD}TEST RESULTS${NC}"
echo "=============================================="
echo -e " ${GREEN}Passed: $PASS_COUNT${NC}"
echo -e " ${RED}Failed: $FAIL_COUNT${NC}"
echo -e " ${YELLOW}Skipped: $SKIP_COUNT${NC}"
echo " Total: $TOTAL"
echo ""
# Note about manual tests
MANUAL_TESTS=34
echo -e "${CYAN}Note: $MANUAL_TESTS tests in sections 8-11 (Frontend UI) require manual browser testing.${NC}"
echo " 8. Menu Access (5 tests) - Role-based visibility of Audit Log menu"
echo " 9. Modal Display (12 tests) - Table rendering, badges, formatting"
echo " 10. Frontend Filters (10 tests) - Filter dropdowns, reset, apply"
echo " 11. Pagination UI (7 tests) - Page navigation, boundary conditions"
echo ""
if [[ "$FAIL_COUNT" -eq 0 ]]; then
echo -e "${GREEN}${BOLD}RESULT: ALL AUTOMATED TESTS PASSED${NC}"
exit 0
else
echo -e "${RED}${BOLD}RESULT: $FAIL_COUNT TEST(S) FAILED${NC}"
exit 1
fi