#!/bin/bash # # Qbix Server — Test Suite # # Runs functional, security, PHP integration, and performance tests. # Starts the server automatically, runs tests, reports results. # # Usage: # ./tests/run.sh Run all tests # ./tests/run.sh --quick Skip benchmarks # ./tests/run.sh --bench Benchmarks only # ./tests/run.sh --bench-nginx Compare against nginx (must be installed) # # Requirements: PHP 8.1+, ab (Apache Bench) # set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" PORT=19876 HOST="127.0.0.1" PASS=0 FAIL=0 SKIP=0 ERRORS="" # ── Helpers ────────────────────────────────────────── red() { echo -e "\033[31m$1\033[0m"; } green() { echo -e "\033[32m$1\033[0m"; } yellow(){ echo -e "\033[33m$1\033[0m"; } bold() { echo -e "\033[1m$1\033[0m"; } request() { local method="$1" path="$2" extra_headers="$3" body="$4" timeout 5 php -r ' $fp = @fsockopen("'"$HOST"'", '"$PORT"', $e, $es, 2); if (!$fp) { echo "CONNECT_FAIL"; exit; } $req = "'"$method"' '"$path"' HTTP/1.1\r\nHost: localhost\r\n"; $extra = "'"$extra_headers"'"; if ($extra) $req .= str_replace("\\r\\n", "\r\n", $extra) . "\r\n"; $body = "'"$body"'"; if ($body) $req .= "Content-Length: " . strlen($body) . "\r\n"; $req .= "Connection: close\r\n\r\n"; if ($body) $req .= $body; fwrite($fp, $req); stream_set_timeout($fp, 3); $r = ""; while (!feof($fp)) { $c = @fread($fp, 65536); if ($c === false || $c === "") break; $r .= $c; $info = stream_get_meta_data($fp); if (!empty($info["timed_out"])) break; } fclose($fp); echo $r; ' 2>/dev/null } status_of() { echo "$1" | head -1 | grep -oP 'HTTP/1\.[01] \K\d+' } body_of() { local sep=$(echo "$1" | grep -n $'^\r$' | head -1 | cut -d: -f1) if [ -n "$sep" ]; then echo "$1" | tail -n +"$((sep+1))" fi } header_of() { echo "$1" | grep -i "^$2:" | head -1 | sed 's/^[^:]*: *//' | tr -d '\r' } assert_status() { local name="$1" expected="$2" actual="$3" if [ "$actual" = "$expected" ]; then green " ✓ $name (HTTP $actual)" PASS=$((PASS+1)) else red " ✗ $name — expected $expected, got ${actual:-FAIL}" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ $name" fi } assert_contains() { local name="$1" haystack="$2" needle="$3" if echo "$haystack" | grep -q "$needle"; then green " ✓ $name" PASS=$((PASS+1)) else red " ✗ $name — response missing: $needle" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ $name" fi } assert_not_contains() { local name="$1" haystack="$2" needle="$3" if echo "$haystack" | grep -q "$needle"; then red " ✗ $name — response contains: $needle" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ $name" else green " ✓ $name" PASS=$((PASS+1)) fi } # ── Start server ───────────────────────────────────── start_server() { pkill -f "qbixserver.php.*$PORT" 2>/dev/null || true sleep 1 cd "$ROOT_DIR" php qbixserver.php --root="$SCRIPT_DIR/web" --port=$PORT > /tmp/qbix_test.log 2>&1 & SERVER_PID=$! sleep 2 if ! kill -0 $SERVER_PID 2>/dev/null; then red "Failed to start server. Log:" cat /tmp/qbix_test.log exit 1 fi } stop_server() { if [ -n "$SERVER_PID" ]; then kill $SERVER_PID 2>/dev/null || true wait $SERVER_PID 2>/dev/null || true fi } trap stop_server EXIT # ══════════════════════════════════════════════════════ # TESTS # ══════════════════════════════════════════════════════ run_functional() { bold "\n═══ Functional Tests ═══" bold "\n Static files" R=$(request "GET" "/index.html") assert_status "GET /index.html" "200" "$(status_of "$R")" assert_contains "Content-Type html" "$R" "text/html" assert_contains "Body contains heading" "$R" "Test Page" R=$(request "HEAD" "/index.html") assert_status "HEAD /index.html" "200" "$(status_of "$R")" assert_not_contains "HEAD has no body" "$R" "Test Page" bold "\n Content types" echo "body{}" > "$SCRIPT_DIR/web/style.css" R=$(request "GET" "/style.css") assert_contains "CSS content type" "$R" "text/css" echo '{"a":1}' > "$SCRIPT_DIR/web/data.json" R=$(request "GET" "/data.json") assert_contains "JSON content type" "$R" "application/json" bold "\n Caching" R=$(request "GET" "/index.html") ETAG=$(header_of "$R" "ETag") if [ -n "$ETAG" ]; then green " ✓ ETag header present: $ETAG" PASS=$((PASS+1)) # Test 304 via ab which handles connection properly NOT_MOD=$(ab -n 1 -H "If-None-Match: $ETAG" http://$HOST:$PORT/index.html 2>&1 | grep "Non-2xx" | awk '{print $3}') if [ "$NOT_MOD" = "1" ]; then green " ✓ 304 Not Modified with matching ETag" PASS=$((PASS+1)) else yellow " ⊘ 304 test inconclusive" SKIP=$((SKIP+1)) fi else yellow " ⊘ No ETag header (skipped 304 test)" SKIP=$((SKIP+2)) fi bold "\n Keep-alive" KA=$(timeout 3 php -r ' $fp = @fsockopen("'"$HOST"'", '"$PORT"', $e, $es, 1); if (!$fp) { echo "FAIL"; exit; } fwrite($fp, "GET /index.html HTTP/1.1\r\nHost: l\r\nConnection: keep-alive\r\n\r\n"); stream_set_timeout($fp, 2); $r = ""; while (($line = fgets($fp)) !== false) { $r .= $line; if (trim($line) === "") break; // end of headers } fclose($fp); echo (stripos($r, "keep-alive") !== false) ? "YES" : "NO"; ' 2>/dev/null) if [ "$KA" = "YES" ]; then green " ✓ Keep-alive connection" PASS=$((PASS+1)) else red " ✗ Keep-alive not in response headers" FAIL=$((FAIL+1)) fi bold "\n Error handling" R=$(request "GET" "/nonexistent") assert_status "404 for missing file" "404" "$(status_of "$R")" R=$(request "GET" "/Q/health") assert_status "Health endpoint" "200" "$(status_of "$R")" assert_contains "Health JSON" "$R" '"status"' R=$(request "GET" "/Q/dashboard") assert_status "Dashboard" "200" "$(status_of "$R")" } run_security() { bold "\n═══ Security Tests ═══" bold "\n Path traversal" R=$(request "GET" "/../../../etc/passwd") S=$(status_of "$R") if [ "$S" = "400" ] || [ "$S" = "403" ] || [ "$S" = "404" ]; then green " ✓ Path traversal blocked (HTTP $S)" PASS=$((PASS+1)) else red " ✗ Path traversal NOT blocked (HTTP $S)" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ Path traversal" fi R=$(request "GET" "/..%2F..%2F..%2Fetc%2Fpasswd") S=$(status_of "$R") if [ "$S" = "400" ] || [ "$S" = "403" ] || [ "$S" = "404" ]; then green " ✓ Encoded path traversal blocked (HTTP $S)" PASS=$((PASS+1)) else red " ✗ Encoded path traversal NOT blocked (HTTP $S)" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ Encoded path traversal" fi bold "\n Dotfile protection" R=$(request "GET" "/.env") assert_status "Dotfile .env blocked" "403" "$(status_of "$R")" assert_not_contains "Dotfile content hidden" "$R" "secret" R=$(request "GET" "/.secret/data.txt") S=$(status_of "$R") if [ "$S" = "403" ] || [ "$S" = "404" ]; then green " ✓ Dotdir .secret/ blocked (HTTP $S)" PASS=$((PASS+1)) else red " ✗ Dotdir .secret/ accessible (HTTP $S)" FAIL=$((FAIL+1)) ERRORS="${ERRORS}\n ✗ Dotdir accessible" fi bold "\n Malformed requests" R=$(timeout 3 php -r ' $fp = @fsockopen("'"$HOST"'", '"$PORT"', $e, $es, 1); if (!$fp) { echo "CONNECT_FAIL"; exit; } fwrite($fp, "GARBAGE DATA HERE\r\n\r\n"); stream_set_timeout($fp, 2); $r = ""; while (!feof($fp)) { $c = @fread($fp, 4096); if ($c===""||$c===false) break; $r .= $c; } fclose($fp); echo $r; ' 2>/dev/null) S=$(status_of "$R") if [ "$S" = "400" ] || [ -z "$S" ]; then green " ✓ Malformed request rejected" PASS=$((PASS+1)) else red " ✗ Malformed request not rejected (HTTP $S)" FAIL=$((FAIL+1)) fi bold "\n Oversized headers" BIGHEADER=$(python3 -c "print('X-Big: ' + 'A' * 70000)" 2>/dev/null || echo "") if [ -n "$BIGHEADER" ]; then R=$(request "GET" "/" "$BIGHEADER") S=$(status_of "$R") if [ "$S" = "431" ] || [ -z "$S" ]; then green " ✓ Oversized header rejected (431)" PASS=$((PASS+1)) else red " ✗ Oversized header accepted (HTTP $S)" FAIL=$((FAIL+1)) fi else yellow " ⊘ Oversized header test skipped (no python3)" SKIP=$((SKIP+1)) fi bold "\n Null bytes in URL" R=$(request "GET" "/index%00.html") S=$(status_of "$R") if [ "$S" = "400" ] || [ "$S" = "403" ] || [ "$S" = "404" ]; then green " ✓ Null byte in URL blocked (HTTP $S)" PASS=$((PASS+1)) else red " ✗ Null byte in URL not blocked (HTTP $S)" FAIL=$((FAIL+1)) fi } run_php() { bold "\n═══ PHP Integration Tests ═══" bold "\n Basic execution" R=$(request "GET" "/hello.php?foo=bar&n=42") assert_status "PHP script execution" "200" "$(status_of "$R")" B=$(body_of "$R") assert_contains "GET method" "$B" '"method":"GET"' assert_contains "Query string parsed" "$B" '"foo":"bar"' assert_contains "PHP_SELF set" "$B" 'php_self' assert_contains "SERVER_SOFTWARE" "$B" 'QbixServer' assert_contains "GATEWAY_INTERFACE" "$B" 'CGI' assert_contains "Q class available" "$B" '"q_class":true' assert_contains "Q_Request available" "$B" '"q_request":true' assert_contains "Q_Config available" "$B" '"q_config":true' bold "\n Cookies" R=$(request "GET" "/hello.php" "Cookie: session=abc123; theme=dark") B=$(body_of "$R") assert_contains "Cookie parsed" "$B" '"session":"abc123"' assert_contains "Multiple cookies" "$B" '"theme":"dark"' bold "\n Basic auth" AUTH=$(echo -n "admin:secret" | base64) R=$(request "GET" "/hello.php" "Authorization: Basic $AUTH") B=$(body_of "$R") assert_contains "Auth user parsed" "$B" '"auth_user":"admin"' bold "\n POST form data" R=$(request "POST" "/hello.php" "Content-Type: application/x-www-form-urlencoded" "name=Alice&age=30") B=$(body_of "$R") assert_contains "POST data parsed" "$B" '"name":"Alice"' bold "\n POST JSON" R=$(request "POST" "/hello.php" "Content-Type: application/json" '{"msg":"hello"}') B=$(body_of "$R") assert_contains "JSON body parsed" "$B" '"msg":"hello"' assert_contains "Raw input available" "$B" '"raw_input":"{\\\"msg\\\":\\\"hello\\\"}"' bold "\n HTTPS detection via proxy" R=$(request "GET" "/hello.php" "X-Forwarded-Proto: https") B=$(body_of "$R") assert_contains "HTTPS detected from proxy" "$B" '"scheme":"https"' bold "\n File upload (multipart)" BOUNDARY="----QbixTestBoundary" BODY="------QbixTestBoundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMyDoc\r\n------QbixTestBoundary\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nFile content here\r\n------QbixTestBoundary--\r\n" R=$(timeout 5 php -r ' $fp = @fsockopen("'"$HOST"'", '"$PORT"', $e, $es, 2); if (!$fp) { echo "CONNECT_FAIL"; exit; } $body = "------QbixTestBoundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMyDoc\r\n------QbixTestBoundary\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nFile content here\r\n------QbixTestBoundary--\r\n"; $h = "POST /upload.php HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=----QbixTestBoundary\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n"; fwrite($fp, $h . $body); stream_set_timeout($fp, 3); $r = ""; while (!feof($fp)) { $c = @fread($fp, 65536); if ($c===""||$c===false) break; $r .= $c; } fclose($fp); echo $r; ' 2>/dev/null) B=$(body_of "$R") assert_contains "Multipart POST field" "$B" '"title":"MyDoc"' assert_contains "File upload name" "$B" '"name":"test.txt"' assert_contains "File upload content" "$B" '"content":"File content here"' bold "\n Response headers from PHP" R=$(request "GET" "/headers.php") assert_status "Custom status code" "201" "$(status_of "$R")" assert_contains "Custom header" "$R" "X-Custom-Header: hello" bold "\n Crash isolation" R=$(request "GET" "/exit.php") # Server should survive sleep 1 R2=$(request "GET" "/index.html") assert_status "Server survives exit()" "200" "$(status_of "$R2")" R=$(request "GET" "/fatal.php") sleep 1 R2=$(request "GET" "/index.html") assert_status "Server survives fatal error" "200" "$(status_of "$R2")" } run_bench() { bold "\n═══ Performance Benchmarks ═══" if ! command -v ab &>/dev/null; then yellow " ⊘ Apache Bench (ab) not found — skipping benchmarks" yellow " Install: sudo apt install apache2-utils" SKIP=$((SKIP+3)) return fi bold "\n Static file throughput" echo -n " Sequential (c=1): " ab -n 2000 -c 1 http://$HOST:$PORT/index.html 2>&1 | grep "Requests per second" | awk '{print $4 " req/s"}' echo -n " Concurrent (c=10): " ab -n 5000 -c 10 http://$HOST:$PORT/index.html 2>&1 | grep "Requests per second" | awk '{print $4 " req/s"}' echo -n " Keep-alive (c=10): " ab -n 5000 -c 10 -k http://$HOST:$PORT/index.html 2>&1 | grep "Requests per second" | awk '{print $4 " req/s"}' echo -n " Keep-alive (c=50): " ab -n 10000 -c 50 -k http://$HOST:$PORT/index.html 2>&1 | grep "Requests per second" | awk '{print $4 " req/s"}' bold "\n PHP throughput" echo -n " PHP script (c=10): " ab -n 500 -c 10 -s 10 http://$HOST:$PORT/hello.php 2>&1 | grep "Requests per second" | awk '{print $4 " req/s"}' bold "\n Stress test" RESULT=$(ab -n 10000 -c 50 -k http://$HOST:$PORT/index.html 2>&1) FAILED=$(echo "$RESULT" | grep "Failed requests" | awk '{print $3}') RPS=$(echo "$RESULT" | grep "Requests per second" | awk '{print $4}') if [ "$FAILED" = "0" ]; then green " ✓ 10K requests @ c=50: $RPS req/s, 0 failures" PASS=$((PASS+1)) else red " ✗ 10K requests @ c=50: $FAILED failures" FAIL=$((FAIL+1)) fi bold "\n Server still alive after stress" R=$(request "GET" "/index.html") assert_status "Alive after stress test" "200" "$(status_of "$R")" } run_bench_nginx() { bold "\n═══ nginx Comparison ═══" if ! command -v nginx &>/dev/null; then yellow " ⊘ nginx not found — skipping comparison" return fi if ! command -v ab &>/dev/null; then yellow " ⊘ Apache Bench (ab) not found" return fi NGINX_PORT=19877 NGINX_CONF="/tmp/qbix_test_nginx.conf" cat > "$NGINX_CONF" << NGINXEOF worker_processes 1; error_log /dev/null; pid /tmp/qbix_test_nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; access_log off; server { listen $NGINX_PORT; root $SCRIPT_DIR/web; } } NGINXEOF nginx -c "$NGINX_CONF" 2>/dev/null sleep 1 printf "\n %-25s %12s %12s %8s\n" "Scenario" "nginx" "Qbix" "Ratio" printf " %-25s %12s %12s %8s\n" "─────────────────────────" "────────────" "────────────" "────────" for scenario in "c=1:-c 1 -n 2000" "c=10:-c 10 -n 5000" "c=10 keep-alive:-c 10 -n 5000 -k" "c=50 keep-alive:-c 50 -n 10000 -k"; do NAME="${scenario%%:*}" ARGS="${scenario##*:}" NGINX_RPS=$(ab $ARGS http://$HOST:$NGINX_PORT/index.html 2>&1 | grep "Requests per" | awk '{printf "%.0f", $4}') QBIX_RPS=$(ab $ARGS http://$HOST:$PORT/index.html 2>&1 | grep "Requests per" | awk '{printf "%.0f", $4}') if [ -n "$NGINX_RPS" ] && [ "$NGINX_RPS" -gt 0 ]; then RATIO=$(echo "scale=0; $QBIX_RPS * 100 / $NGINX_RPS" | bc) printf " %-25s %10s/s %10s/s %6s%%\n" "$NAME" "$NGINX_RPS" "$QBIX_RPS" "$RATIO" fi done nginx -s stop -c "$NGINX_CONF" 2>/dev/null || true rm -f "$NGINX_CONF" /tmp/qbix_test_nginx.pid } # ── Main ───────────────────────────────────────────── bold "╔══════════════════════════════════════════╗" bold "║ Qbix Server — Test Suite ║" bold "╚══════════════════════════════════════════╝" start_server case "${1:-all}" in --quick) run_functional run_security run_php ;; --bench) run_bench ;; --bench-nginx) run_bench run_bench_nginx ;; *) run_functional run_security run_php run_bench ;; esac # ── Summary ────────────────────────────────────────── bold "\n╔══════════════════════════════════════════╗" if [ $FAIL -eq 0 ]; then green "║ ✓ ALL TESTS PASSED ║" else red "║ ✗ SOME TESTS FAILED ║" fi bold "╚══════════════════════════════════════════╝" echo "" echo " Passed: $PASS" echo " Failed: $FAIL" echo " Skipped: $SKIP" if [ $FAIL -gt 0 ]; then echo -e "\n Failures:$ERRORS" fi echo "" stop_server exit $FAIL