Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ steps.rust-version.outputs.rust-version }}
components: clippy, rustfmt

- name: Install wasm targets for generated-project checks
if: matrix.suite == 'cli'
Expand Down Expand Up @@ -80,6 +81,13 @@ jobs:
if: matrix.suite == 'cli'
run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config

- name: Verify native request ingress harness
if: matrix.suite == 'workspace'
run: |
cargo fmt --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all -- --check
cargo test --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --locked
cargo clippy --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all-targets --locked -- -D warnings

- name: Lifecycle fixture host tests
if: matrix.suite == 'workspace'
run: >-
Expand Down Expand Up @@ -191,10 +199,35 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ steps.rust-version.outputs.rust-version }}
components: clippy, rustfmt

- name: Add wasm target
run: rustup target add ${{ matrix.target }}

- name: Set up matching browser and driver
if: matrix.adapter == 'cloudflare'
id: chrome
uses: browser-actions/setup-chrome@v2
with:
chrome-version: 154.0.8037.92
install-chromedriver: true
install-dependencies: true

- name: Set up Node for local Worker tooling
if: matrix.adapter == 'cloudflare'
uses: actions/setup-node@v6
with:
node-version: 24.12.0
cache: npm
cache-dependency-path: tests/fixtures/request-fidelity/package-lock.json

- name: Bind browser tests to pinned Chrome
if: matrix.adapter == 'cloudflare'
env:
CHROME_BINARY: ${{ steps.chrome.outputs.chrome-path }}
run: |
node -e 'require("node:fs").writeFileSync(require("node:path").join(process.env.RUNNER_TEMP, "edgezero-webdriver.json"), JSON.stringify({"goog:chromeOptions": {binary: process.env.CHROME_BINARY}}))'

- name: Resolve wasm-bindgen CLI version
if: matrix.adapter == 'cloudflare'
id: wasm-bindgen-version
Expand Down Expand Up @@ -285,6 +318,8 @@ jobs:

- name: Run ${{ matrix.adapter }} wasm tests
env:
CHROMEDRIVER: ${{ steps.chrome.outputs.chromedriver-path }}
WASM_BINDGEN_TEST_WEBDRIVER_JSON: ${{ runner.temp }}/edgezero-webdriver.json
${{ matrix.runner_env }}: ${{ matrix.runner_value }}
run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract

Expand All @@ -298,6 +333,51 @@ jobs:
${{ matrix.runner_env }}: ${{ matrix.runner_value }}
run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib

# Spin's metadata helper runs on the host; its full lib test artifact
# imports Spin-specific interfaces unavailable in plain Wasmtime.
# The real Spin HTTP fixture below executes the converter instead.
- name: Install lock-matched worker-build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 CI cost and supply chain

  • worker-build build time: the "skip if cached" check is correct, but it never takes effect. In every PR run, cargo install worker-build compiled from source (126–129s). The cache restore misses, and rust-cache cleans ~/.cargo/bin. Overall the PR adds about 4 minutes to the Cloudflare job, about 1 minute to Spin and about 1.3 minutes to Fastly. A pinned prebuilt install (taiki-e/install-action with an exact version, or cargo binstall), plus the fixture target dir in the cache paths, would recover most of that.
  • esbuild download: in module mode, worker-build downloads esbuild from the npm registry with no checksum unless ESBUILD_BIN is set. package-lock.json already pins esbuild with an integrity hash, so ESBUILD_BIN="$PWD/tests/fixtures/request-fidelity/node_modules/.bin/esbuild" would match the pinned-digest approach used for Spin.
  • Small items:
    • Use node-version-file: .tool-versions instead of hard-coding the Node version.
    • Add --retry to the Spin curl, and check the version before downloading.
    • The Spin lib-tests comment at :336-338 sits above the worker-build step.

if: matrix.adapter == 'cloudflare'
shell: bash
run: |
if [[ "$(worker-build --version 2>/dev/null || true)" != "0.8.3" ]]; then
cargo install worker-build --version 0.8.3 --locked --force
fi

