diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c88a751..b6cf1933 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,6 +77,10 @@ jobs: - name: Run Axum adapter tests run: cargo test-axum + - name: Run HTML processor benchmarks (smoke) + # -- --test runs each benchmark as a regular test (no timing harness) so CI stays fast + run: cargo bench -p trusted-server-core --bench html_processor_bench -- --test + - name: Verify Fastly WASM release build env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 @@ -112,6 +116,30 @@ jobs: - name: Run Cloudflare adapter tests (native host) run: cargo test-cloudflare + test-parity: + name: cargo test (cross-adapter parity) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy + cache-shared-key: cargo-${{ runner.os }} + + - name: Run cross-adapter parity tests + run: cargo test --manifest-path crates/integration-tests/Cargo.toml --test parity + + - name: Clippy (parity test crate) + run: cargo clippy --manifest-path crates/integration-tests/Cargo.toml --all-targets -- -D warnings + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 18e79cf6..96bb20a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3558,6 +3558,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "base64", "edgezero-adapter-axum", "edgezero-core", "error-stack", @@ -3576,6 +3577,7 @@ name = "trusted-server-adapter-cloudflare" version = "0.1.0" dependencies = [ "async-trait", + "base64", "bytes", "edgezero-adapter-cloudflare", "edgezero-core", diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index e5631f64..028831c8 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -205,6 +205,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.9" @@ -213,10 +235,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit 0.8.4", "memchr", @@ -224,10 +249,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", - "tower", + "tokio", + "tower 0.5.3", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -246,6 +276,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -431,6 +462,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -440,6 +473,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -489,12 +528,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -932,6 +999,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -963,6 +1036,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "edgezero-adapter-axum" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "http", + "log", + "redb", + "reqwest 0.13.4", + "simple_logger", + "thiserror", + "tokio", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + [[package]] name = "edgezero-core" version = "0.1.0" @@ -1205,6 +1319,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1352,9 +1472,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1587,6 +1709,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1865,15 +1988,25 @@ dependencies = [ name = "integration-tests" version = "0.1.0" dependencies = [ + "axum", + "bytes", "derive_more 2.1.1", + "edgezero-adapter-axum", + "edgezero-core", "env_logger", "error-stack", + "http", + "http-body-util", "libc", "log", - "reqwest", + "reqwest 0.12.28", "scraper", "serde_json", "testcontainers", + "tokio", + "tower 0.4.13", + "trusted-server-adapter-axum", + "trusted-server-adapter-cloudflare", "trusted-server-core", "urlencoding", ] @@ -1929,6 +2062,65 @@ dependencies = [ "syn", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "jose-b64" version = "0.1.2" @@ -2067,6 +2259,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2098,6 +2296,12 @@ dependencies = [ "syn", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2282,6 +2486,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2788,6 +3001,62 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2868,6 +3137,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2954,6 +3232,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2961,7 +3241,44 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tower", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -3062,6 +3379,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -3098,15 +3416,44 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3290,6 +3637,17 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3323,6 +3681,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3413,6 +3782,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3429,6 +3808,34 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.61.2", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3684,7 +4091,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -3751,6 +4160,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3873,7 +4283,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -3890,6 +4300,21 @@ dependencies = [ "tonic", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -3921,7 +4346,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -3945,6 +4370,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3970,6 +4396,38 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trusted-server-adapter-axum" +version = "0.1.0" +dependencies = [ + "async-trait", + "edgezero-adapter-axum", + "edgezero-core", + "error-stack", + "futures", + "log", + "reqwest 0.12.28", + "simple_logger", + "tokio", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "edgezero-adapter-cloudflare", + "edgezero-core", + "error-stack", + "js-sys", + "log", + "trusted-server-core", + "trusted-server-js", + "worker", +] + [[package]] name = "trusted-server-core" version = "0.1.0" @@ -4350,6 +4808,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -4382,6 +4853,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.3" @@ -4686,6 +5175,64 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "worker" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 5ac378ca..7f5e9cd7 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,6 +9,11 @@ name = "integration" path = "tests/integration.rs" harness = true +[[test]] +name = "parity" +path = "tests/parity.rs" +harness = true + [dev-dependencies] trusted-server-core = { path = "../trusted-server-core" } testcontainers = { version = "0.25", features = ["blocking"] } @@ -21,3 +26,33 @@ derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" libc = "0.2" urlencoding = "2.1" +trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } +trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare" } +# This crate is excluded from the workspace, so the edgezero git rev below +# cannot come from [workspace.dependencies] and duplicates the pin in the +# root Cargo.toml. Any edgezero rev bump must be applied in BOTH places, or +# this crate compiles edgezero-core at a different rev than the adapters +# under test and fails with a type mismatch. +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", features = ["axum"] } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1" } +axum = "0.8.9" +tower = { version = "0.4", features = ["util"] } +tokio = { version = "1.52", features = ["rt-multi-thread", "macros"] } +http = "1" +http-body-util = "0.1" +bytes = "1" + +[lints.clippy] +unwrap_used = "deny" +expect_used = "allow" +panic = "deny" +module_name_repetitions = "allow" +must_use_candidate = "warn" +doc_markdown = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +needless_pass_by_value = "warn" +redundant_closure_for_method_calls = "warn" +print_stdout = "warn" +print_stderr = "warn" +dbg_macro = "warn" diff --git a/crates/integration-tests/tests/frameworks/scenarios.rs b/crates/integration-tests/tests/frameworks/scenarios.rs index cc1b64ce..a9f9ec8d 100644 --- a/crates/integration-tests/tests/frameworks/scenarios.rs +++ b/crates/integration-tests/tests/frameworks/scenarios.rs @@ -44,7 +44,7 @@ pub enum CustomScenario { /// Next.js: Form action URLs are rewritten from origin to proxy. NextJsFormAction, - /// WordPress: Admin pages (`/wp-admin/`) receive script injection. + /// `WordPress`: Admin pages (`/wp-admin/`) receive script injection. /// /// The trusted server currently injects into ALL HTML responses /// regardless of path. This test documents that behavior and guards diff --git a/crates/integration-tests/tests/frameworks/wordpress.rs b/crates/integration-tests/tests/frameworks/wordpress.rs index 00996ce9..ae3eaf28 100644 --- a/crates/integration-tests/tests/frameworks/wordpress.rs +++ b/crates/integration-tests/tests/frameworks/wordpress.rs @@ -4,10 +4,10 @@ use crate::common::runtime::TestResult; use testcontainers::core::{ContainerRequest, IntoContainerPort}; use testcontainers::{GenericImage, ImageExt as _}; -/// WordPress frontend framework for integration testing. +/// `WordPress` frontend framework for integration testing. /// /// Uses a pre-built Docker image (`test-wordpress:latest`) that serves -/// a minimal WordPress site with a test theme. The image must be built +/// a minimal `WordPress` site with a test theme. The image must be built /// before running tests: /// /// ```bash diff --git a/crates/integration-tests/tests/parity.rs b/crates/integration-tests/tests/parity.rs new file mode 100644 index 00000000..43d1dc9b --- /dev/null +++ b/crates/integration-tests/tests/parity.rs @@ -0,0 +1,418 @@ +//! Cross-adapter parity tests: Axum vs Cloudflare in-process. +//! +//! Sends identical requests to both adapters and asserts that: +//! - Response status codes match +//! - Critical headers (X-Geo-Info-Available, WWW-Authenticate on 401) match +//! +//! Fastly parity is verified via cargo test-fastly + Viceroy in CI. + +// Both adapters define `TrustedServerApp` — alias both to avoid name collision. +// axum::http re-exports from the `http` crate, so HeaderMap types are identical. +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; +use edgezero_adapter_axum::EdgeZeroAxumService; +use edgezero_core::http::request_builder; +use edgezero_core::router::RouterService; +use http::HeaderMap; +use tower::{Service as _, ServiceExt as _}; +use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; +use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; +use trusted_server_core::settings::Settings; + +/// Shared test settings for both adapters. +/// +/// The settings baked into the binaries contain placeholder secrets that +/// `get_settings()` rejects by design, so both routers are built through +/// their `routes_with_settings` testing seams from this known-good config. +/// The handler regex covers both the adapter-level `/admin/...` routes and +/// the `/_ts/admin/...` paths required by settings validation. +fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/(_ts/)?admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "parity-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse parity test settings") +} + +/// Build the Axum adapter router from the shared test settings. +fn axum_router() -> RouterService { + AxumApp::routes_with_settings(test_settings()) + .expect("should build Axum router from parity test settings") +} + +/// Build the Cloudflare adapter router from the shared test settings. +fn cf_router() -> RouterService { + CloudflareApp::routes_with_settings(test_settings()) + .expect("should build Cloudflare router from parity test settings") +} + +/// Send a GET request to the Axum adapter and return (status, headers). +async fn axum_get(uri: &str) -> (u16, HeaderMap) { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri(uri) + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + +/// Send a POST request to the Axum adapter and return (status, headers, body bytes). +async fn axum_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { + use http_body_util::BodyExt as _; + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(AxumBody::from(body.to_owned())) + .expect("should build POST request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let body_bytes = resp + .into_body() + .collect() + .await + .expect("should collect body") + .to_bytes(); + (status, headers, body_bytes) +} + +/// Convenience wrapper for tests that don't need body. +async fn axum_post_headers(uri: &str, body: &str) -> (u16, HeaderMap) { + let (s, h, _) = axum_post(uri, body).await; + (s, h) +} + +/// Send a GET request to the Cloudflare adapter and return (status, headers). +async fn cf_get(uri: &str) -> (u16, HeaderMap) { + let router = cf_router(); + let req = request_builder() + .method("GET") + .uri(uri) + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = router.oneshot(req).await.expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + +/// Send a POST request to the Cloudflare adapter and return (status, headers, body bytes). +async fn cf_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { + let router = cf_router(); + let req = request_builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(body.to_owned())) + .expect("should build POST request"); + let resp = router.oneshot(req).await.expect("should respond"); + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let body_bytes = resp.into_body().into_bytes(); + (status, headers, body_bytes) +} + +/// Convenience wrapper for tests that don't need body. +async fn cf_post_headers(uri: &str, body: &str) -> (u16, HeaderMap) { + let (s, h, _) = cf_post(uri, body).await; + (s, h) +} + +// --------------------------------------------------------------------------- +// Route parity: same route → same status on both adapters +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_route_status_parity() { + let (axum_status, _) = axum_get("/.well-known/trusted-server.json").await; + let (cf_status, _) = cf_get("/.well-known/trusted-server.json").await; + assert_eq!( + axum_status, cf_status, + "/.well-known/trusted-server.json must return same status: axum={axum_status} cf={cf_status}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_route_body_is_json_parity() { + // known divergence: without real signing-key configuration both adapters may + // return an error body. Assert that whichever body type each returns (JSON or + // not) is consistent: if the Cloudflare adapter returns valid JSON then the + // Axum adapter must also return valid JSON for the same route. + use http_body_util::BodyExt as _; + use serde_json::Value; + + let (axum_status, axum_body_bytes) = { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let body = resp + .into_body() + .collect() + .await + .expect("should collect body") + .to_bytes(); + (status, body) + }; + + let (cf_status, cf_body_bytes) = { + let router = cf_router(); + let req = request_builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = router.oneshot(req).await.expect("should respond"); + let status = resp.status().as_u16(); + let body = resp.into_body().into_bytes(); + (status, body) + }; + + // Both adapters must agree on whether the response is JSON. + let axum_json: Option = serde_json::from_slice(&axum_body_bytes).ok(); + let cf_json: Option = serde_json::from_slice(&cf_body_bytes).ok(); + assert_eq!( + axum_json.is_some(), + cf_json.is_some(), + "/.well-known/trusted-server.json body JSON-parsability must match across adapters \ + (axum_status={axum_status} cf_status={cf_status})" + ); + + // When both return JSON objects, top-level key sets must also match. + if let (Some(Value::Object(axum_obj)), Some(Value::Object(cf_obj))) = (&axum_json, &cf_json) { + let mut axum_keys: Vec<&str> = axum_obj.keys().map(String::as_str).collect(); + let mut cf_keys: Vec<&str> = cf_obj.keys().map(String::as_str).collect(); + axum_keys.sort_unstable(); + cf_keys.sort_unstable(); + assert_eq!( + axum_keys, cf_keys, + "/.well-known/trusted-server.json top-level JSON keys must match across adapters" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn verify_signature_route_parity() { + // known divergence: without real signing-key configuration the handler may + // return 5xx. The parity assertion is that both adapters agree on the status + // (routing and middleware are wired identically). + let (axum_status, _) = axum_post_headers("/verify-signature", "{}").await; + let (cf_status, _) = cf_post_headers("/verify-signature", "{}").await; + + assert_ne!(axum_status, 404, "Axum /verify-signature must be routed"); + assert_ne!( + cf_status, 404, + "Cloudflare /verify-signature must be routed" + ); + assert_eq!( + axum_status, cf_status, + "/verify-signature must return same status: axum={axum_status} cf={cf_status}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_unauthenticated_parity() { + // Both adapters must return 401 for unauthenticated admin requests. + // The authenticated-path divergence (Axum→501 no-KV, CF→4xx no-KV) + // is separate and not covered here. + let (axum_status, axum_headers) = axum_post_headers("/admin/keys/rotate", "{}").await; + let (cf_status, cf_headers) = cf_post_headers("/admin/keys/rotate", "{}").await; + + assert_eq!( + axum_status, 401, + "Axum must return 401 for unauthenticated admin route" + ); + assert_eq!( + cf_status, 401, + "Cloudflare must return 401 for unauthenticated admin route" + ); + assert_eq!( + axum_status, cf_status, + "both adapters must return the same status for unauthenticated admin route" + ); + + let axum_www_auth = axum_headers + .get("www-authenticate") + .expect("Axum 401 must include WWW-Authenticate") + .to_str() + .expect("should be valid UTF-8"); + let cf_www_auth = cf_headers + .get("www-authenticate") + .expect("Cloudflare 401 must include WWW-Authenticate") + .to_str() + .expect("should be valid UTF-8"); + assert_eq!( + axum_www_auth, cf_www_auth, + "WWW-Authenticate header value must match across adapters for /admin/keys/rotate" + ); + assert!( + axum_www_auth.starts_with("Basic"), + "WWW-Authenticate must use Basic scheme: {axum_www_auth:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_deactivate_unauthenticated_parity() { + // Mirror of admin_rotate_unauthenticated_parity for the deactivate endpoint. + let (axum_status, axum_headers) = axum_post_headers("/admin/keys/deactivate", "{}").await; + let (cf_status, cf_headers) = cf_post_headers("/admin/keys/deactivate", "{}").await; + + assert_eq!( + axum_status, 401, + "Axum must return 401 for unauthenticated admin/keys/deactivate" + ); + assert_eq!( + cf_status, 401, + "Cloudflare must return 401 for unauthenticated admin/keys/deactivate" + ); + assert_eq!( + axum_status, cf_status, + "both adapters must return the same status for unauthenticated admin/keys/deactivate" + ); + + let axum_www_auth = axum_headers + .get("www-authenticate") + .expect("Axum 401 on admin/keys/deactivate must include WWW-Authenticate") + .to_str() + .expect("should be valid UTF-8"); + let cf_www_auth = cf_headers + .get("www-authenticate") + .expect("Cloudflare 401 on admin/keys/deactivate must include WWW-Authenticate") + .to_str() + .expect("should be valid UTF-8"); + assert_eq!( + axum_www_auth, cf_www_auth, + "WWW-Authenticate header value must match across adapters for /admin/keys/deactivate" + ); + assert!( + axum_www_auth.starts_with("Basic"), + "WWW-Authenticate must use Basic scheme: {axum_www_auth:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn geo_header_parity_on_all_responses() { + let routes_to_check: &[(&str, &str, &str)] = &[ + ("GET", "/.well-known/trusted-server.json", ""), + ("POST", "/auction", r#"{"adUnits":[]}"#), + ("POST", "/verify-signature", "{}"), + ]; + + for (method, path, body) in routes_to_check { + let (axum_status, axum_headers) = if *method == "GET" { + axum_get(path).await + } else { + axum_post_headers(path, body).await + }; + let (cf_status, cf_headers) = if *method == "GET" { + cf_get(path).await + } else { + cf_post_headers(path, body).await + }; + + assert!( + axum_headers.contains_key("x-geo-info-available"), + "Axum: {method} {path} (status={axum_status}) must have X-Geo-Info-Available" + ); + assert!( + cf_headers.contains_key("x-geo-info-available"), + "Cloudflare: {method} {path} (status={cf_status}) must have X-Geo-Info-Available" + ); + let axum_geo = axum_headers + .get("x-geo-info-available") + .expect("should have x-geo-info-available after assert") + .to_str() + .expect("should be valid UTF-8"); + let cf_geo = cf_headers + .get("x-geo-info-available") + .expect("should have x-geo-info-available after assert") + .to_str() + .expect("should be valid UTF-8"); + assert_eq!( + axum_geo, cf_geo, + "{method} {path}: X-Geo-Info-Available value must match across adapters \ + (axum={axum_geo:?} cf={cf_geo:?})" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auction_not_challenged_by_auth_parity() { + let (axum_status, _) = axum_post_headers("/auction", r#"{"adUnits":[]}"#).await; + let (cf_status, _) = cf_post_headers("/auction", r#"{"adUnits":[]}"#).await; + + assert_ne!(axum_status, 401, "Axum /auction must not 401"); + assert_ne!(cf_status, 401, "Cloudflare /auction must not 401"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn publisher_proxy_fallback_parity() { + // Cookie (Set-Cookie) parity for the publisher proxy requires a live origin. + // Without an origin, both adapters return an error (4xx or 5xx). The parity + // assertion is that Set-Cookie presence matches across adapters regardless of + // whether the proxy succeeds. + let (axum_status, axum_headers) = axum_get("/").await; + let (cf_status, cf_headers) = cf_get("/").await; + + // Both adapters must agree: either both proxy to the origin or both fail. + assert_eq!( + axum_status >= 500, + cf_status >= 500, + "publisher fallback 5xx behaviour must match: axum={axum_status} cf={cf_status}" + ); + + let axum_has_cookie = axum_headers.contains_key("set-cookie"); + let cf_has_cookie = cf_headers.contains_key("set-cookie"); + assert_eq!( + axum_has_cookie, cf_has_cookie, + "Set-Cookie presence must match: axum={axum_has_cookie} cf={cf_has_cookie}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unknown_route_returns_same_status_parity() { + let (axum_status, _) = axum_get("/this-route-does-not-exist-abc123").await; + let (cf_status, _) = cf_get("/this-route-does-not-exist-abc123").await; + + assert_eq!( + axum_status, cf_status, + "unknown routes must return same status: axum={axum_status} cf={cf_status}" + ); +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 8cbed2d6..1bdf7dbc 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,6 +29,7 @@ trusted-server-core = { path = "../trusted-server-core" } [dev-dependencies] axum = "0.8" +base64 = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower = { version = "0.4", features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 17a805b5..8a1ac151 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -10,14 +10,16 @@ use edgezero_adapter_axum::EdgeZeroAxumService; use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp; -fn make_service() -> EdgeZeroAxumService { - // Drive the router with explicit test settings: the settings baked into - // the binary contain placeholder secrets that `get_settings()` rejects - // by design, which would turn every route into a startup error page. +/// Build the full application router from explicit test settings. +/// +/// The settings baked into the binary contain placeholder secrets that +/// `get_settings()` rejects by design, which would turn every route into a +/// startup error page (and its route table into the fallback-only set). +fn test_router() -> edgezero_core::router::RouterService { let settings = trusted_server_core::settings::Settings::from_toml( r#" [[handlers]] - path = "^/_ts/admin" + path = "^/(_ts/)?admin" username = "admin" password = "admin-pass" @@ -33,9 +35,53 @@ fn make_service() -> EdgeZeroAxumService { ) .expect("should parse route test settings"); - let router = TrustedServerApp::routes_with_settings(settings) - .expect("should build router from test settings"); - EdgeZeroAxumService::new(router) + TrustedServerApp::routes_with_settings(settings) + .expect("should build router from test settings") +} + +fn make_service() -> EdgeZeroAxumService { + EdgeZeroAxumService::new(test_router()) +} + +fn registered_routes() -> Vec<(String, String)> { + test_router() + .routes() + .into_iter() + .map(|r| (r.method().to_string(), r.path().to_string())) + .collect() +} + +fn assert_route_registered(method: &str, path: &str) { + let routes = registered_routes(); + assert!( + routes.iter().any(|(m, p)| m == method && p == path), + "{method} {path} must be explicitly registered; registered routes: {routes:?}" + ); +} + +/// Verify that every expected explicit route is registered in the route table. +/// +/// Uses [`RouterService::routes()`] for introspection rather than checking +/// response status codes — wildcards (`/{*rest}`) can return non-404 even when +/// an explicit registration is missing, making status-based checks false positives. +#[test] +fn all_explicit_routes_are_registered() { + let expected: &[(&str, &str)] = &[ + ("GET", "/.well-known/trusted-server.json"), + ("POST", "/verify-signature"), + ("POST", "/admin/keys/rotate"), + ("POST", "/admin/keys/deactivate"), + ("POST", "/auction"), + ("GET", "/first-party/proxy"), + ("GET", "/first-party/click"), + ("GET", "/first-party/sign"), + ("POST", "/first-party/sign"), + ("POST", "/first-party/proxy-rebuild"), + ]; + + for (method, path) in expected { + assert_route_registered(method, path); + } } // --------------------------------------------------------------------------- @@ -238,9 +284,140 @@ async fn finalize_middleware_sets_geo_unavailable_header() { } // --------------------------------------------------------------------------- -// Basic-auth gate test +// Basic-auth parity tests // --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must return 401 without credentials" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_includes_www_authenticate_header() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "should be 401 before checking header" + ); + assert!( + resp.headers().contains_key("www-authenticate"), + "401 response must include WWW-Authenticate header" + ); + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("should have www-authenticate header") + .to_str() + .expect("should be valid UTF-8"); + assert!( + www_auth.starts_with("Basic realm="), + "WWW-Authenticate must be Basic scheme, got: {www_auth}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_with_wrong_credentials_returns_401() { + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must reject wrong credentials with 401" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_endpoint_does_not_require_auth() { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 401, + "/.well-known/trusted-server.json must not require auth" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auction_endpoint_does_not_require_auth() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"adUnits":[]}"#)) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 401, + "/auction must not apply admin basic-auth gate" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admin_route_returns_non_404_non_5xx() { let mut svc = make_service(); @@ -266,3 +443,171 @@ async fn admin_route_returns_non_404_non_5xx() { // routes; only an unhandled 500 indicates a panic or missing handler. assert_ne!(status, 500, "admin route must not panic: got {status}"); } + +// --------------------------------------------------------------------------- +// Admin key route full path coverage +// --------------------------------------------------------------------------- + +// Exercises the auth-fail path with a realistic key body (complements the +// generic `admin_route_without_credentials_returns_401` above). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_auth_fail_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/rotate without credentials must return 401" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_deactivate_key_auth_fail_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/deactivate without credentials must return 401" + ); +} + +// --------------------------------------------------------------------------- +// First-party route smoke tests +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_proxy_is_routed() { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/first-party/proxy") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 404, + "/first-party/proxy must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_click_is_routed() { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/first-party/click") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 404, + "/first-party/click must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_sign_get_is_routed() { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/first-party/sign") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 404, + "GET /first-party/sign must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_sign_post_is_routed() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/first-party/sign") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 404, + "POST /first-party/sign must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_party_proxy_rebuild_is_routed() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/first-party/proxy-rebuild") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 404, + "/first-party/proxy-rebuild must be routed" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 0cc3a0c4..67848f26 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -34,5 +34,6 @@ js-sys = { workspace = true } worker = { version = "0.7", default-features = false, features = ["http"] } [dev-dependencies] +base64 = { workspace = true } edgezero-core = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 567e14da..f29209be 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -50,6 +50,18 @@ pub struct AppState { /// registry fail to initialise. fn build_state() -> Result, Report> { let settings = get_settings()?; + build_state_with_settings(settings) +} + +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the auction orchestrator or the integration +/// registry fail to initialise. +fn build_state_with_settings( + settings: Settings, +) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -191,6 +203,33 @@ impl Hooks for TrustedServerApp { } }; + build_router(&state) + } +} + +impl TrustedServerApp { + /// Build the full application router from explicit settings. + /// + /// Testing seam: cross-adapter parity tests use this to drive the router + /// with known-good settings instead of the baked `get_settings()` result, + /// whose embedded placeholder secrets fail validation by design. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or the integration + /// registry fail to initialise. + pub fn routes_with_settings( + settings: Settings, + ) -> Result> { + let state = build_state_with_settings(settings)?; + Ok(build_router(&state)) + } +} + +fn build_router(state: &Arc) -> RouterService { + { + let state = Arc::clone(state); + // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. async fn dispatch( state: Arc, diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 831e6bd4..848c446a 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -6,7 +6,57 @@ use edgezero_core::app::Hooks as _; use edgezero_core::http::request_builder; +use edgezero_core::router::RouterService; use trusted_server_adapter_cloudflare::app::TrustedServerApp; +use trusted_server_core::settings::Settings; + +/// Build the full application router from explicit test settings. +/// +/// The settings baked into the binary contain placeholder secrets that +/// `get_settings()` rejects by design, which would turn every route into a +/// startup error page (and its route table into the fallback-only set). +/// The handler regex covers both the adapter-level `/admin/...` routes and +/// the `/_ts/admin/...` paths required by settings validation. +fn test_router() -> RouterService { + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/(_ts/)?admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse route test settings"); + + TrustedServerApp::routes_with_settings(settings) + .expect("should build router from test settings") +} + +/// Return the set of (METHOD, path) pairs explicitly registered on the router. +fn registered_routes() -> Vec<(String, String)> { + test_router() + .routes() + .into_iter() + .map(|r| (r.method().to_string(), r.path().to_string())) + .collect() +} + +fn assert_route_registered(method: &str, path: &str) { + let routes = registered_routes(); + assert!( + routes.iter().any(|(m, p)| m == method && p == path), + "{method} {path} must be explicitly registered; registered routes: {routes:?}" + ); +} #[test] fn routes_build_without_panic() { @@ -20,11 +70,11 @@ fn routes_build_without_panic() { // AuthMiddleware are wired so they cannot be removed silently. // --------------------------------------------------------------------------- -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn finalize_middleware_injects_geo_header() { // The X-Geo-Info-Available header is injected by FinalizeResponseMiddleware. // Its absence on any response means the middleware was not wired. - let router = TrustedServerApp::routes(); + let router = test_router(); let req = request_builder() .method("GET") @@ -40,35 +90,218 @@ async fn finalize_middleware_injects_geo_header() { ); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auth_middleware_runs_in_chain_for_protected_routes() { - // Verifies that AuthMiddleware is wired into the middleware chain for auction - // requests. Without it, FinalizeResponseMiddleware would still run but auth - // challenges would be skipped silently. - // - // CI settings may not have basic_auth configured, so this test does not - // assert 401 — it asserts that both middleware layers ran (X-Geo-Info-Available - // present) and that the route is actually reached (status != 404). - let router = TrustedServerApp::routes(); + // Verifies that AuthMiddleware is wired by asserting the 401 + WWW-Authenticate + // challenge on a protected route (/admin/keys/rotate). Only AuthMiddleware + // short-circuits with this response — FinalizeResponseMiddleware alone would not. + let router = test_router(); let req = request_builder() .method("POST") - .uri("/auction") + .uri("/admin/keys/rotate") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); let resp = router.oneshot(req).await; - // Regardless of auth config the response must carry the finalize header, - // confirming both middleware layers ran (auth short-circuits through finalize). + assert_eq!( + resp.status().as_u16(), + 401, + "AuthMiddleware must short-circuit with 401 on protected routes without credentials" + ); assert!( - resp.headers().contains_key("x-geo-info-available"), - "middleware chain must inject X-Geo-Info-Available even on auth-rejected responses" + resp.headers().contains_key("www-authenticate"), + "AuthMiddleware must include WWW-Authenticate on 401 responses" + ); +} + +// --------------------------------------------------------------------------- +// Route smoke tests — verify all adapter routes are registered and do not 5xx +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_is_routed_not_5xx() { + let router = test_router(); + let req = request_builder() + .method("GET") + .uri("/static/tsjs=0000000000000000") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + // The tsjs route is matched by the /{*rest} catch-all. The handler returns 404 + // for an unknown hash — that is correct application behaviour, not a routing miss. + assert!(status < 500, "tsjs route must not 5xx: got {status}"); +} + +/// Verify that every expected explicit route is registered in the route table. +/// +/// Uses [`RouterService::routes()`] for introspection rather than checking +/// response status codes — wildcards (`/{*rest}`) can return non-404 even when +/// an explicit registration is missing, making status-based checks false positives. +#[test] +fn all_explicit_routes_are_registered() { + let expected: &[(&str, &str)] = &[ + ("GET", "/.well-known/trusted-server.json"), + ("POST", "/verify-signature"), + ("POST", "/admin/keys/rotate"), + ("POST", "/admin/keys/deactivate"), + ("POST", "/auction"), + ("GET", "/first-party/proxy"), + ("GET", "/first-party/click"), + ("GET", "/first-party/sign"), + ("POST", "/first-party/sign"), + ("POST", "/first-party/proxy-rebuild"), + ]; + + for (method, path) in expected { + assert_route_registered(method, path); + } +} + +// --------------------------------------------------------------------------- +// Basic-auth parity tests +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_returns_401() { + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must return 401 without credentials" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_includes_www_authenticate_header() { + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "should be 401 before checking header" + ); + assert!( + resp.headers().contains_key("www-authenticate"), + "401 response must include WWW-Authenticate header" + ); + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("should have www-authenticate header") + .to_str() + .expect("should be valid UTF-8"); + assert!( + www_auth.starts_with("Basic realm="), + "WWW-Authenticate must be Basic scheme, got: {www_auth}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_with_wrong_credentials_returns_401() { + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must reject wrong credentials with 401" ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_endpoint_does_not_require_auth() { + let router = test_router(); + let req = request_builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; assert_ne!( resp.status().as_u16(), - 404, - "auction endpoint must be routed" + 401, + "/.well-known/trusted-server.json must not require auth" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auction_endpoint_does_not_require_auth() { + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"adUnits":[]}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!( + resp.status().as_u16(), + 401, + "/auction must not apply admin basic-auth gate" + ); +} + +// --------------------------------------------------------------------------- +// Admin key route full path coverage +// --------------------------------------------------------------------------- + +// Exercises the auth-fail path with a realistic key body (complements the +// generic `admin_route_without_credentials_returns_401` above). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_auth_fail_returns_401() { + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/rotate without credentials must return 401" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_deactivate_key_auth_fail_returns_401() { + let router = test_router(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/deactivate without credentials must return 401" ); } diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index bd13ee8b..41c6a0df 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -78,3 +78,7 @@ temp-env = { workspace = true } [[bench]] name = "consent_decode" harness = false + +[[bench]] +name = "html_processor_bench" +harness = false diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs new file mode 100644 index 00000000..300b8606 --- /dev/null +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -0,0 +1,63 @@ +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use trusted_server_core::html_processor::{create_html_processor, HtmlProcessorConfig}; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::streaming_processor::StreamProcessor as _; + +fn make_config() -> HtmlProcessorConfig { + HtmlProcessorConfig { + origin_host: "origin.bench.com".to_string(), + request_host: "proxy.bench.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::default(), + } +} + +fn make_html(size_kb: usize) -> Vec { + let link_block = r#"Link + + +"#; + + let body_content = link_block.repeat((size_kb * 1024) / link_block.len() + 1); + + format!( + r#" + + + +Benchmark Page + + +{body_content} + +"# + ) + .into_bytes() +} + +fn bench_html_processor(c: &mut Criterion) { + let mut group = c.benchmark_group("html_processor"); + + for size_kb in [10usize, 100] { + let html = make_html(size_kb); + + group.bench_with_input( + BenchmarkId::new("process_chunk", format!("{size_kb}kb")), + &html, + |b, html| { + b.iter(|| { + let config = make_config(); + let mut processor = create_html_processor(config); + processor + .process_chunk(black_box(html.as_slice()), true) + .expect("should process HTML") + }); + }, + ); + } + + group.finish(); +} + +criterion_group!(benches, bench_html_processor); +criterion_main!(benches); diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6f56e85a..bdc8a603 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -46,6 +46,10 @@ fn main() { // Only write when content changes to avoid unnecessary recompilation. let dest_path = Path::new(TRUSTED_SERVER_OUTPUT_CONFIG_PATH); + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent) + .unwrap_or_else(|_| panic!("Failed to create directory for {dest_path:?}")); + } let current = fs::read_to_string(dest_path).unwrap_or_default(); if current != merged_toml { fs::write(dest_path, merged_toml) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 18765f0c..05d1040e 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -520,6 +520,11 @@ mod tests { use std::io::Cursor; use std::sync::Arc; + // 1.1× accounts for the injected tsjs script tag plus URL attribute rewrites. + // Observed growth on the test fixture is ≤1.01×; 1.1× gives headroom while + // catching real regressions (e.g., double-injection or buffer leak). + const MAX_GROWTH_FACTOR: f64 = 1.1; + fn create_test_config() -> HtmlProcessorConfig { HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), @@ -1183,4 +1188,117 @@ mod tests { "should contain post-processor mutation" ); } + + #[test] + fn golden_script_tag_injected_at_head_start() { + // The trusted-server script tag must be the FIRST child of . + // Any drift in injection position breaks the page initialization order. + let html = r#" + +Test +

