Skip to content

E2E Test Coverage & Plan

Current state of end-to-end test coverage and the tests we still need to build.


Current Coverage (111 tests, 28 files)

The ephpm-e2e crate lives in crates/ephpm-e2e/ and runs inside a Kind cluster via Tilt. See developer/testing.md for infrastructure details.

Single-Node Tests

FileTestsCoverage
basic.rs3404 errors, PHP rendering, static file serving
http.rs9HEAD no body, POST body, content-type static, ETag 304, gzip compression, 413 body too large, cache-control, X-Forwarded-For, fallback to index.php
php.rs7$_GET, $_SERVER vars, exit() output, http_response_code(), $_COOKIE, php://input, custom response header
phpinfo.rs2PHP version matching, health check
php_config.rs1PHP configuration validation
php_extended.rs6Empty PHP output 200, JSON content-type, multiple Set-Cookie headers, SERVER_SOFTWARE, PUT/DELETE methods, additional PHP behavior
kv.rs11set/get, TTL expiry, atomic incr, del/exists, incr_by, expire extends TTL, pttl, setnx, mset/mget, empty values, large values
errors.rs3Fatal error 500, memory limit 500, syntax error 500 (all verify server recovery)
security.rs4Dotfile 403, PHP source not exposed, blocked_paths 403, path traversal blocked
security_p0.rs6Additional security tests (host validation, allowed PHP paths, etc.)
hidden_files.rs2Hidden file blocking modes
concurrency.rsParallel PHP requests, atomic KV increments under load (uses non-tokio test harness)
metrics.rs9Prometheus format, build info, HTTP counters, handler labels, PHP execution metrics, in-flight gauge, body size histograms, metrics self-counting, status codes
etag_cache.rs6PHP ETag 200+header, matching ETag 304, mismatched ETag 200, POST bypass, no If-None-Match 200, independent query strings
timeouts.rs2PHP sleep exceeding timeout returns 504, server recovers after timeout
timeout_edge.rs1Timeout edge cases
http_edge.rs4Percent-encoded paths, HEAD Content-Length + empty body, ~4KB query string, multiple query params
brotli.rs1Brotli accept-encoding handling
custom_headers.rs2Custom response headers via config
file_cache.rs4Open file cache behavior
vhosts.rs3Virtual host routing
sqlite.rs4Embedded SQLite via litewire
query_stats.rs3Query digest tracking and metrics
rate_limit.rs1Per-IP rate limiting
rw_split.rs6Read/write splitting
postgres_proxy.rs2PostgreSQL wire protocol proxy
tds_proxy.rs2TDS wire protocol proxy

Cluster Tests

FileTestsCoverage
cluster.rs7Cluster gossip discovery, KV replication, node membership

PHP Fixtures (tests/docroot/)

FilePurpose
index.phpPHP version, SAPI name, “Hello from ePHPm”
test.phpEchoes $_SERVER, GET/POST/COOKIE params, request headers
info.phpphpinfo() — large output for compression testing
exit_test.phpecho "bye"; exit(0); — output before exit
status_201.phphttp_response_code(201) — custom status
server_test.phpJSON dump of $_SERVER variables
server_vars.phpJSON dump of key $_SERVER variables
custom_header.phpheader('X-Custom: ok') — custom response header
error_test.phpUndefined array key — non-fatal warning
fatal_error.phpCalls undefined function — fatal error
memory_hog.phpAllocates 100 MB with 2M limit — OOM fatal
syntax_error.php$x = ; — parser error
kv.phpKV store router: set/get/del/exists/pttl/incr/incr_by/expire/setnx/mset/mget
empty.phpNo output — empty response testing
etag_test.phpSets ETag header via header() — PHP ETag cache testing
json_response.phpJSON Content-Type + json_encode() output
multi_cookie.phpSets 3 Set-Cookie headers via setcookie()
sleep.phpsleep(N) via ?seconds=N — timeout testing
large_output.php~1 MiB repeating output — body size / compression
image.png1x1 PNG (69 bytes) — binary content-type
test.htmlStatic HTML
test.cssStatic CSS
test.jsStatic JS
.envHidden file — blocked by security rules
subdir/index.htmlSubdirectory index
uploads/shell.phpAllowlist test
vendor/secret.phpBlocked paths glob test

E2E Helpers (src/lib.rs)

One exported function:

  • required_env(name) -> String — reads env var or panics

No cluster helpers, no poll_until, no optional_env.


Infrastructure