- name: Install pinned Spin HTTP runtime
if: matrix.adapter == 'spin'
shell: bash
run: |
version="$(awk '$1 == "spin-cli" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)"
install_dir="$RUNNER_TEMP/spin"
mkdir -p "$install_dir"
curl -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz"
# GitHub's published digest for the v4.0.0 linux-amd64 release asset.
# Keep the version and digest paired when updating the fixture pin.
test "$version" = "4.0.0"
echo "e705c9bfd9484a9175f392a116856680862a40f31d1a85618bed331f34ffccfa $RUNNER_TEMP/spin.tar.gz" | sha256sum --check --strict
tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir"
echo "$install_dir" >> "$GITHUB_PATH"
"$install_dir/spin" --version

- name: Check isolated fixture formatting
run: cargo fmt --manifest-path tests/fixtures/request-fidelity/Cargo.toml --all -- --check

- name: Lint isolated ingress fixture
run: >-
cargo clippy --manifest-path tests/fixtures/request-fidelity/Cargo.toml
--features ${{ matrix.adapter }} --target ${{ matrix.target }}
--all-targets --locked -- -D warnings

- name: Run local HTTP ingress probes
shell: bash
run: |
if [ "${{ matrix.adapter }}" = "cloudflare" ]; then
WASM_BINDGEN_BIN="$(command -v wasm-bindgen)"
export WASM_BINDGEN_BIN
fi
./scripts/test-request-fidelity.sh ${{ matrix.adapter }}

- name: Fastly reusable HTTP smoke
if: matrix.adapter == 'fastly'
run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke
Expand Down
169 changes: 165 additions & 4 deletions crates/edgezero-adapter-axum/src/dev_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use std::sync::Arc;

use crate::request::HttpTransport;
use anyhow::Context as _;
use axum::Router;
use axum::body::Body as AxumBody;
use axum::http::Request as AxumRequest;
use tokio::net::TcpListener as TokioTcpListener;
use tokio::runtime::Builder as RuntimeBuilder;
use tokio::signal;
Expand Down Expand Up @@ -299,10 +302,12 @@ async fn serve_with_stores(
}
service
};
let axum_router = Router::new().fallback_service(service_fn(move |req| {
let mut svc = service.clone();
async move { svc.call(req).await }
}));
let axum_router =
Router::new().fallback_service(service_fn(move |mut req: AxumRequest<AxumBody>| {
req.extensions_mut().insert(HttpTransport);
let mut svc = service.clone();
async move { svc.call(req).await }
}));
let make_service = axum_router.into_make_service_with_connect_info::<SocketAddr>();