Hello

+"#; + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + let head_pos = output_str.find("").expect("should contain "); + let script_pos = output_str + .find(" head_pos, + "script tag must appear after opening: head_pos={head_pos}, script_pos={script_pos}" + ); + + // No other elements between and the script tag + let between = &output_str[head_pos + "".len()..script_pos]; + let trimmed = between.trim(); + assert!( + trimmed.is_empty(), + "script tag must be first child of , found content before it: {trimmed:?}" + ); + } + + #[test] + fn golden_url_rewriting_replaces_origin_in_href() { + // href attributes pointing at origin domain must be rewritten to proxy host. + let origin = "https://origin.test-publisher.com"; + let html = format!( + r#" + Link + + "# + ); + + let request_host = "proxy.test-publisher.com"; + let config = HtmlProcessorConfig { + origin_host: "origin.test-publisher.com".to_string(), + request_host: request_host.to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::default(), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + assert!( + !output_str.contains("origin.test-publisher.com"), + "origin host must not appear in rewritten HTML" + ); + assert!( + output_str.contains(request_host), + "proxy host must appear in rewritten HTML" + ); + } + + #[test] + fn golden_integration_script_is_not_double_injected() { + // Integration scripts from the registry must appear exactly once. + let html = r#" +

Content

"#; + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + let script_count = output_str.matches("/static/tsjs=").count(); + assert_eq!( + script_count, 1, + "script tag must appear exactly once, found {script_count} occurrences" + ); + } + + #[test] + fn response_size_does_not_grow_disproportionately() { + // Processing must not expand HTML by more than 1.1× (accounts for the + // injected script tag + URL rewrites). Disproportionate growth indicates + // a bug (e.g., double-processing, buffer leak). + let html = include_str!("html_processor.test.html"); + let input_size = html.len(); + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + + let output_size = output.len(); + let growth_factor = output_size as f64 / input_size as f64; + + assert!( + growth_factor < MAX_GROWTH_FACTOR, + "processed HTML must not grow by more than {MAX_GROWTH_FACTOR}×: input={input_size}B output={output_size}B factor={growth_factor:.2}" + ); + } } diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index 45a40232..a571885b 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -269,3 +269,44 @@ pub trait PlatformHttpClient: Send + Sync { self.select(vec![pending]).await?.ready } } + +#[cfg(test)] +mod tests { + use super::*; + + // --------------------------------------------------------------------------- + // Error-correlation interim scope (before EdgeZero #213) + // --------------------------------------------------------------------------- + + #[test] + fn platform_response_default_has_no_backend_name() { + // On Axum/Cloudflare noop clients return PlatformResponse::new(response) + // with no backend_name. Core logic must not panic when backend_name is None. + let response = edgezero_core::http::response_builder() + .status(200) + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + let resp = PlatformResponse::new(response); + // PlatformResponse has a public field, not a method. + // PlatformPendingRequest has backend_name() method; PlatformResponse does not. + assert_eq!( + resp.backend_name, None, + "PlatformResponse without backend_name must have None field" + ); + } + + #[test] + fn platform_response_with_backend_name_is_some() { + // On Fastly, responses carry backend_name for error correlation. + let response = edgezero_core::http::response_builder() + .status(200) + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + let resp = PlatformResponse::new(response).with_backend_name("prebid-backend"); + assert_eq!( + resp.backend_name.as_deref(), + Some("prebid-backend"), + "with_backend_name must set backend_name field" + ); + } +} diff --git a/docs/superpowers/plans/2026-05-20-pr18-phase5-verification.md b/docs/superpowers/plans/2026-05-20-pr18-phase5-verification.md new file mode 100644 index 00000000..d7cc62a3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-pr18-phase5-verification.md @@ -0,0 +1,1553 @@ +# PR-18 Phase 5: Cross-Adapter Verification Suite + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the Phase 5 verification gate suite — route parity, cross-adapter behavior, auth parity, auction error-correlation, HTML golden tests, and performance benchmarks — proving all three adapters (Fastly, Axum, Cloudflare) are behaviorally equivalent before production cutover. + +**Architecture:** Tests live across three layers: (1) in-process unit tests per adapter's own `tests/routes.rs`, (2) a new `parity` test binary in `crates/integration-tests` that drives Axum and Cloudflare adapters with identical requests and asserts matching status/headers, and (3) Criterion benchmarks for HTML processor throughput. Fastly parity is verified via the existing `cargo test-fastly` + Viceroy matrix. + +**Tech Stack:** Rust 2024, `tokio`, `tower`, `http` crate, `edgezero_core`, `edgezero_adapter_axum`, `edgezero_adapter_cloudflare`, `criterion 0.5` + +--- + +## File Map + +| Action | Path | Responsibility | +| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| Modify | `crates/trusted-server-adapter-cloudflare/tests/routes.rs` | Route smoke tests for all 10+ routes + basic-auth parity + admin key full path | +| Modify | `crates/trusted-server-adapter-axum/tests/routes.rs` | Basic-auth parity + admin key full path coverage | +| Modify | `crates/integration-tests/Cargo.toml` | Add `[[test]]` for parity binary + adapter deps | +| Create | `crates/integration-tests/tests/parity.rs` | Cross-adapter in-process parity (Axum vs Cloudflare) | +| Modify | `crates/trusted-server-core/src/auction/orchestrator.rs` | Auction async fan-out + error-correlation unit tests | +| Modify | `crates/trusted-server-core/src/html_processor.rs` | Golden output snapshot assertions | +| Create | `crates/trusted-server-core/benches/html_processor_bench.rs` | Criterion p95 latency + throughput benchmark | +| Modify | `crates/trusted-server-core/Cargo.toml` | Already has criterion; verify bench target exists | +| Modify | `.github/workflows/test.yml` | Add benchmark regression gate | + +--- + +## Task 1: Cloudflare Route Completeness + +Cloudflare `tests/routes.rs` has only 2 tests today (middleware regression + auth chain). All 10 routes from the Fastly `route_request` match list must be smoke-tested. + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` + +- [ ] **Step 1: Write failing tests for missing routes** + +Add after the existing 2 tests in `crates/trusted-server-adapter-cloudflare/tests/routes.rs`: + +```rust +// Routes currently missing from Cloudflare route smoke tests: +// /static/tsjs=*, /verify-signature, /admin/keys/rotate, +// /admin/keys/deactivate, /auction, /first-party/proxy, +// /first-party/click, /first-party/sign (GET+POST), /first-party/proxy-rebuild + +#[tokio::test] +async fn tsjs_route_is_routed_not_5xx() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("GET") + .uri("/static/tsjs=0000000000000000") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert!(status < 500, "tsjs route must not 5xx: got {status}"); + assert_ne!(status, 404, "tsjs route must be registered"); +} + +#[tokio::test] +async fn verify_signature_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/verify-signature") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!(resp.status().as_u16(), 404, "/verify-signature must be routed"); +} + +#[tokio::test] +async fn admin_rotate_key_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!(resp.status().as_u16(), 404, "/admin/keys/rotate must be routed"); +} + +#[tokio::test] +async fn admin_deactivate_key_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!(resp.status().as_u16(), 404, "/admin/keys/deactivate must be routed"); +} + +#[tokio::test] +async fn auction_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"adUnits":[]}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); +} + +#[tokio::test] +async fn first_party_proxy_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("GET") + .uri("/first-party/proxy") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert_ne!(status, 404, "/first-party/proxy must be routed"); + assert!(status < 500, "/first-party/proxy must not 5xx: got {status}"); +} + +#[tokio::test] +async fn first_party_click_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("GET") + .uri("/first-party/click") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert_ne!(status, 404, "/first-party/click must be routed"); + assert!(status < 500, "/first-party/click must not 5xx: got {status}"); +} + +#[tokio::test] +async fn first_party_sign_get_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("GET") + .uri("/first-party/sign") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert_ne!(status, 404, "GET /first-party/sign must be routed"); + assert!(status < 500, "GET /first-party/sign must not 5xx: got {status}"); +} + +#[tokio::test] +async fn first_party_sign_post_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/first-party/sign") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert_ne!(status, 404, "POST /first-party/sign must be routed"); + assert!(status < 500, "POST /first-party/sign must not 5xx: got {status}"); +} + +#[tokio::test] +async fn first_party_proxy_rebuild_is_routed() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/first-party/proxy-rebuild") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + assert_ne!(status, 404, "/first-party/proxy-rebuild must be routed"); + assert!(status < 500, "/first-party/proxy-rebuild must not 5xx: got {status}"); +} +``` + +- [ ] **Step 2: Run tests and verify they compile + fail correctly** + +```bash +cargo test-cloudflare 2>&1 | tail -20 +``` + +Expected: all new tests compile; some may fail if routes are missing. + +- [ ] **Step 3: Fix any missing route wiring** + +If a test reports 404, check `crates/trusted-server-adapter-cloudflare/src/app.rs` route registration around line 354-364 and add the missing `.method("/path", handler)` entry. + +- [ ] **Step 4: Run tests and verify they pass** + +```bash +cargo test-cloudflare 2>&1 | tail -20 +``` + +Expected: all route tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/tests/routes.rs +git commit -m "Add route smoke tests for all Cloudflare adapter routes" +``` + +--- + +## Task 2: Basic-Auth Parity Tests + +Both adapters must: (a) return 401 on protected routes without credentials, (b) include `WWW-Authenticate: Basic realm="..."` header in 401 responses, (c) not challenge on unprotected routes. + +**Files:** + +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` + +- [ ] **Step 1: Write failing auth tests for Cloudflare adapter** + +Add to `crates/trusted-server-adapter-cloudflare/tests/routes.rs`: + +```rust +// --------------------------------------------------------------------------- +// Basic-auth parity tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_route_without_credentials_returns_401() { + // Protected route (/admin/*) must challenge unauthenticated requests. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must return 401 without credentials" + ); +} + +#[tokio::test] +async fn admin_route_without_credentials_includes_www_authenticate_header() { + // 401 response must include WWW-Authenticate so clients know auth scheme. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "should be 401 before checking header" + ); + assert!( + resp.headers().contains_key("www-authenticate"), + "401 response must include WWW-Authenticate header" + ); + let www_auth = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + www_auth.starts_with("Basic realm="), + "WWW-Authenticate must be Basic scheme, got: {www_auth}" + ); +} + +#[tokio::test] +async fn admin_route_with_wrong_credentials_returns_401() { + // Wrong credentials must not grant access. + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must reject wrong credentials with 401" + ); +} + +#[tokio::test] +async fn discovery_endpoint_does_not_require_auth() { + // /.well-known/trusted-server.json is publicly accessible — no auth gate. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!( + resp.status().as_u16(), + 401, + "/.well-known/trusted-server.json must not require auth" + ); +} + +#[tokio::test] +async fn auction_endpoint_does_not_require_auth() { + // /auction is a consumer-facing endpoint — must not apply basic-auth gate. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"adUnits":[]}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_ne!( + resp.status().as_u16(), + 401, + "/auction must not apply admin basic-auth gate" + ); +} +``` + +- [ ] **Step 2: Run Cloudflare auth tests** + +```bash +cargo test-cloudflare 2>&1 | grep -E "FAILED|PASSED|test result" +``` + +Expected: new auth tests pass. If 401 is not returned, the auth middleware may not be configured in test settings — check `AppState::build_state()` fallback behavior. + +- [ ] **Step 3: Write same auth tests for Axum adapter** + +Add to `crates/trusted-server-adapter-axum/tests/routes.rs` (same tests, adapted for Axum Service interface): + +```rust +// --------------------------------------------------------------------------- +// Basic-auth parity tests +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must return 401 without credentials" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_without_credentials_includes_www_authenticate_header() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "should be 401 before checking header" + ); + assert!( + resp.headers().contains_key("www-authenticate"), + "401 response must include WWW-Authenticate header" + ); + let www_auth = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + www_auth.starts_with("Basic realm="), + "WWW-Authenticate must be Basic scheme, got: {www_auth}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_with_wrong_credentials_returns_401() { + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(AxumBody::from("{}")) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin route must reject wrong credentials with 401" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_endpoint_does_not_require_auth() { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 401, + "/.well-known/trusted-server.json must not require auth" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auction_endpoint_does_not_require_auth() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"adUnits":[]}"#)) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_ne!( + resp.status().as_u16(), + 401, + "/auction must not apply admin basic-auth gate" + ); +} +``` + +Note: `base64 = "0.22"` is in `[workspace.dependencies]`. Use `{ workspace = true }` for adapters. + +- [ ] **Step 4: Add `base64` dev-dependency to both adapter Cargo.toml files** + +In `crates/trusted-server-adapter-axum/Cargo.toml` `[dev-dependencies]`: + +```toml +base64 = { workspace = true } +``` + +In `crates/trusted-server-adapter-cloudflare/Cargo.toml` `[dev-dependencies]`: + +```toml +base64 = { workspace = true } +``` + +- [ ] **Step 5: Run both adapter auth tests** + +```bash +cargo test-axum 2>&1 | grep -E "FAILED|PASSED|test result" +cargo test-cloudflare 2>&1 | grep -E "FAILED|PASSED|test result" +``` + +Expected: all auth parity tests pass on both adapters. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/tests/routes.rs \ + crates/trusted-server-adapter-axum/Cargo.toml \ + crates/trusted-server-adapter-cloudflare/Cargo.toml +git commit -m "Add basic-auth parity tests to Axum and Cloudflare adapters" +``` + +--- + +## Task 3: Admin Key Route Full Path Coverage + +Covers auth-fail, validation-fail, and storage-fail paths on both Axum and Cloudflare. Success path differs by adapter (Axum returns 501; Cloudflare returns 200 or storage error). + +**Files:** + +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` + +- [ ] **Step 1: Add admin key path tests to Cloudflare adapter** + +Add to `crates/trusted-server-adapter-cloudflare/tests/routes.rs`: + +```rust +// --------------------------------------------------------------------------- +// Admin key route full path coverage +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_rotate_key_auth_fail_returns_401() { + // Auth-fail path: missing credentials → 401 (tested in basic-auth section, + // this test documents the specific admin key route behavior explicitly). + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/rotate without credentials must return 401" + ); +} + +#[tokio::test] +async fn admin_deactivate_key_auth_fail_returns_401() { + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/deactivate without credentials must return 401" + ); +} + +#[tokio::test] +async fn admin_rotate_key_validation_fail_returns_non_5xx() { + // Validation-fail path: authenticated but malformed body must not 5xx. + // CI settings may not have basic_auth configured; if auth passes through, + // an empty/malformed body should produce 400/422, not 500. + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:admin-pass"); + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + // Validation-fail path must be a 4xx client error — not 2xx (passed) or 5xx (crashed). + assert!( + (400..500).contains(&status), + "admin/keys/rotate with malformed body must return 4xx: got {status}" + ); +} + +#[tokio::test] +async fn admin_rotate_key_storage_fail_does_not_panic() { + // Storage-fail path: handler is reached, store operation returns error. + // In CI the store is either absent (error) or a noop. Either way must not + // panic (no 500 with a backtrace). + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:admin-pass"); + let router = TrustedServerApp::routes(); + let req = request_builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key-id"}"#)) + .expect("should build request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + // Storage-fail path: handler reached, store returns error. + // Must produce a proper HTTP error (4xx or 5xx), NOT a routing miss (404) + // and NOT an unrecovered panic (which would surface as 500 on some runtimes). + assert_ne!(status, 404, "admin/keys/rotate must not 404 when authenticated"); + assert!( + status >= 400, + "admin/keys/rotate storage-fail must return error status: got {status}" + ); +} +``` + +- [ ] **Step 2: Add admin key path tests to Axum adapter** + +Add to `crates/trusted-server-adapter-axum/tests/routes.rs` (same logic, Axum returns 501 for authenticated requests since store writes are unsupported): + +```rust +// --------------------------------------------------------------------------- +// Admin key route full path coverage +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_auth_fail_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/rotate without credentials must return 401" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_authenticated_returns_not_5xx() { + // Axum dev server returns 501 Not Implemented for admin key writes. + // Auth runs first — if configured: 401. If auth skipped in CI: 501. + // Either way: must not 500. + use base64::Engine as _; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:admin-pass"); + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .header("authorization", format!("Basic {creds}")) + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + let status = resp.status().as_u16(); + assert_ne!(status, 500, "admin/keys/rotate must not 5xx: got {status}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_deactivate_key_auth_fail_returns_401() { + let mut svc = make_service(); + let req = Request::builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + let resp = svc.ready().await.expect("should be ready").call(req).await.expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 401, + "admin/keys/deactivate without credentials must return 401" + ); +} +``` + +- [ ] **Step 3: Run and verify** + +```bash +cargo test-axum 2>&1 | grep -E "admin_key|FAILED|test result" +cargo test-cloudflare 2>&1 | grep -E "admin_key|FAILED|test result" +``` + +Expected: all admin key path tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/tests/routes.rs +git commit -m "Add admin key route full path coverage to Axum and Cloudflare adapters" +``` + +--- + +## Task 4: Cross-Adapter In-Process Parity Tests + +A dedicated test binary drives Axum and Cloudflare adapters with identical requests and asserts matching status codes and critical headers. This catches divergence that per-adapter smoke tests miss. + +**Files:** + +- Modify: `crates/integration-tests/Cargo.toml` +- Create: `crates/integration-tests/tests/parity.rs` + +- [ ] **Step 1: Add adapter dependencies and parity test binary to integration-tests** + +Note: `crates/integration-tests` is intentionally **excluded** from the workspace (see root `Cargo.toml` `exclude` list). Its `Cargo.toml` must use explicit versions or path deps for everything — `workspace = true` is not available here. + +Edit `crates/integration-tests/Cargo.toml`. Add after the existing `[[test]]` block: + +```toml +[[test]] +name = "parity" +path = "tests/parity.rs" +harness = true +``` + +Add to `[dev-dependencies]`: + +```toml +trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } +trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare" } +axum = "0.7" +tower = "0.5" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +http = "1" +http-body-util = "0.1" +bytes = "1" +base64 = "0.22" +# serde_json already in dev-dependencies for existing integration tests +``` + +Then add edgezero git deps **matching the rev in root Cargo.toml exactly**. Run this to extract the rev: + +```bash +grep 'rev = ' Cargo.toml | head -1 | grep -oP '(?<=rev = ").*(?=")' +``` + +Then add to `crates/integration-tests/Cargo.toml` `[dev-dependencies]` (replace `` with actual value): + +```toml +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "", features = ["axum"] } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "" } +``` + +> **Why git dep not path dep:** edgezero is a git dependency in the workspace (not a local crate). Since `integration-tests` is workspace-excluded it cannot use `{ workspace = true }` — it must replicate the git dep form with the same rev to get Cargo to unify the dependency with the workspace's Cargo.lock. + +- [ ] **Step 2: Verify integration-tests compiles** + +```bash +cd crates/integration-tests && cargo check --test parity 2>&1 | head -30 +``` + +Expected: compiles without errors. Since integration-tests is workspace-excluded, run `cargo` from inside the `crates/integration-tests` directory or use `--manifest-path`. + +- [ ] **Step 3: Create parity test file** + +Create `crates/integration-tests/tests/parity.rs`: + +```rust +//! Cross-adapter parity tests: Axum vs Cloudflare in-process. +//! +//! Sends identical requests to both adapters and asserts that: +//! - Response status codes match +//! - Critical headers (X-Geo-Info-Available, WWW-Authenticate on 401) match +//! +//! Fastly parity is verified via cargo test-fastly + Viceroy in CI. + +// Both adapters define `TrustedServerApp` — alias both to avoid name collision. +// axum::http re-exports from the `http` crate, so HeaderMap types are identical. +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; +use edgezero_adapter_axum::EdgeZeroAxumService; +use edgezero_core::app::Hooks as _; +use edgezero_core::http::request_builder; +use http::HeaderMap; +use tower::{Service as _, ServiceExt as _}; +use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; +use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; + +/// Send a GET request to the Axum adapter and return (status, headers). +async fn axum_get(uri: &str) -> (u16, HeaderMap) { + let mut svc = EdgeZeroAxumService::new(AxumApp::routes()); + let req = AxumRequest::builder() + .method("GET") + .uri(uri) + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + // axum::http::HeaderMap is http::HeaderMap — same type, just re-exported + (resp.status().as_u16(), resp.headers().clone()) +} + +/// Send a POST request to the Axum adapter and return (status, headers, body bytes). +async fn axum_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { + use http_body_util::BodyExt as _; + let mut svc = EdgeZeroAxumService::new(AxumApp::routes()); + let req = AxumRequest::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(AxumBody::from(body.to_owned())) + .expect("should build POST request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let body_bytes = resp.into_body().collect().await.expect("should collect body").to_bytes(); + (status, headers, body_bytes) +} + +/// Convenience wrapper for tests that don't need body. +async fn axum_post_headers(uri: &str, body: &str) -> (u16, HeaderMap) { + let (s, h, _) = axum_post(uri, body).await; + (s, h) +} + +/// Send a GET request to the Cloudflare adapter and return (status, headers). +async fn cf_get(uri: &str) -> (u16, HeaderMap) { + let router = CloudflareApp::routes(); + let req = request_builder() + .method("GET") + .uri(uri) + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + // router.oneshot() is infallible — returns Response directly, not Result + let resp = router.oneshot(req).await; + (resp.status().as_u16(), resp.headers().clone()) +} + +/// Send a POST request to the Cloudflare adapter and return (status, headers, body bytes). +async fn cf_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { + use http_body_util::BodyExt as _; + let router = CloudflareApp::routes(); + let req = request_builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(body.to_owned())) + .expect("should build POST request"); + let resp = router.oneshot(req).await; + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let body_bytes = resp.into_body().collect().await.expect("should collect body").to_bytes(); + (status, headers, body_bytes) +} + +/// Convenience wrapper for tests that don't need body. +async fn cf_post_headers(uri: &str, body: &str) -> (u16, HeaderMap) { + let (s, h, _) = cf_post(uri, body).await; + (s, h) +} + +// --------------------------------------------------------------------------- +// Route parity: same route → same status class on both adapters +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn discovery_route_status_parity() { + let (axum_status, _) = axum_get("/.well-known/trusted-server.json").await; + let (cf_status, _) = cf_get("/.well-known/trusted-server.json").await; + assert_eq!( + axum_status, cf_status, + "/.well-known/trusted-server.json must return same status: axum={axum_status} cf={cf_status}" + ); +} + +#[tokio::test] +async fn discovery_route_body_is_json_parity() { + // Spec criterion 2 requires body parity. Discovery must return parseable JSON + // on both adapters (not just same status). + use http_body_util::BodyExt as _; + use serde_json::Value; + + let axum_body_bytes = { + let mut svc = EdgeZeroAxumService::new(AxumApp::routes()); + let req = AxumRequest::builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(AxumBody::empty()) + .expect("should build GET request"); + let resp = svc.ready().await.expect("ready").call(req).await.expect("respond"); + resp.into_body().collect().await.expect("collect body").to_bytes() + }; + + let cf_body_bytes = { + let router = CloudflareApp::routes(); + let req = request_builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(edgezero_core::body::Body::empty()) + .expect("should build GET request"); + let resp = router.oneshot(req).await; + resp.into_body().collect().await.expect("collect body").to_bytes() + }; + + let axum_json: Option = serde_json::from_slice(&axum_body_bytes).ok(); + let cf_json: Option = serde_json::from_slice(&cf_body_bytes).ok(); + assert!(axum_json.is_some(), "Axum discovery must return valid JSON body"); + assert!(cf_json.is_some(), "Cloudflare discovery must return valid JSON body"); +} + +#[tokio::test] +async fn verify_signature_route_parity() { + // Spec criterion 2: "signing responses" must have status parity. + // Both adapters must reach the handler (not 404) and not panic (not 500). + let (axum_status, _) = axum_post_headers("/verify-signature", "{}").await; + let (cf_status, _) = cf_post_headers("/verify-signature", "{}").await; + + assert_ne!(axum_status, 404, "Axum /verify-signature must be routed"); + assert_ne!(cf_status, 404, "Cloudflare /verify-signature must be routed"); + assert!(axum_status < 500, "Axum /verify-signature must not 5xx: {axum_status}"); + assert!(cf_status < 500, "Cloudflare /verify-signature must not 5xx: {cf_status}"); + assert_eq!( + axum_status, cf_status, + "/verify-signature must return same status: axum={axum_status} cf={cf_status}" + ); +} + +#[tokio::test] +async fn admin_rotate_unauthenticated_parity() { + let (axum_status, axum_headers) = axum_post_headers("/admin/keys/rotate", "{}").await; + let (cf_status, cf_headers) = cf_post_headers("/admin/keys/rotate", "{}").await; + + assert_eq!( + axum_status, cf_status, + "/admin/keys/rotate unauthenticated must return same status: axum={axum_status} cf={cf_status}" + ); + assert_eq!(axum_status, 401, "both adapters must return 401"); + + let axum_www_auth = axum_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let cf_www_auth = cf_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + assert!( + axum_www_auth.starts_with("Basic realm="), + "Axum 401 WWW-Authenticate must be Basic scheme: {axum_www_auth:?}" + ); + assert!( + cf_www_auth.starts_with("Basic realm="), + "Cloudflare 401 WWW-Authenticate must be Basic scheme: {cf_www_auth:?}" + ); + // Values should match (same realm string) — documents intentional divergence if not + assert_eq!( + axum_www_auth, cf_www_auth, + "WWW-Authenticate values must match across adapters" + ); +} + +#[tokio::test] +async fn geo_header_parity_on_all_responses() { + // X-Geo-Info-Available must be present on every response (FinalizeResponseMiddleware). + let routes_to_check: &[(&str, &str, &str)] = &[ + ("GET", "/.well-known/trusted-server.json", ""), + ("POST", "/auction", r#"{"adUnits":[]}"#), + ("POST", "/verify-signature", "{}"), + ]; + + for (method, path, body) in routes_to_check { + let (axum_status, axum_headers) = if *method == "GET" { + axum_get(path).await + } else { + axum_post_headers(path, body).await + }; + let (cf_status, cf_headers) = if *method == "GET" { + cf_get(path).await + } else { + cf_post_headers(path, body).await + }; + + assert!( + axum_headers.contains_key("x-geo-info-available"), + "Axum: {method} {path} (status={axum_status}) must have X-Geo-Info-Available" + ); + assert!( + cf_headers.contains_key("x-geo-info-available"), + "Cloudflare: {method} {path} (status={cf_status}) must have X-Geo-Info-Available" + ); + } +} + +#[tokio::test] +async fn auction_not_challenged_by_auth_parity() { + // /auction must not be gated by admin basic-auth on either adapter. + let (axum_status, _) = axum_post_headers("/auction", r#"{"adUnits":[]}"#).await; + let (cf_status, _) = cf_post_headers("/auction", r#"{"adUnits":[]}"#).await; + + assert_ne!(axum_status, 401, "Axum /auction must not 401"); + assert_ne!(cf_status, 401, "Cloudflare /auction must not 401"); +} + +#[tokio::test] +async fn cookie_behavior_note() { + // Cookie (Set-Cookie) behavior is set by the publisher proxy handler when + // the origin serves a full HTML page. In-process tests without a live origin + // cannot exercise this path. Cookie parity is covered by the Docker-based + // integration tests in test_all_combinations (marked #[ignore] — requires Docker). + // + // What we CAN verify in-process: publisher route does NOT set a cookie when + // the origin is unavailable (no origin configured → no EC cookie attempt). + let (axum_status, axum_headers) = axum_get("/").await; + let (cf_status, cf_headers) = cf_get("/").await; + + // Neither adapter should crash on publisher fallback path + assert!(axum_status < 500, "Axum publisher fallback must not 5xx: {axum_status}"); + assert!(cf_status < 500, "Cloudflare publisher fallback must not 5xx: {cf_status}"); + + // If a Set-Cookie is set, both adapters must set it (presence parity) + let axum_has_cookie = axum_headers.contains_key("set-cookie"); + let cf_has_cookie = cf_headers.contains_key("set-cookie"); + assert_eq!( + axum_has_cookie, cf_has_cookie, + "Set-Cookie presence must match: axum={axum_has_cookie} cf={cf_has_cookie}" + ); +} + +#[tokio::test] +async fn unknown_route_returns_same_status_parity() { + // Both adapters must handle unknown routes the same way (not crash). + let (axum_status, _) = axum_get("/this-route-does-not-exist-abc123").await; + let (cf_status, _) = cf_get("/this-route-does-not-exist-abc123").await; + + assert_eq!( + axum_status, cf_status, + "unknown routes must return same status: axum={axum_status} cf={cf_status}" + ); +} +``` + +- [ ] **Step 4: Run parity tests** + +```bash +cd crates/integration-tests && cargo test --test parity 2>&1 | tail -30 +``` + +Expected: all parity tests pass. If status codes diverge, investigate the differing adapter behavior and add an exception comment documenting the known difference if intentional. + +- [ ] **Step 5: Commit** + +```bash +git add crates/integration-tests/Cargo.toml crates/integration-tests/tests/parity.rs +git commit -m "Add cross-adapter in-process parity test suite (Axum vs Cloudflare)" +``` + +--- + +## Task 5: Auction Async Fan-Out and Error-Correlation Tests + +Verify that `PlatformResponse::backend_name` is `None` on Axum/Cloudflare (as expected before EdgeZero #213), and that the auction orchestrator handles this gracefully without panicking. + +**Files:** + +- Modify: `crates/trusted-server-core/src/platform/http.rs` (where `PlatformResponse` is defined) + +- [ ] **Step 1: Locate existing test module in orchestrator.rs** + +```bash +grep -n "#\[cfg(test)\]\|mod tests\|#\[test\]" crates/trusted-server-core/src/auction/orchestrator.rs | head -20 +``` + +- [ ] **Step 2: Write error-correlation tests** + +These tests live in `crates/trusted-server-core/src/platform/http.rs` `#[cfg(test)]` module (where `PlatformResponse` is defined), not in `orchestrator.rs`. Add after the existing tests in that file's `#[cfg(test)]` module: + +```rust +// --------------------------------------------------------------------------- +// Error-correlation interim scope (before EdgeZero #213) +// --------------------------------------------------------------------------- + +#[test] +fn platform_response_default_has_no_backend_name() { + // On Axum/Cloudflare noop clients return PlatformResponse::new(response) + // with no backend_name. Core logic must not panic when backend_name is None. + let response = edgezero_core::http::Response::builder() + .status(200) + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + let resp = PlatformResponse::new(response); + // PlatformResponse has a public field, not a method. + // PlatformPendingRequest has backend_name() method; PlatformResponse does not. + assert_eq!( + resp.backend_name, + None, + "PlatformResponse without backend_name must have None field" + ); +} + +#[test] +fn platform_response_with_backend_name_is_some() { + // On Fastly, responses carry backend_name for error correlation. + let response = edgezero_core::http::Response::builder() + .status(200) + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + let resp = PlatformResponse::new(response).with_backend_name("prebid-backend"); + assert_eq!( + resp.backend_name.as_deref(), + Some("prebid-backend"), + "with_backend_name must set backend_name field" + ); +} +``` + +Confirmed: `platform/http.rs` has **no existing `#[cfg(test)]` module** (verified by grep). Must create one. Add at end of file: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // error-correlation tests go here +} +``` + +**File:** `crates/trusted-server-core/src/platform/http.rs` + +```` + +- [ ] **Step 3: Run the tests** + +```bash +cargo test -p trusted-server-core auction::orchestrator::tests 2>&1 | tail -20 +```` + +Expected: both tests pass. + +- [ ] **Step 4: Run tests** + +```bash +cargo test -p trusted-server-core platform_response 2>&1 | tail -15 +``` + +Expected: both tests pass (test names match `platform_response_*`). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/platform/http.rs +git commit -m "Add error-correlation unit tests for PlatformResponse backend_name" +``` + +--- + +## Task 6: HTML Rewriting Golden Tests + +Strengthen `html_processor.rs` tests with precise snapshot-style assertions that will catch regressions in injection position, URL rewriting correctness, and integration rewriter behavior. + +**Files:** + +- Modify: `crates/trusted-server-core/src/html_processor.rs` + +- [ ] **Step 1: Find the existing `test_real_publisher_html` test and helper** + +```bash +grep -n "fn test_real_publisher_html\|fn create_test_config\|fn test_integration_registry" \ + crates/trusted-server-core/src/html_processor.rs +``` + +Confirmed: `create_test_config()` is at line ~537. `test_real_publisher_html` at ~728. + +`HtmlProcessorConfig` actual fields (no `script_tag`): + +```rust +pub struct HtmlProcessorConfig { + pub origin_host: String, + pub request_host: String, + pub request_scheme: String, + pub integrations: IntegrationRegistry, // NOT Arc<> wrapped +} +``` + +- [ ] **Step 2: Add golden injection position test** + +In the `#[cfg(test)]` module of `crates/trusted-server-core/src/html_processor.rs`, add after the existing tests: + +```rust +#[test] +fn golden_script_tag_injected_at_head_start() { + // The trusted-server script tag must be the FIRST child of . + // Any drift in injection position breaks the page initialization order. + let html = r#" + +Test +

