#!/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 # 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 [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