1079 lines
40 KiB
Bash
Executable File
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
|