Hello

+"#; + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + let head_pos = output_str + .find("") + .expect("should contain "); + let script_pos = output_str + .find(" head_pos, + "script tag must appear after opening: head_pos={head_pos}, script_pos={script_pos}" + ); + + // No other elements between and the script tag + let between = &output_str[head_pos + "".len()..script_pos]; + let trimmed = between.trim(); + assert!( + trimmed.is_empty(), + "script tag must be first child of , found content before it: {trimmed:?}" + ); +} + +#[test] +fn golden_url_rewriting_replaces_origin_in_href() { + // href attributes pointing at origin domain must be rewritten to proxy host. + let origin = "https://origin.test-publisher.com"; + let html = format!( + r#" + Link + + "# + ); + + let request_host = "proxy.test-publisher.com"; + let config = HtmlProcessorConfig { + origin_host: "origin.test-publisher.com".to_string(), + request_host: request_host.to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::default(), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + assert!( + !output_str.contains("origin.test-publisher.com"), + "origin host must not appear in rewritten HTML" + ); + assert!( + output_str.contains(request_host), + "proxy host must appear in rewritten HTML" + ); +} + +#[test] +fn golden_integration_script_is_not_double_injected() { + // Integration scripts from the registry must appear exactly once. + let html = r#" +

Content

