#![cfg(test)]
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::{to_bytes, Body};
use axum::extract::ConnectInfo;
use axum::http::{header, Method, Request, StatusCode};
use axum::response::Response;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::TcpListener;
use tower::ServiceExt as _;
use boatramp_core::config::{DeployConfig, DomainConfig, HeaderRule, Redirect, SiteConfig};
use boatramp_core::deploy::{sha256_hex, DeployStore, FileEntry, Manifest};
use boatramp_core::gateway::{GatewayConfig, GatewayRoute, Upstream};
use boatramp_core::kv::MemoryKv;
use boatramp_core::project::ProjectRef;
use boatramp_core::security::SecurityProfile;
use boatramp_core::ByteStream;
use futures::StreamExt as _;
use crate::{Auth, FastServe, HandlerRuntime, ServerOptions};
const PEER: SocketAddr =
SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 40000);
fn file(bytes: &'static [u8], content_type: &str) -> FileEntry {
FileEntry {
hash: sha256_hex(bytes),
size: bytes.len() as u64,
content_type: Some(content_type.to_string()),
variants: BTreeMap::new(),
}
}
async fn put_blob(deploy: &DeployStore, bytes: &'static [u8]) {
let hash = sha256_hex(bytes);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
}
fn mk(host: &str, method: Method, path: &str) -> Request<Body> {
let mut req = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap();
req.headers_mut()
.insert(header::HOST, host.parse().unwrap());
req.headers_mut()
.insert("x-request-id", "fixed-test-id".parse().unwrap());
req.extensions_mut().insert(ConnectInfo(PEER));
req
}
async fn collect(resp: Response) -> (StatusCode, Vec<(String, Vec<u8>)>, Vec<u8>) {
let status = resp.status();
let mut headers: Vec<(String, Vec<u8>)> = resp
.headers()
.iter()
.filter(|(k, _)| !matches!(k.as_str(), "content-length" | "transfer-encoding"))
.map(|(k, v)| (k.as_str().to_string(), v.as_bytes().to_vec()))
.collect();
headers.sort();
let body = to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, headers, body)
}
async fn spawn_upstream() -> SocketAddr {
let up = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = up.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut s, _)) = up.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match s.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) if !buf[..n].windows(4).any(|w| w == b"\r\n\r\n") => continue,
Ok(_) => {}
}
let body = b"hello-from-upstream";
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
if s.write_all(head.as_bytes()).await.is_err()
|| s.write_all(body).await.is_err()
{
return;
}
}
});
}
});
addr
}
async fn seed(up_addr: SocketAddr) -> DeployStore {
let deploy = DeployStore::new(
Arc::new(boatramp_storage::FsStorage::new(std::env::temp_dir())),
Arc::new(MemoryKv::new()),
);
const INDEX: &[u8] = b"<h1>home</h1>";
const ABOUT: &[u8] = b"<h1>about</h1>";
const NF: &[u8] = b"<h1>nope</h1>";
const BIG: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz-the-big-file-body-x";
const JS: &[u8] = b"export const id = (x) => x;";
for b in [INDEX, ABOUT, NF, BIG, JS] {
put_blob(&deploy, b).await;
}
let mut files = BTreeMap::new();
files.insert("index.html".to_string(), file(INDEX, "text/html"));
files.insert("about.html".to_string(), file(ABOUT, "text/html"));
files.insert("404.html".to_string(), file(NF, "text/html"));
files.insert("big.txt".to_string(), file(BIG, "text/plain"));
files.insert("app.js".to_string(), file(JS, "text/javascript"));
let config = DeployConfig {
clean_urls: true,
error_documents: BTreeMap::from([(404, "/404.html".to_string())]),
redirects: vec![Redirect {
from: "/old".to_string(),
to: "/new".to_string(),
status: 301,
when: None,
}],
headers: vec![HeaderRule {
matches: "**.js".to_string(),
set: BTreeMap::from([("Cache-Control".to_string(), "immutable".to_string())]),
unset: vec![],
}],
..DeployConfig::default()
};
let manifest = Manifest {
files,
config,
..Default::default()
};
deploy
.set_site_config(
ProjectRef::DEFAULT,
"static",
&SiteConfig {
domains: DomainConfig {
primary: Some("app.local".into()),
..Default::default()
},
..Default::default()
},
)
.await
.unwrap();
let id = deploy.put_manifest(&manifest).await.unwrap();
deploy
.activate(ProjectRef::DEFAULT, "static", &id)
.await
.unwrap();
deploy
.set_site_config(
ProjectRef::DEFAULT,
"gw",
&SiteConfig {
domains: DomainConfig {
primary: Some("gw.local".into()),
..Default::default()
},
gateway: Some(GatewayConfig {
upstreams: std::iter::once((
"backend".to_string(),
Upstream {
target: format!("http://{up_addr}"),
..Default::default()
},
))
.collect(),
routes: vec![GatewayRoute {
matches: "/**".into(),
upstream: "backend".into(),
}],
}),
..Default::default()
},
)
.await
.unwrap();
let gw_id = deploy.put_manifest(&Manifest::default()).await.unwrap();
deploy
.activate(ProjectRef::DEFAULT, "gw", &gw_id)
.await
.unwrap();
deploy
}
fn build(deploy: DeployStore) -> (axum::Router, FastServe) {
crate::router_with_fast(
deploy,
Auth::disabled(),
HandlerRuntime::disabled(),
ServerOptions {
posture: SecurityProfile::Dev.preset(),
..Default::default()
},
)
}
#[tokio::test]
async fn classifier_excludes_every_reserved_route_and_non_read_method() {
let up = spawn_upstream().await;
let deploy = seed(up).await;
let (_router, fast) = build(deploy);
for (method, path) in [
(Method::GET, "/"),
(Method::GET, "/about"),
(Method::GET, "/app.js"),
(Method::GET, "/big.txt"),
(Method::GET, "/old"),
(Method::GET, "/deep/nested/path"),
(Method::HEAD, "/"),
] {
assert!(
fast.eligible(&mk("app.local", method.clone(), path)),
"expected eligible: {method} {path}"
);
}
for path in [
"/api",
"/api/",
"/api/sites",
"/api/projects/acme/sites/blog",
"/_sites/static/",
"/_deploy/abc/",
"/_webhooks/x",
"/.well-known/acme-challenge/tok",
"/.well-known/boatramp-bootstrap-identity",
"/healthz",
"/readyz",
"/mcp",
"/mcp/messages",
] {
assert!(
!fast.eligible(&mk("app.local", Method::GET, path)),
"reserved route must not be eligible: {path}"
);
}
for method in [
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
Method::OPTIONS,
] {
assert!(
!fast.eligible(&mk("app.local", method.clone(), "/")),
"non-read method must not be eligible: {method}"
);
}
}
#[tokio::test]
async fn fast_path_is_byte_identical_to_the_router_for_every_eligible_request() {
let up = spawn_upstream().await;
let deploy = seed(up).await;
let (router, fast) = build(deploy);
for (host, method, path) in [
("app.local", Method::GET, "/"),
("app.local", Method::GET, "/about"), ("app.local", Method::GET, "/app.js"), ("app.local", Method::GET, "/big.txt"),
("app.local", Method::GET, "/old"), ("app.local", Method::GET, "/does-not-exist"), ("app.local", Method::HEAD, "/"),
("nope.local", Method::GET, "/"),
] {
let probe = mk(host, method.clone(), path);
assert!(
fast.eligible(&probe),
"test bug: {method} {host}{path} should be eligible"
);
let via_router = collect(
router
.clone()
.oneshot(mk(host, method.clone(), path))
.await
.unwrap(),
)
.await;
let via_fast = collect(fast.dispatch(mk(host, method.clone(), path), PEER).await).await;
assert_eq!(
via_router, via_fast,
"fast path diverged from router for {method} {host}{path}"
);
}
let via_router = collect(
router
.clone()
.oneshot(mk("gw.local", Method::GET, "/anything"))
.await
.unwrap(),
)
.await;
let via_fast = collect(
fast.dispatch(mk("gw.local", Method::GET, "/anything"), PEER)
.await,
)
.await;
assert_eq!(
via_router.0,
StatusCode::OK,
"router should proxy the private upstream under the permissive posture"
);
assert_eq!(via_router.0, via_fast.0, "gateway status diverged");
assert_eq!(via_router.2, via_fast.2, "gateway body diverged");
assert_eq!(
via_fast.2, b"hello-from-upstream",
"fast path did not proxy"
);
}
#[cfg(feature = "http3")]
#[tokio::test]
async fn tls_http3_fast_path_matches_router_with_alt_svc() {
let up = spawn_upstream().await;
let deploy = seed(up).await;
let (router, fast) = crate::router_with_fast(
deploy,
Auth::disabled(),
HandlerRuntime::disabled(),
ServerOptions {
posture: SecurityProfile::Dev.preset(),
served_over_tls: true,
..Default::default()
},
);
let port = 8443u16;
let router = crate::advertise_http3(router, port);
let fast = fast.advertise_http3(port);
for (host, path) in [
("app.local", "/"),
("app.local", "/about"),
("app.local", "/app.js"),
("app.local", "/old"),
] {
let probe = mk(host, Method::GET, path);
assert!(fast.eligible(&probe), "should be eligible: {host}{path}");
let via_router = collect(
router
.clone()
.oneshot(mk(host, Method::GET, path))
.await
.unwrap(),
)
.await;
let via_fast = collect(fast.dispatch(mk(host, Method::GET, path), PEER).await).await;
assert_eq!(
via_router, via_fast,
"TLS+h3 fast path diverged for {host}{path}"
);
assert!(
via_fast.1.iter().any(|(k, _)| k == "alt-svc"),
"Alt-Svc missing on the fast path for {host}{path}"
);
}
}