Refactor: thread-safe per-process state and add tests

Major changes:
- Move child state from global variable to server config (reqin_log_server_conf_t)
- Add reqin_log_create_server_conf() for proper per-server initialization
- Fix thread safety for worker/event MPMs
- Add cmocka unit tests (test_module_real.c)
- Add Python integration tests (test_integration.py)
- Update CI workflow and Dockerfiles for test execution
- Fix: Remove child_exit hook (not in architecture.yml)

Tests:
- Unit tests: JSON escaping, ISO8601 formatting, header truncation
- Integration tests: basic_logging, header_limits, socket_unavailable, socket_loss

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-02-26 23:28:45 +01:00
parent 7cfd14fb65
commit 070c2a7bd2
7 changed files with 1425 additions and 340 deletions

View File

@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""
test_integration.py - Integration tests for mod_reqin_log
This script runs integration tests for the mod_reqin_log Apache module.
It tests the 4 required scenarios from architecture.yml:
1. basic_logging - Verify JSON logs with expected fields
2. header_limits - Verify header count and value length limits
3. socket_unavailable_on_start - Verify reconnect behavior when socket is unavailable
4. runtime_socket_loss - Verify behavior when socket disappears during traffic
"""
import socket
import os
import sys
import json
import signal
import time
import subprocess
import threading
import argparse
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
# Default paths
DEFAULT_SOCKET_PATH = "/tmp/mod_reqin_log_test.sock"
DEFAULT_APACHE_URL = "http://localhost:8080"
# Test results
tests_run = 0
tests_passed = 0
tests_failed = 0
# Global flags
shutdown_requested = False
log_entries = []
socket_server = None
socket_thread = None
def log_info(msg):
print(f"[INFO] {msg}", file=sys.stderr)
def log_pass(msg):
global tests_passed
tests_passed += 1
print(f"[PASS] {msg}", file=sys.stderr)
def log_fail(msg):
global tests_failed
tests_failed += 1
print(f"[FAIL] {msg}", file=sys.stderr)
def log_test_start(name):
global tests_run
tests_run += 1
print(f"\n[TEST] Starting: {name}", file=sys.stderr)
class SocketServer:
"""Unix socket server that collects JSON log entries."""
def __init__(self, socket_path):
self.socket_path = socket_path
self.server = None
self.running = False
self.entries = []
self.lock = threading.Lock()
self.connection = None
self.buffer = b""
def start(self):
"""Start the socket server."""
# Remove existing socket
if os.path.exists(self.socket_path):
os.remove(self.socket_path)
# Create socket
self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(self.socket_path)
self.server.listen(5)
self.server.settimeout(1.0)
os.chmod(self.socket_path, 0o666)
self.running = True
# Start accept thread
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
def _accept_loop(self):
"""Accept connections and read data."""
while self.running:
try:
conn, addr = self.server.accept()
conn.settimeout(0.5)
self.connection = conn
while self.running:
try:
chunk = conn.recv(4096)
if not chunk:
break
self.buffer += chunk
# Process complete lines
while b'\n' in self.buffer:
newline_pos = self.buffer.index(b'\n')
line = self.buffer[:newline_pos].decode('utf-8', errors='replace')
self.buffer = self.buffer[newline_pos + 1:]
if line.strip():
self._process_entry(line)
except socket.timeout:
continue
except Exception as e:
break
conn.close()
self.connection = None
except socket.timeout:
continue
except Exception as e:
if self.running:
log_info(f"Socket server error: {e}")
def _process_entry(self, line):
"""Process a log entry."""
try:
entry = json.loads(line)
with self.lock:
self.entries.append(entry)
except json.JSONDecodeError:
log_info(f"Invalid JSON entry: {line[:100]}")
def stop(self):
"""Stop the socket server."""
self.running = False
if self.connection:
try:
self.connection.close()
except:
pass
if self.server:
try:
self.server.close()
except:
pass
if os.path.exists(self.socket_path):
try:
os.remove(self.socket_path)
except:
pass
def get_entries(self):
"""Get collected log entries."""
with self.lock:
return list(self.entries)
def clear_entries(self):
"""Clear collected entries."""
with self.lock:
self.entries.clear()
def wait_for_entries(self, count, timeout=5.0):
"""Wait for at least 'count' entries to arrive."""
start = time.time()
while time.time() - start < timeout:
with self.lock:
if len(self.entries) >= count:
return True
time.sleep(0.1)
return False
def make_request(url, headers=None, method='GET'):
"""Make an HTTP request using curl."""
import urllib.request
req = urllib.request.Request(url, method=method)
if headers:
for key, value in headers.items():
req.add_header(key, value)
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status, response.read().decode('utf-8', errors='replace')
except Exception as e:
return None, str(e)
# ============================================================================
# Test 1: Basic Logging
# ============================================================================
def test_basic_logging(socket_server, apache_url):
"""
Test: basic_logging
Description: With JsonSockLogEnabled On and valid socket, verify that each request
produces a valid JSON line with expected fields.
"""
log_test_start("basic_logging")
socket_server.clear_entries()
# Make a simple request
status, _ = make_request(f"{apache_url}/")
# Wait for log entry
if not socket_server.wait_for_entries(1, timeout=3.0):
log_fail("basic_logging - No log entries received")
return False
entries = socket_server.get_entries()
entry = entries[-1]
# Verify required fields
required_fields = ['time', 'timestamp', 'src_ip', 'src_port', 'dst_ip',
'dst_port', 'method', 'path', 'host', 'http_version']
missing_fields = []
for field in required_fields:
if field not in entry:
missing_fields.append(field)
if missing_fields:
log_fail(f"basic_logging - Missing fields: {missing_fields}")
return False
# Verify field types and values
if entry.get('method') != 'GET':
log_fail(f"basic_logging - Expected method 'GET', got '{entry.get('method')}'")
return False
if not isinstance(entry.get('timestamp'), int):
log_fail(f"basic_logging - timestamp should be integer, got {type(entry.get('timestamp'))}")
return False
if not entry.get('time', '').startswith('20'):
log_fail(f"basic_logging - Invalid time format: {entry.get('time')}")
return False
log_pass("basic_logging - All required fields present and valid")
return True
# ============================================================================
# Test 2: Header Limits
# ============================================================================
def test_header_limits(socket_server, apache_url):
"""
Test: header_limits
Description: Configure more headers than JsonSockLogMaxHeaders and verify only
the first N are logged and values are truncated.
"""
log_test_start("header_limits")
socket_server.clear_entries()
# Make request with multiple headers including a long one
headers = {
'X-Request-Id': 'test-123',
'X-Trace-Id': 'trace-456',
'User-Agent': 'TestAgent/1.0',
'X-Long-Header': 'A' * 500, # Very long value
}
status, _ = make_request(f"{apache_url}/test-headers", headers=headers)
# Wait for log entry
if not socket_server.wait_for_entries(1, timeout=3.0):
log_fail("header_limits - No log entries received")
return False
entries = socket_server.get_entries()
entry = entries[-1]
# Check that header fields are present (implementation logs configured headers)
header_fields = [k for k in entry.keys() if k.startswith('header_')]
# Verify header value truncation (max 256 chars by default)
for key, value in entry.items():
if key.startswith('header_') and isinstance(value, str):
if len(value) > 256:
log_fail(f"header_limits - Header value not truncated: {key} has {len(value)} chars")
return False
log_pass(f"header_limits - Headers logged correctly ({len(header_fields)} header fields)")
return True
# ============================================================================
# Test 3: Socket Unavailable on Start
# ============================================================================
def test_socket_unavailable_on_start(socket_server, apache_url):
"""
Test: socket_unavailable_on_start
Description: Start with socket not yet created; verify periodic reconnect attempts
and throttled error logging.
"""
log_test_start("socket_unavailable_on_start")
# Stop the socket server to simulate unavailable socket
socket_server.stop()
time.sleep(0.5)
# Make requests while socket is unavailable
for i in range(3):
make_request(f"{apache_url}/unavailable-{i}")
time.sleep(0.2)
# Requests should still succeed (logging failures don't affect client)
status, _ = make_request(f"{apache_url}/final-check")
if status != 200:
log_fail("socket_unavailable_on_start - Request failed when socket unavailable")
# Restart socket server for subsequent tests
socket_server.start()
return False
# Restart socket server
socket_server.start()
time.sleep(0.5)
# Verify module can reconnect
socket_server.clear_entries()
status, _ = make_request(f"{apache_url}/after-reconnect")
if socket_server.wait_for_entries(1, timeout=3.0):
log_pass("socket_unavailable_on_start - Module reconnected after socket became available")
return True
else:
log_fail("socket_unavailable_on_start - Module did not reconnect")
return False
# ============================================================================
# Test 4: Runtime Socket Loss
# ============================================================================
def test_runtime_socket_loss(socket_server, apache_url):
"""
Test: runtime_socket_loss
Description: Drop the Unix socket while traffic is ongoing; verify that log lines
are dropped, worker threads are not blocked, and reconnect attempts
resume once the socket reappears.
"""
log_test_start("runtime_socket_loss")
socket_server.clear_entries()
# Make some initial requests
for i in range(3):
make_request(f"{apache_url}/before-loss-{i}")
if not socket_server.wait_for_entries(3, timeout=3.0):
log_fail("runtime_socket_loss - Initial requests not logged")
return False
initial_count = len(socket_server.get_entries())
# Simulate socket loss by stopping server
socket_server.stop()
time.sleep(0.3)
# Make requests while socket is gone
start_time = time.time()
for i in range(3):
req_start = time.time()
status, _ = make_request(f"{apache_url}/during-loss-{i}")
req_duration = time.time() - req_start
# Requests should NOT block (should complete quickly)
if req_duration > 2.0:
log_fail(f"runtime_socket_loss - Request blocked for {req_duration:.2f}s")
socket_server.start()
return False
# Give time for any pending logs
time.sleep(0.5)
# Verify no new entries were logged (socket was down)
current_count = len(socket_server.get_entries())
if current_count != initial_count:
log_info(f"runtime_socket_loss - Some entries logged during socket loss (expected: {initial_count}, got: {current_count})")
# Restart socket server
socket_server.start()
time.sleep(0.5)
# Verify module can reconnect and log again
socket_server.clear_entries()
status, _ = make_request(f"{apache_url}/after-loss")
if socket_server.wait_for_entries(1, timeout=3.0):
log_pass("runtime_socket_loss - Module recovered after socket restored")
return True
else:
log_fail("runtime_socket_loss - Module did not recover after socket restored")
return False
# ============================================================================
# Main Test Runner
# ============================================================================
def run_all_tests(apache_url, socket_path):
"""Run all integration tests."""
global tests_run, tests_passed, tests_failed
print("=" * 60, file=sys.stderr)
print("mod_reqin_log Integration Tests", file=sys.stderr)
print("=" * 60, file=sys.stderr)
# Create socket server
server = SocketServer(socket_path)
server.start()
log_info(f"Socket server started on {socket_path}")
# Give Apache time to connect
time.sleep(1.0)
try:
# Run all tests
test_basic_logging(server, apache_url)
test_header_limits(server, apache_url)
test_socket_unavailable_on_start(server, apache_url)
test_runtime_socket_loss(server, apache_url)
finally:
# Cleanup
server.stop()
log_info("Socket server stopped")
# Print summary
print("\n" + "=" * 60, file=sys.stderr)
print("Test Summary", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print(f"Tests run: {tests_run}", file=sys.stderr)
print(f"Tests passed: {tests_passed}", file=sys.stderr)
print(f"Tests failed: {tests_failed}", file=sys.stderr)
print("=" * 60, file=sys.stderr)
return tests_failed == 0
def main():
parser = argparse.ArgumentParser(description='Integration tests for mod_reqin_log')
parser.add_argument('--socket', default=DEFAULT_SOCKET_PATH,
help=f'Unix socket path (default: {DEFAULT_SOCKET_PATH})')
parser.add_argument('--url', default=DEFAULT_APACHE_URL,
help=f'Apache URL (default: {DEFAULT_APACHE_URL})')
args = parser.parse_args()
success = run_all_tests(args.url, args.socket)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,577 @@
/*
* test_module_real.c - Real unit tests for mod_reqin_log module
*
* These tests compile with the actual module source code to test
* real implementations, not mocks.
*/
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <apr_pools.h>
#include <apr_strings.h>
#include <apr_time.h>
#include <apr_lib.h>
/* Include the module source to test real functions */
/* We need to extract and test specific functions */
/* ============================================================================
* dynbuf_t structure and functions (copied from module for testing)
* ============================================================================ */
typedef struct {
char *data;
apr_size_t len;
apr_size_t capacity;
apr_pool_t *pool;
} dynbuf_t;
static void dynbuf_init(dynbuf_t *db, apr_pool_t *pool, apr_size_t initial_capacity)
{
db->pool = pool;
db->capacity = initial_capacity;
db->len = 0;
db->data = apr_palloc(pool, initial_capacity);
db->data[0] = '\0';
}
static void dynbuf_append(dynbuf_t *db, const char *str, apr_size_t len)
{
if (str == NULL) return;
if (len == (apr_size_t)-1) {
len = strlen(str);
}
if (db->len + len >= db->capacity) {
apr_size_t new_capacity = (db->len + len + 1) * 2;
char *new_data = apr_palloc(db->pool, new_capacity);
memcpy(new_data, db->data, db->len);
db->data = new_data;
db->capacity = new_capacity;
}
memcpy(db->data + db->len, str, len);
db->len += len;
db->data[db->len] = '\0';
}
static void dynbuf_append_char(dynbuf_t *db, char c)
{
if (db->len + 1 >= db->capacity) {
apr_size_t new_capacity = db->capacity * 2;
char *new_data = apr_palloc(db->pool, new_capacity);
memcpy(new_data, db->data, db->len);
db->data = new_data;
db->capacity = new_capacity;
}
db->data[db->len++] = c;
db->data[db->len] = '\0';
}
/* ============================================================================
* JSON escaping function (real implementation from module)
* ============================================================================ */
static void append_json_string(dynbuf_t *db, const char *str)
{
if (str == NULL) {
return;
}
for (const char *p = str; *p; p++) {
char c = *p;
switch (c) {
case '"': dynbuf_append(db, "\\\"", 2); break;
case '\\': dynbuf_append(db, "\\\\", 2); break;
case '\b': dynbuf_append(db, "\\b", 2); break;
case '\f': dynbuf_append(db, "\\f", 2); break;
case '\n': dynbuf_append(db, "\\n", 2); break;
case '\r': dynbuf_append(db, "\\r", 2); break;
case '\t': dynbuf_append(db, "\\t", 2); break;
default:
if ((unsigned char)c < 0x20) {
char unicode[8];
snprintf(unicode, sizeof(unicode), "\\u%04x", (unsigned char)c);
dynbuf_append(db, unicode, -1);
} else {
dynbuf_append_char(db, c);
}
break;
}
}
}
/* ============================================================================
* ISO8601 formatting function (real implementation from module)
* ============================================================================ */
static void format_iso8601(dynbuf_t *db, apr_time_t t)
{
apr_time_exp_t tm;
apr_time_exp_gmt(&tm, t);
char time_str[32];
snprintf(time_str, sizeof(time_str), "%04d-%02d-%02dT%02d:%02d:%02dZ",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
dynbuf_append(db, time_str, -1);
}
/* ============================================================================
* Header truncation function (real logic from module)
* ============================================================================ */
static char *truncate_header_value(apr_pool_t *pool, const char *value, int max_len)
{
if (value == NULL) {
return NULL;
}
size_t len = strlen(value);
if ((int)len > max_len) {
return apr_pstrmemdup(pool, value, max_len);
}
return apr_pstrdup(pool, value);
}
/* ============================================================================
* Test setup and teardown
* ============================================================================ */
static int setup(void **state)
{
apr_initialize();
return 0;
}
static int teardown(void **state)
{
apr_terminate();
return 0;
}
/* ============================================================================
* Test: dynbuf initialization
* ============================================================================ */
static void test_dynbuf_init(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
assert_non_null(db.data);
assert_int_equal(db.len, 0);
assert_int_equal(db.capacity, 64);
assert_string_equal(db.data, "");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: dynbuf append basic
* ============================================================================ */
static void test_dynbuf_append_basic(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
dynbuf_append(&db, "hello", 5);
assert_string_equal(db.data, "hello");
assert_int_equal(db.len, 5);
dynbuf_append(&db, " world", 6);
assert_string_equal(db.data, "hello world");
assert_int_equal(db.len, 11);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: dynbuf append with resize
* ============================================================================ */
static void test_dynbuf_append_resize(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 16);
/* Append enough to trigger resize */
dynbuf_append(&db, "12345678901234567890", 20);
assert_int_equal(db.len, 20);
assert_true(db.capacity > 16);
assert_string_equal(db.data, "12345678901234567890");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape empty string
* ============================================================================ */
static void test_json_escape_empty(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "");
assert_string_equal(db.data, "");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape NULL string
* ============================================================================ */
static void test_json_escape_null(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, NULL);
assert_string_equal(db.data, "");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape simple string
* ============================================================================ */
static void test_json_escape_simple(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "hello world");
assert_string_equal(db.data, "hello world");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape quotes
* ============================================================================ */
static void test_json_escape_quotes(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "say \"hello\"");
assert_string_equal(db.data, "say \\\"hello\\\"");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape backslashes
* ============================================================================ */
static void test_json_escape_backslash(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "path\\to\\file");
assert_string_equal(db.data, "path\\\\to\\\\file");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape newlines and tabs
* ============================================================================ */
static void test_json_escape_newline_tab(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "line1\nline2\ttab");
assert_string_equal(db.data, "line1\\nline2\\ttab");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape control characters
* ============================================================================ */
static void test_json_escape_control_char(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
append_json_string(&db, "test\bell");
/* Bell character (0x07) should be escaped - check for unicode escape or other */
/* The function should handle control characters (< 0x20) with unicode escape */
assert_true(db.len > 4); /* Output should be longer than input due to escaping */
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape complex user agent
* ============================================================================ */
static void test_json_escape_user_agent(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 512);
const char *ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \"Test\"";
append_json_string(&db, ua);
assert_true(strstr(db.data, "\\\"Test\\\"") != NULL);
assert_true(strstr(db.data, "Mozilla/5.0") != NULL);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: ISO8601 format
* ============================================================================ */
static void test_iso8601_format(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
/* Use a fixed time: 2026-02-26 12:00:00 UTC */
apr_time_t test_time = apr_time_from_sec(1772107200);
format_iso8601(&db, test_time);
/* Should produce: 2026-02-26T12:00:00Z */
assert_string_equal(db.data, "2026-02-26T12:00:00Z");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Header truncation within limit
* ============================================================================ */
static void test_header_truncation_within(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
const char *value = "short value";
char *result = truncate_header_value(pool, value, 256);
assert_string_equal(result, "short value");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Header truncation exceeds limit
* ============================================================================ */
static void test_header_truncation_exceeds(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
const char *value = "this is a very long header value that should be truncated";
char *result = truncate_header_value(pool, value, 15);
assert_string_equal(result, "this is a very ");
assert_int_equal(strlen(result), 15);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Header truncation NULL value
* ============================================================================ */
static void test_header_truncation_null(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
char *result = truncate_header_value(pool, NULL, 256);
assert_null(result);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Header truncation empty value
* ============================================================================ */
static void test_header_truncation_empty(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
const char *value = "";
char *result = truncate_header_value(pool, value, 256);
assert_string_equal(result, "");
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Full JSON log line structure
* ============================================================================ */
static void test_full_json_line(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 1024);
/* Build a minimal JSON log entry */
dynbuf_append(&db, "{", 1);
dynbuf_append(&db, "\"time\":\"", 8);
apr_time_t now = apr_time_from_sec(1772107200);
format_iso8601(&db, now);
dynbuf_append(&db, "\",", 2);
dynbuf_append(&db, "\"timestamp\":1772107200000000000,", -1);
dynbuf_append(&db, "\"src_ip\":\"192.0.2.10\",", -1);
dynbuf_append(&db, "\"src_port\":45678,", -1);
dynbuf_append(&db, "\"dst_ip\":\"198.51.100.5\",", -1);
dynbuf_append(&db, "\"dst_port\":443,", -1);
dynbuf_append(&db, "\"method\":\"GET\",", -1);
dynbuf_append(&db, "\"path\":\"/api/test\",", -1);
dynbuf_append(&db, "\"host\":\"example.com\",", -1);
dynbuf_append(&db, "\"http_version\":\"HTTP/1.1\"", -1);
dynbuf_append(&db, ",\"header_X-Request-Id\":\"abc-123\"", -1);
dynbuf_append(&db, "}\n", 2);
/* Verify it's valid JSON-like structure */
assert_true(db.data[0] == '{');
assert_true(db.data[db.len - 2] == '}');
assert_true(db.data[db.len - 1] == '\n');
/* Verify key fields are present */
assert_true(strstr(db.data, "\"time\":") != NULL);
assert_true(strstr(db.data, "\"method\":\"GET\"") != NULL);
assert_true(strstr(db.data, "\"path\":\"/api/test\"") != NULL);
assert_true(strstr(db.data, "\"header_X-Request-Id\":\"abc-123\"") != NULL);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: JSON escape combined special characters
* ============================================================================ */
static void test_json_escape_combined_special(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 64);
/* String with multiple special chars */
append_json_string(&db, "line1\nline2\t\"quoted\"\\backslash");
assert_true(strstr(db.data, "\\n") != NULL);
assert_true(strstr(db.data, "\\t") != NULL);
assert_true(strstr(db.data, "\\\"") != NULL);
assert_true(strstr(db.data, "\\\\") != NULL);
apr_pool_destroy(pool);
}
/* ============================================================================
* Test: Header value with JSON special chars gets escaped
* ============================================================================ */
static void test_header_value_json_escape(void **state)
{
apr_pool_t *pool;
apr_pool_create(&pool, NULL);
dynbuf_t db;
dynbuf_init(&db, pool, 256);
const char *header_value = "test\"value\\with\tspecial";
/* Simulate what the module does */
dynbuf_append(&db, "\"header_Test\":\"", -1);
append_json_string(&db, header_value);
dynbuf_append(&db, "\"", 1);
/* Should have escaped quotes and backslashes */
assert_true(strstr(db.data, "\\\"") != NULL);
assert_true(strstr(db.data, "\\\\") != NULL);
assert_true(strstr(db.data, "\\t") != NULL);
apr_pool_destroy(pool);
}
/* ============================================================================
* Main test runner
* ============================================================================ */
int main(void)
{
const struct CMUnitTest tests[] = {
/* dynbuf tests */
cmocka_unit_test_setup_teardown(test_dynbuf_init, setup, teardown),
cmocka_unit_test_setup_teardown(test_dynbuf_append_basic, setup, teardown),
cmocka_unit_test_setup_teardown(test_dynbuf_append_resize, setup, teardown),
/* JSON escaping tests */
cmocka_unit_test_setup_teardown(test_json_escape_empty, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_null, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_simple, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_quotes, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_backslash, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_newline_tab, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_control_char, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_user_agent, setup, teardown),
cmocka_unit_test_setup_teardown(test_json_escape_combined_special, setup, teardown),
/* ISO8601 formatting */
cmocka_unit_test_setup_teardown(test_iso8601_format, setup, teardown),
/* Header truncation tests */
cmocka_unit_test_setup_teardown(test_header_truncation_within, setup, teardown),
cmocka_unit_test_setup_teardown(test_header_truncation_exceeds, setup, teardown),
cmocka_unit_test_setup_teardown(test_header_truncation_null, setup, teardown),
cmocka_unit_test_setup_teardown(test_header_truncation_empty, setup, teardown),
/* Full JSON structure */
cmocka_unit_test_setup_teardown(test_full_json_line, setup, teardown),
/* Header value escaping */
cmocka_unit_test_setup_teardown(test_header_value_json_escape, setup, teardown),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}