"#; + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + let output_str = std::str::from_utf8(&output).expect("should be valid UTF-8"); + + let script_count = output_str.matches("/static/tsjs=").count(); + assert_eq!( + script_count, 1, + "script tag must appear exactly once, found {script_count} occurrences" + ); +} +``` + +- [ ] **Step 3: Run golden tests** + +```bash +cargo test -p trusted-server-core html_processor 2>&1 | tail -30 +``` + +Expected: all golden tests pass. If they fail, diagnose whether the processor behavior is wrong or the test assumptions are wrong (e.g., `create_test_config()` not available). + +- [ ] **Step 4: Fix any helper gaps** + +If `create_test_config()` doesn't exist, add it to the test module following the config pattern in `test_real_publisher_html` (around line 730). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/html_processor.rs +git commit -m "Add HTML rewriting golden regression tests" +``` + +--- + +## Task 7: Performance Benchmarks (p95 Latency + Response Size) + +Criterion benchmarks for the HTML processor establish a baseline for regression detection. Benchmark name: `html_processor_bench`. + +**Files:** + +- Modify: `crates/trusted-server-core/Cargo.toml` (verify bench target) +- Create: `crates/trusted-server-core/benches/html_processor_bench.rs` + +- [ ] **Step 1: Verify existing Cargo.toml bench configuration** + +```bash +grep -A5 "\[\[bench\]\]" crates/trusted-server-core/Cargo.toml +``` + +There is already a `consent_decode` bench. Add a second `[[bench]]` entry. + +- [ ] **Step 2: Add benchmark entry to Cargo.toml** + +In `crates/trusted-server-core/Cargo.toml`, add after the existing `[[bench]]` block: + +```toml +[[bench]] +name = "html_processor_bench" +harness = false +``` + +- [ ] **Step 3: Create benchmark file** + +Create `crates/trusted-server-core/benches/html_processor_bench.rs`: + +```rust +//! Performance benchmarks for the HTML processor. +//! +//! Baseline targets (to be updated after first run establishes actuals): +//! - process_chunk (10KB HTML): < 2ms mean +//! - process_chunk (100KB HTML): < 10ms mean +//! +//! Run with: cargo bench -p trusted-server-core --bench html_processor_bench + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use trusted_server_core::html_processor::{HtmlProcessorConfig, create_html_processor}; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::streaming_processor::StreamProcessor as _; + +fn make_config() -> HtmlProcessorConfig { + // HtmlProcessorConfig fields: origin_host, request_host, request_scheme, integrations + // No script_tag field — the script tag is generated from the configured tsjs module list + HtmlProcessorConfig { + origin_host: "origin.bench.com".to_string(), + request_host: "proxy.bench.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::default(), + } +} + +fn make_html(size_kb: usize) -> Vec { + // Construct a realistic HTML page of approximately `size_kb` KB + // with links, images, and ad slots to exercise all rewriter paths. + let link_block = r#"Link + + +"#; + + let body_content = link_block.repeat((size_kb * 1024) / link_block.len() + 1); + + format!( + r#" + + + +Benchmark Page + + +{body_content} + +"# + ) + .into_bytes() +} + +fn bench_html_processor(c: &mut Criterion) { + let mut group = c.benchmark_group("html_processor"); + + for size_kb in [10usize, 100] { + let html = make_html(size_kb); + + group.bench_with_input( + BenchmarkId::new("process_chunk", format!("{size_kb}kb")), + &html, + |b, html| { + b.iter(|| { + let config = make_config(); + // `create_html_processor` returns `impl StreamProcessor` + // which exposes `process_chunk(&mut self, chunk: &[u8], is_last: bool)` + let mut processor = create_html_processor(config); + let result = processor + .process_chunk(html.as_slice(), true) + .expect("should process HTML"); + result + }); + }, + ); + } + + group.finish(); +} + +criterion_group!(benches, bench_html_processor); +criterion_main!(benches); +``` + +- [ ] **Step 4: Run benchmarks to establish baseline** + +```bash +cargo bench -p trusted-server-core --bench html_processor_bench 2>&1 | tail -20 +``` + +Expected: benchmarks complete. Record the mean latencies from the output for future regression comparison. + +- [ ] **Step 5: Verify response size by adding a measurement test** + +Add to the benchmark file a single measurement test (not a Criterion bench) to assert response size bounds. Alternatively, add this to the `html_processor.rs` unit tests: + +In `crates/trusted-server-core/src/html_processor.rs` `#[cfg(test)]` module: + +```rust +#[test] +fn response_size_does_not_grow_disproportionately() { + // Processing must not expand HTML by more than 2× (accounts for injected + // script tag + URL rewrites). Disproportionate growth indicates a bug + // (e.g., double-processing, buffer leak). + // File exists at crates/trusted-server-core/src/html_processor.test.html + // (already used by test_real_publisher_html at line ~728). + let html = include_str!("html_processor.test.html"); + let input_size = html.len(); + + let config = create_test_config(); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(html.as_bytes(), true) + .expect("should process HTML"); + + let output_size = output.len(); + let growth_factor = output_size as f64 / input_size as f64; + + assert!( + growth_factor < 2.0, + "processed HTML must not grow by more than 2×: input={input_size}B output={output_size}B factor={growth_factor:.2}" + ); +} +``` + +- [ ] **Step 6: Run tests** + +```bash +cargo test -p trusted-server-core response_size 2>&1 | tail -10 +``` + +Expected: passes. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/Cargo.toml \ + crates/trusted-server-core/benches/html_processor_bench.rs \ + crates/trusted-server-core/src/html_processor.rs +git commit -m "Add Criterion benchmarks and response size regression test for HTML processor" +``` + +--- + +## Task 8: CI Verification Gate + +Update the CI workflows to run the new parity test binary and include a benchmark smoke-run (no regression threshold yet — establishes baseline). + +**Files:** + +- Modify: `.github/workflows/test.yml` + +- [ ] **Step 1: Add parity test job to test.yml** + +Add after the `test-cloudflare` job in `.github/workflows/test.yml`: + +```yaml +test-parity: + name: cargo test (cross-adapter parity) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + cache-shared-key: cargo-${{ runner.os }} + + - name: Run cross-adapter parity tests + run: cargo test --manifest-path crates/integration-tests/Cargo.toml --test parity +``` + +- [ ] **Step 2: Add benchmark smoke-run to axum job (optional — compile-only check)** + +In the `test-axum` job, add after the test step: + +```yaml +- name: Run HTML processor benchmarks (smoke run) + run: cargo bench -p trusted-server-core --bench html_processor_bench -- --test + # `-- --test` runs benchmarks as tests (1 iteration), not full bench. + # Full benchmarking is done manually, not in CI. +``` + +- [ ] **Step 3: Verify workflow YAML is valid** + +```bash +# Check for YAML syntax errors +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "YAML valid" +``` + +Expected: `YAML valid`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "Add cross-adapter parity and benchmark CI gates for Phase 5 verification" +``` + +--- + +## Verification Checklist + +After all tasks are complete, run the full suite: + +```bash +# Fastly + core +cargo test-fastly + +# Axum adapter (includes auth parity + admin key tests) +cargo test-axum + +# Cloudflare adapter (includes route completeness + auth parity + admin key tests) +cargo test-cloudflare + +# Cross-adapter parity +cargo test --manifest-path crates/integration-tests/Cargo.toml --test parity + +# HTML golden + response size + error-correlation +cargo test -p trusted-server-core + +# Benchmarks (smoke run) +cargo bench -p trusted-server-core --bench html_processor_bench -- --test +``` + +All commands must exit 0 before marking this PR complete.