ComponentFileStatus
Kind clusterk8s/kind-config.yamlExists
Single-node deploymentk8s/base/ephpm-single.yamlExists (1 replica, port 8080, readiness probe)
E2E test jobk8s/tests/e2e-job.yamlExists (EPHPM_URL + EXPECTED_PHP_VERSION)
Tiltfilek8s/TiltfileExists (ci + dev modes)
Cluster StatefulSetMissing
Cluster headless serviceMissing
Per-pod servicesMissing
Cluster env vars in e2e jobMissing

Feature Coverage Matrix

FeatureImplementedE2E TestedGap
HTTP/1.1 servingYesYes (9 tests)
HTTP/2YesNoBlocked — requires TLS; no certs in Kind env
TLS / HTTPSYesNoBlocked — needs self-signed cert + CA trust in e2e pod
Static file servingYesYes (3 tests)
Request routing (fallback)YesYes (1 test)
Configuration (TOML + env vars)YesYes (1 test)
Embedded KV store (SAPI)YesYes (11 tests)
KV store CLI (ephpm kv)YesNoMedium
PHP embedding (ZTS)YesYes (7+2 tests)
Compression (gzip)YesYes (1 test)
Compression (brotli)YesYes (1 test)
ETags / 304 (static)YesYes (1 test)
PHP ETag cacheYesYes (6 tests)
Security (paths, dotfiles)YesYes (12 tests)
SessionsYesNoMedium
TimeoutsYesYes (3 tests)
PHP error recoveryYesYes (3 tests)
Proxy headersYesYes (1 test)Low
Custom response headersYesYes (2 tests)
Virtual hostsYesYes (3 tests)
File cacheYesYes (4 tests)
Rate limitingYesYes (1 test)
Graceful shutdownYesNoMedium — needs kubectl
Concurrency / loadYesYes
Embedded SQLite (litewire)YesYes (4 tests)
Query statsYesYes (3 tests)
R/W splittingYesYes (6 tests)
Cluster gossipYesYes (7 tests)
Cluster KV replicationYesYes (7 tests)
Cluster resilienceYesNoMedium
Observability (metrics)YesYes (9 tests)
Observability (tracing)PartialNoLow
CLIPartialNoMedium
ACMEYesNoBlocked — needs real domain in e2e env
DB proxy (MySQL)YesYes (6 tests)
DB proxy (PostgreSQL)PartialYes (2 tests)
DB proxy (TDS)PartialYes (2 tests)
Admin UI / APIPlanned

Tests To Build

High Priority — Missing coverage for implemented features

1. Cluster Infrastructure + Discovery

Build the K8s resources and test cluster membership.

  • Create k8s/base/ephpm-cluster.yaml (StatefulSet 3 replicas, ConfigMap, headless service, per-pod services)
  • Add cluster env vars to k8s/tests/e2e-job.yaml (EPHPM_CLUSTER_URL, EPHPM_CLUSTER_NODE{0,1,2}_URL)
  • Update k8s/Tiltfile to deploy cluster resources with dependency on single-node
  • Add optional_env(), cluster_url(), cluster_node_urls(), poll_until() helpers to src/lib.rs
  • Add serde + serde_json deps to Cargo.toml

File: cluster_discovery.rs

  • All 3 nodes see full membership via /api/nodes
  • Each node reports a unique ID
  • /api/nodes response shape validation (JSON fields)
  • cluster_id matches config value
  • Gossip addresses are distinct across nodes
  • All nodes report alive state

2. Cluster KV Replication

  • Small value (< 512B) replicates across all nodes via gossip
  • Large value (> 512B) stays local to the node it was written on
  • Gossip replication converges within 5s
  • TTL expiry propagates to all nodes
  • Delete propagates to all nodes
  • Overwrite propagates new value
  • Concurrent writes to different nodes don’t conflict
  • PHP kv.php routes through clustered store

3. Cluster ETag Cache

  • ETag cached on originating node
  • ETag replicates to other nodes via gossip
  • ETag mismatch on remote node returns 200

4. Cluster Resilience (kubectl-gated)

  • Node failure detected by remaining nodes
  • KV data survives node loss
  • Rejoining node receives gossip state
  • Requests succeed during node failure

Medium Priority — Gaps in single-node coverage

5. PHP ETag Cache

  • First PHP request returns 200 + ETag header
  • Repeat request with If-None-Match returns 304
  • Mismatched ETag returns 200
  • Different query strings get different ETags
  • POST requests are not cached
  • No If-None-Match header returns 200

6. Sessions

  • Session persistence via Set-Cookie / Cookie round-trip
  • Session isolation between different session IDs
  • Session survives after PHP error
  • New session created without cookie
  • Invalid session ID handled gracefully