let shutdown = enable_ctrl_c.then_some(async {
Expand Down Expand Up @@ -748,6 +753,23 @@ mod integration_tests {
use tokio::task::{JoinHandle, spawn_blocking};
use tokio::time::sleep;

use async_trait::async_trait;
use edgezero_core::body::Body;
use edgezero_core::http::{HeaderValue, Method, Request, Response, response_builder};
use edgezero_core::middleware::{Middleware, Next};
use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress};
use edgezero_core::router::PreDispatchHook;
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use std::sync::atomic::{AtomicUsize, Ordering};

struct IngressHook {
calls: Arc<AtomicUsize>,
}
struct IngressMiddleware {
calls: Arc<AtomicUsize>,
}

struct TestServer {
_temp_dir: tempfile::TempDir,
base_url: String,
Expand All @@ -759,6 +781,145 @@ mod integration_tests {
handle: JoinHandle<()>,
}

#[async_trait(?Send)]
impl Middleware for IngressMiddleware {
async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result<Response, EdgeError> {
self.calls.fetch_add(1, Ordering::SeqCst);
next.run(ctx).await
}
}

#[async_trait(?Send)]
impl PreDispatchHook for IngressHook {
async fn handle(&self, request: &mut Request) -> Result<Option<Response>, EdgeError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let ingress = request
.extensions()
.get::<RequestIngress>()
.expect("should carry adapter metadata");
assert_eq!(
ingress
.origin()
.expect("should expose HTTP binding")
.scheme(),
"http"
);
let CapturedTarget::Complete(target) = ingress.target() else {
panic!("should capture target")
};
assert_eq!(target.value(), request.uri().to_string());
match request.headers()["x-fixture"]
.to_str()
.expect("should read fixed fixture")
{
"repeat" => {
assert_eq!(request.method().as_str(), "EXAMPLE-METHOD");
assert_eq!(request.headers().get_all("origin").iter().count(), 2);
assert_eq!(request.headers().get_all("x-control").iter().count(), 2);
let cookies: Vec<_> = request
.headers()
.get_all("cookie")
.iter()
.map(HeaderValue::as_bytes)
.collect();
assert_eq!(cookies, [b"a=\xff".as_slice(), b"b=2".as_slice()]);
}
"length" => {
assert_eq!(
request.headers().get_all("content-length").iter().count(),
1
);
assert_eq!(
ingress
.header_fidelity(&"content-length".parse().expect("should parse name"))
.field_multiplicity(),
Preservation::Unknown
);
}
"te-cl" => assert!(!request.headers().contains_key("content-length")),
"continue" => {
*request.method_mut() = Method::GET;
*request.uri_mut() = "/ordinary".parse().expect("should parse fixed route");
return Ok(None);
}
"error" => return Err(EdgeError::bad_request("fixture rejection")),
"dot" => assert_eq!(request.uri().path(), "/reserved/%2e/../%2F//"),
_ => panic!("should use fixed fixture"),
}
Ok(Some(
response_builder()
.status(204)
.header("x-hook", "yes")
.body(Body::empty())
.map_err(EdgeError::internal)?,
))
}
}

#[action]
async fn ingress_ordinary() -> &'static str {
"continued"
}

fn raw_ingress_response(addr: SocketAddr, fixture: &[u8]) -> Vec<u8> {
let mut connection = TcpStream::connect(addr).expect("should connect listener");
connection
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("should set timeout");
connection
.write_all(fixture)
.expect("should send exact request");
let mut response = Vec::new();
connection
.read_to_end(&mut response)
.expect("should read response");
response
}

#[tokio::test(flavor = "multi_thread")]
async fn ingress_raw_tcp_distinguishes_parser_and_hook_behaviour() {
let hook_calls = Arc::new(AtomicUsize::new(0));
let middleware_calls = Arc::new(AtomicUsize::new(0));
let router = RouterService::builder()
.middleware(IngressMiddleware {
calls: Arc::clone(&middleware_calls),
})
.pre_dispatch_hook(Arc::new(IngressHook {
calls: Arc::clone(&hook_calls),
}))
.get("/ordinary", ingress_ordinary)
.build();
let server = start_test_server(router).await;
let addr: SocketAddr = server
.base_url
.strip_prefix("http://")
.expect("should have HTTP binding")
.parse()
.expect("should parse listener address");
let cases: [(&[u8], &[u8], usize); 7] = [
(b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: repeat\r\nCookie: a=\xff\r\nOrigin: https://example.com\r\nX-Control: first\r\nCOOKIE: b=2\r\nOrigin: https://other.example.com\r\nX-Control: second\r\n\r\n", b"HTTP/1.1 204", 1),
(b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", b"HTTP/1.1 204", 1),
(b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", b"HTTP/1.1 400", 0),
(b"GET /reserved/%2e/../%2F// HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: dot\r\n\r\n", b"HTTP/1.1 204", 1),
(b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: continue\r\n\r\n", b"HTTP/1.1 200", 1),
(b"GET /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: error\r\n\r\n", b"HTTP/1.1 400", 1),
(b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: te-cl\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n0\r\n\r\n", b"HTTP/1.1 204", 1),
];
for (fixture, status, calls) in cases {
let before = hook_calls.load(Ordering::SeqCst);
let response = spawn_blocking(move || raw_ingress_response(addr, fixture))
.await
.expect("should join socket probe");
assert!(
response.starts_with(status),
"should return expected fixed fixture status"
);
assert_eq!(hook_calls.load(Ordering::SeqCst) - before, calls);
}
assert_eq!(middleware_calls.load(Ordering::SeqCst), 1);
server.handle.abort();
}

async fn start_test_server(router: RouterService) -> TestServer {
let listener = TokioTcpListener::bind("127.0.0.1:0")
.await
Expand Down
Loading
Loading