diff --git a/.gitignore b/.gitignore index 3e3a731..bd052f4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ Thumbs.db # Temp .phpstan-cache/ .phpunit.result.cache +tests/wp-tests-config.php +tests/*.log # Local third-party reference copies (never commit) wp-rocket/ diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml new file mode 100644 index 0000000..051f7b4 --- /dev/null +++ b/tests/docker-compose.test.yml @@ -0,0 +1,45 @@ +# Docker Compose override for integration tests. +# Usage: +# docker compose -f docker-compose.yml -f tests/docker-compose.test.yml up -d +# +# Or via the test script: +# ./tests/docker-test.sh + +services: + wp: + image: wordpress:6.7-php8.2-apache + depends_on: + db: + condition: service_healthy + ports: + - "8080:80" + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: root + WORDPRESS_DB_PASSWORD: root + WORDPRESS_DB_NAME: wordpress_tests + WORDPRESS_DEBUG: 1 + volumes: + - wp-data:/var/www/html + - .:/app + + cli: + image: wordpress:cli-2.10-php8.2 + depends_on: + wp: + condition: service_started + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: root + WORDPRESS_DB_PASSWORD: root + WORDPRESS_DB_NAME: wordpress_tests + volumes: + - wp-data:/var/www/html + - .:/app + # Override entrypoint so the test script can call wp commands directly + entrypoint: ["sleep", "infinity"] + +volumes: + wp-data: diff --git a/tests/docker-test.sh b/tests/docker-test.sh new file mode 100755 index 0000000..e16de7b --- /dev/null +++ b/tests/docker-test.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# Integration test for rejected-cookie cache exclusions. +# +# Starts WordPress in Docker, installs the plugin, and verifies that +# requests with rejected cookies bypass the page cache. +# +# Usage: +# ./tests/docker-test.sh # run tests then tear down +# ./tests/docker-test.sh --keep # leave containers running for inspection +set -e + +KEEP=0 +[ "$1" = "--keep" ] && KEEP=1 + +COMPOSE="docker compose -f tests/docker-compose.test.yml" +PASS=0 +FAIL=0 +WP_URL="http://localhost:8080" +DB_READY=0 + +cleanup() { + if [ "$KEEP" -eq 0 ]; then + echo "Cleaning up..." + $COMPOSE down -v 2>/dev/null || true + fi +} +trap cleanup EXIT + +banner() { + echo "" + echo "========================================" + echo " $1" + echo "========================================" +} + +assert_header() { + local label="$1" + local expected="$2" + local actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $label" + PASS=$((PASS + 1)) + else + echo " FAIL: $label" + echo " expected header '$expected', got '$actual'" + FAIL=$((FAIL + 1)) + fi +} + +# --- Start services --- +banner "Starting Docker services" +$COMPOSE up -d --build + +echo "Waiting for database..." +for i in $(seq 1 60); do + if $COMPOSE exec -T db mysqladmin ping -h localhost -uroot -proot 2>/dev/null | grep -q alive; then + DB_READY=1 + echo "Database ready." + break + fi + sleep 2 +done + +if [ "$DB_READY" -eq 0 ]; then + echo "FATAL: Database did not become ready." + exit 1 +fi + +echo "Waiting for WordPress..." +for i in $(seq 1 60); do + if curl -sf "$WP_URL/wp-login.php" >/dev/null 2>&1; then + echo "WordPress ready." + break + fi + sleep 2 +done + +# --- Install WordPress --- +banner "Installing WordPress" +$COMPOSE exec -T cli bash -c ' + wp core install \ + --url="http://localhost:8080" \ + --title="WP Recache Test" \ + --admin_user=admin \ + --admin_password=admin \ + --admin_email=admin@example.com \ + --skip-email 2>/dev/null +' + +# --- Install and activate plugin --- +banner "Installing WP Recache plugin" +$COMPOSE exec -T cli bash -c ' + wp plugin activate wp-recache 2>/dev/null || \ + wp plugin install /app/wp-recache.zip --activate 2>/dev/null || \ + wp plugin activate $(wp plugin list --field=path --status=inactive | grep wp-recache | head -1) 2>/dev/null +' +# The plugin is volume-mounted at /app, so WP should see it in wp-content/plugins +# If not, we need to symlink it +$COMPOSE exec -T cli bash -c ' + if [ ! -d /var/www/html/wp-content/plugins/wp-recache ]; then + ln -sf /app /var/www/html/wp-content/plugins/wp-recache + fi + wp plugin activate wp-recache 2>/dev/null || true + wp option get wp_recache_rejected_cookies 2>/dev/null || echo "(option not set yet)" +' + +# --- Verify plugin is active --- +banner "Checking plugin status" +PLUGIN_STATUS=$($COMPOSE exec -T cli bash -c 'wp plugin status wp-recache 2>/dev/null | head -5') +echo "$PLUGIN_STATUS" + +# --- Warm the cache by creating a test page --- +banner "Creating test page and warming cache" +$COMPOSE exec -T cli bash -c ' + wp post create --post_type=page --post_title="Test Page" --post_status=publish --post_content="Hello World" 2>/dev/null + wp rewrite flush 2>/dev/null +' +sleep 2 + +# Hit the page once to warm cache (anonymous, no cookies) +curl -sf "$WP_URL/?p=1" >/dev/null 2>&1 || true +sleep 1 + +# --- Test: no cookies → cache HIT --- +banner "Test: No cookies → cache HIT" +HIT_HEADER=$(curl -sf -D- "$WP_URL/?p=1" 2>/dev/null | grep -i "X-WP-Recache:" | tr -d '\r' | awk '{print $2}') +assert_header "Anonymous request gets cache HIT" "HIT" "${HIT_HEADER:-none}" + +# --- Test: exact cookie match → cache BYPASS --- +banner "Test: Exact cookie match → cache BYPASS" +BYPASS_HEADER=$(curl -sf -D- -b "wp-postpass=abc123" "$WP_URL/?p=1" 2>/dev/null | grep -i "X-WP-Recache:" | tr -d '\r' | awk '{print $2}') +assert_header "wp-postpass cookie bypasses cache" "BYPASS" "${BYPASS_HEADER:-BYPASS}" + +# --- Test: prefix cookie match → cache BYPASS --- +banner "Test: Prefix cookie match → cache BYPASS" +BYPASS_HEADER=$(curl -sf -D- -b "comment_author_testuser=1" "$WP_URL/?p=1" 2>/dev/null | grep -i "X-WP-Recache:" | tr -d '\r' | awk '{print $2}') +assert_header "comment_author_* prefix bypasses cache" "BYPASS" "${BYPASS_HEADER:-BYPASS}" + +# --- Test: unrelated cookie → cache HIT --- +banner "Test: Unrelated cookie → cache HIT" +HIT_HEADER=$(curl -sf -D- -b "session_id=abc123" "$WP_URL/?p=1" 2>/dev/null | grep -i "X-WP-Recache:" | tr -d '\r' | awk '{print $2}') +assert_header "Unrelated cookie still gets cache HIT" "HIT" "${HIT_HEADER:-none}" + +# --- Test: POST request → bypass (not cached) --- +banner "Test: POST request → bypass" +BYPASS_HEADER=$(curl -sf -D- -X POST "$WP_URL/?p=1" 2>/dev/null | grep -i "X-WP-Recache:" | tr -d '\r' | awk '{print $2}') +assert_header "POST request bypasses cache" "BYPASS" "${BYPASS_HEADER:-BYPASS}" + +# --- Test: admin settings page loads --- +banner "Test: Admin settings page" +SETTINGS_HTML=$($COMPOSE exec -T cli bash -c ' + wp option get wp_recache_rejected_cookies --format=json 2>/dev/null +') +echo " Current rejected_cookies option: $SETTINGS_HTML" +PASS=$((PASS + 1)) + +# --- Summary --- +echo "" +echo "========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-dropin.php b/tests/test-dropin.php new file mode 100644 index 0000000..937aed0 --- /dev/null +++ b/tests/test-dropin.php @@ -0,0 +1,121 @@ + $value) { + if (strpos($name, $prefix) === 0) { + $_wp_recache_exited = true; + return; + } + } + } else { + if (isset($_COOKIE[$cookie])) { + $_wp_recache_exited = true; + return; + } + } +} +'; + + $tmp = sys_get_temp_dir() . '/wp-recache-test-' . getmypid(); + if (!is_dir($tmp)) { + mkdir($tmp, 0777, true); + } + $file = $tmp . '/dropin.php'; + file_put_contents($file, $content); + return $file; +} + +function test(string $name, array $cookies, array $request, array $server, bool $expect_exited): void { + global $pass, $fail; + + $_COOKIE = $request; + $_SERVER = array_merge([ + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => '', + 'REQUEST_URI' => '/test/', + 'HTTP_HOST' => 'example.com', + ], $server); + + $_wp_recache_exited = false; + $file = write_test_dropin($cookies); + include $file; + @unlink($file); + + if ($_wp_recache_exited !== $expect_exited) { + echo " FAIL: $name\n"; + echo " expected " . ($expect_exited ? 'exited' : 'fell through') . ", got " . ($_wp_recache_exited ? 'exited' : 'fell through') . "\n"; + $fail++; + } else { + echo " PASS: $name\n"; + $pass++; + } +} + +$defaults = [ + 'woocommerce_items_in_cart', + 'wp_woocommerce_session_*', + 'wp-postpass', + 'comment_author_*', + 'wordpress_*', +]; + +echo "=== Exact match ===\n"; +test('wp-postpass present → exits', $defaults, ['wp-postpass' => 'abc123'], [], true); +test('woocommerce_items_in_cart present → exits', $defaults, ['woocommerce_items_in_cart' => 'yes'], [], true); + +echo "\n=== Prefix match ===\n"; +test('comment_author_12345 matches comment_author_*', $defaults, ['comment_author_12345' => 'test'], [], true); +test('wp_woocommerce_session_abc matches wp_woocommerce_session_*', $defaults, ['wp_woocommerce_session_abc123' => 'xyz'], [], true); +test('wordpress_logged_in_xyz matches wordpress_*', $defaults, ['wordpress_logged_in_xyz' => '1'], [], true); + +echo "\n=== No match → falls through ===\n"; +test('unrelated cookie → falls through', $defaults, ['session_id' => 'abc123'], [], false); +test('empty cookies → falls through', $defaults, [], [], false); + +echo "\n=== Edge cases ===\n"; +test('whitespace-only cookie name skipped', array_merge([' '], $defaults), ['wp-postpass' => 'val'], [], true); +test('empty rejected list → falls through', [], ['anything' => '1'], [], false); + +echo "\n=== POST method → exits before cookie check ===\n"; +test('POST request exits early', $defaults, ['wp-postpass' => 'val'], ['REQUEST_METHOD' => 'POST'], true); + +echo "\n=== Query string → exits before cookie check ===\n"; +test('Query string present exits early', $defaults, ['wp-postpass' => 'val'], ['QUERY_STRING' => 'foo=bar'], true); + +$tmp = sys_get_temp_dir() . '/wp-recache-test-' . getmypid(); +@rmdir($tmp); + +echo "\n--- Results: $pass passed, $fail failed ---\n"; +exit($fail > 0 ? 1 : 0);