7. Timeouts (donetimeouts.rs)

  • PHP sleep.php?seconds=30 triggers 504 when server timeout is shorter
  • Server recovers and accepts new requests after timeout

8. Graceful Shutdown (kubectl-gated)

  • Server accepts requests before SIGTERM
  • In-flight request completes during shutdown
  • New connections refused after SIGTERM

9. CLI (kubectl-gated)

  • ephpm --version prints version string
  • ephpm --help prints usage
  • ephpm serve --help prints serve options
  • Invalid flag returns error
  • ephpm kv --help prints KV subcommand options

10. HTTP Edge Cases (partially done — http_edge.rs)

  • Percent-encoded path resolves correctly
  • Multiple query parameters preserved
  • HEAD on static file returns Content-Length with empty body
  • POST to static file returns 405
  • Content-Length matches actual body length
  • Duplicate headers handled
  • Very long query string (~4KB) accepted
  • Empty User-Agent accepted
  • Connection: close honored

11. PHP Extended (partially done — php_extended.rs)

  • Multiple Set-Cookie headers preserved
  • Empty PHP response returns 200 with empty body
  • Content-Type: application/json on JSON response
  • SERVER_SOFTWARE contains “ephpm”
  • REQUEST_METHOD correct for GET/POST/PUT/DELETE
  • Output after header() modification delivered correctly

12. Additional Proxy Headers

  • XFF trusted proxy sets REMOTE_ADDR
  • X-Forwarded-Proto HTTPS
  • X-Forwarded-Proto HTTP
  • Multiple proxies — rightmost untrusted used
  • No header preserves pod IP

Lower Priority

13. Configuration Edge Cases

  • EPHPM_SERVER__LISTEN overrides TOML [server] listen
  • EPHPM_PHP__INI_OVERRIDES JSON array parsed correctly
  • Invalid config returns clear error
  • Missing config uses defaults

14. Observability

  • Structured log output contains method, path, status, duration
  • Log level filtering works

15. Additional KV Tests (partially done — added to kv.rs)

  • Empty string values
  • Overwrite existing key
  • Large values (~10KB)
  • Special characters in keys/values
  • KV operations via CLI (ephpm kv get/set/del)

16. Additional Concurrency / Performance

  • 100 concurrent PHP requests all succeed
  • Mixed static + PHP concurrent requests
  • Sustained KV burst (50 concurrent ops)
  • Request isolation (unique IDs survive concurrent load)

Cluster E2E Infrastructure (To Build)

When cluster tests are implemented, the following resources are needed:

k8s/base/ephpm-cluster.yaml:

  • ConfigMap with cluster-enabled ephpm.toml (cluster_id, gossip bind, join DNS, hot_key_threshold=3)
  • StatefulSet (3 replicas, gossip port 7946 UDP)
  • Headless Service for gossip peer DNS discovery
  • Per-pod Services (ephpm-cluster-0, -1, -2) for targeting specific nodes
  • ClusterIP Service for load-balanced access

E2E job additions:

- name: EPHPM_CLUSTER_URL
  value: "http://ephpm-cluster:8080"
- name: EPHPM_CLUSTER_NODE0_URL
  value: "http://ephpm-cluster-0:8080"
- name: EPHPM_CLUSTER_NODE1_URL
  value: "http://ephpm-cluster-1:8080"
- name: EPHPM_CLUSTER_NODE2_URL
  value: "http://ephpm-cluster-2:8080"

Tiltfile additions:

k8s_yaml("base/ephpm-cluster.yaml")
k8s_resource("ephpm-cluster", resource_deps=["ephpm"], objects=[...])
k8s_resource("ephpm-e2e", resource_deps=["ephpm", "ephpm-cluster"])

Helper additions to src/lib.rs:

  • optional_env(name) -> Option<String>
  • cluster_url() -> Option<String>
  • cluster_node_urls() -> Option<[String; 3]>
  • poll_until(timeout, interval, check) -> bool

Cargo.toml additions:

reqwest = { version = "0.12", default-features = false, features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

PHP Fixtures Needed

These fixtures need to be created in tests/docroot/ for new tests:

FilePurposeNeeded by
empty.php<?php with no outputPHP Extended (#11)
json_response.phpContent-Type: application/json + json_encode()PHP Extended (#11)
multi_cookie.phpTwo setcookie() callsPHP Extended (#11)
query.phpEcho all $_GET as key=value\nHTTP Edge Cases (#10)
server_var.phpReturn single $_SERVER[var] via ?var=PHP Extended (#11)
session.phpsession_start() + session read/writeSessions (#6)
timeout_test.phpAlternative to sleep.php if neededTimeouts (#7)