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
5 changes: 5 additions & 0 deletions crates/integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ name = "integration"
path = "tests/integration.rs"
harness = true

[[test]]
name = "routing"
path = "tests/routing.rs"
harness = true

[dev-dependencies]
testcontainers = { version = "0.25", features = ["blocking"] }
reqwest = { version = "0.12", features = ["blocking"] }
Expand Down
226 changes: 226 additions & 0 deletions crates/integration-tests/tests/routing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#![allow(dead_code)]

mod common;
mod environments;

use common::runtime::RuntimeEnvironment as _;
use environments::fastly::FastlyViceroy;
use std::io::{Read as _, Write as _};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;

/// In-process HTTP server that returns a fixed response body.
///
/// Listens on a fixed port. Accepts connections in a background thread,
/// drains each request, and responds with `HTTP/1.1 200 OK` and the
/// configured body. Stopped on [`Drop`] via a shutdown flag + self-connect.
///
/// Does not store the `JoinHandle` so that `MockOrigin` remains `Sync`
/// (required for placement in a `static OnceLock`). The thread exits
/// naturally when the process ends.
struct MockOrigin {
port: u16,
shutdown: Arc<AtomicBool>,
}

impl MockOrigin {
/// Start a mock origin server on `port` that always responds with `body`.
///
/// # Panics
///
/// Panics if the port cannot be bound.
fn start(port: u16, body: &'static str) -> Self {
let listener = TcpListener::bind(format!("127.0.0.1:{port}"))
.unwrap_or_else(|e| panic!("should bind MockOrigin to port {port}: {e}"));

let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);

thread::spawn(move || {
for stream in listener.incoming() {
if shutdown_clone.load(Ordering::Relaxed) {
break;
}
if let Ok(stream) = stream {
serve(stream, body);
}
}
});

MockOrigin { port, shutdown }
}
}

impl Drop for MockOrigin {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
// Unblock the accept() call so the thread can observe the shutdown flag.
let _ = TcpStream::connect(format!("127.0.0.1:{}", self.port));
}
}

/// Write a minimal HTTP/1.1 200 response with `body` to `stream`.
///
/// Drains the incoming request first so the client does not see a broken pipe.
fn serve(mut stream: TcpStream, body: &'static str) {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}",
len = body.len(),
);
let _ = stream.write_all(response.as_bytes());
}

/// Shared test state: mock origins + Viceroy process + pre-configured reqwest client.
///
/// Initialised once via [`get_harness`]. All five test functions share this
/// single instance to avoid the cost of spinning up Viceroy per test.
struct RoutingHarness {
_origins: Vec<MockOrigin>,
_process: common::runtime::RuntimeProcess,
/// Client with resolve overrides so `http://site-a.test/` connects to Viceroy
/// while sending the correct `Host` header.
client: reqwest::blocking::Client,
}

static HARNESS: OnceLock<Option<RoutingHarness>> = OnceLock::new();

/// Return the shared harness, or `None` if `ROUTING_WASM_PATH` is not set.
///
/// Returns `None` rather than panicking so that tests pass trivially when
/// invoked outside the routing-specific CI step (e.g. `cargo test --workspace`).
fn get_harness() -> Option<&'static RoutingHarness> {
HARNESS
.get_or_init(|| {
let wasm_path = std::env::var("ROUTING_WASM_PATH").ok()?;

let origins = vec![
MockOrigin::start(19090, "default"),
MockOrigin::start(19091, "site-a"),
MockOrigin::start(19092, "site-b"),
MockOrigin::start(19093, "api"),
];

let process = FastlyViceroy
.spawn(std::path::Path::new(&wasm_path))
.expect("should spawn Viceroy with routing WASM");

let viceroy_port: u16 = process
.base_url
.trim_start_matches("http://127.0.0.1:")
.parse()
.expect("should parse Viceroy port from base_url");

let viceroy_addr: std::net::SocketAddr = format!("127.0.0.1:{viceroy_port}")
.parse()
.expect("should parse Viceroy socket addr");

let client = reqwest::blocking::ClientBuilder::new()
.resolve("site-a.test", viceroy_addr)
.resolve("www.site-a.test", viceroy_addr)
.resolve("site-b.test", viceroy_addr)
.resolve("any.test", viceroy_addr)
.resolve("unknown.test", viceroy_addr)
.build()
.expect("should build reqwest client");

Some(RoutingHarness {
_origins: origins,
_process: process,
client,
})
})
.as_ref()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn domain_routes_to_site_a() {
let Some(h) = get_harness() else { return };

let body = h
.client
.get("http://site-a.test/")
.send()
.expect("should send request to site-a.test")
.text()
.expect("should read response body");

assert_eq!(body, "site-a", "should route site-a.test to the site-a backend");
}

#[test]
fn domain_routes_to_site_b() {
let Some(h) = get_harness() else { return };

let body = h
.client
.get("http://site-b.test/")
.send()
.expect("should send request to site-b.test")
.text()
.expect("should read response body");

assert_eq!(body, "site-b", "should route site-b.test to the site-b backend");
}

#[test]
fn www_prefix_stripped() {
let Some(h) = get_harness() else { return };

let body = h
.client
.get("http://www.site-a.test/")
.send()
.expect("should send request to www.site-a.test")
.text()
.expect("should read response body");

assert_eq!(
body, "site-a",
"should strip www. prefix and route to the site-a backend"
);
}

#[test]
fn path_routes_to_api() {
let Some(h) = get_harness() else { return };

// any.test has no domain entry — path pattern matching fires instead.
let body = h
.client
.get("http://any.test/.api/users")
.send()
.expect("should send request to any.test/.api/users")
.text()
.expect("should read response body");

assert_eq!(
body, "api",
"should route /.api/ path prefix to the api backend"
);
}

#[test]
fn unknown_host_falls_back_to_default() {
let Some(h) = get_harness() else { return };

let body = h
.client
.get("http://unknown.test/")
.send()
.expect("should send request to unknown.test")
.text()
.expect("should read response body");

assert_eq!(
body, "default",
"should fall back to publisher.origin_url for unmatched hosts"
);
}
21 changes: 21 additions & 0 deletions crates/trusted-server-adapter-fastly/test-backends.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Test-only backend routing config.
# Embedded at compile time when ROUTING_TEST_BACKENDS=1 is set.
# Ports 19090-19093 are used by MockOrigin servers in tests/routing.rs.

[[backends]]
id = "site-a"
origin_url = "http://127.0.0.1:19091"
domains = ["site-a.test", "www.site-a.test"]

[[backends]]
id = "site-b"
origin_url = "http://127.0.0.1:19092"
domains = ["site-b.test"]

[[backends]]
id = "api"
origin_url = "http://127.0.0.1:19093"

[[backends.path_patterns]]
host = "*"
path_prefix = "/.api/"
95 changes: 87 additions & 8 deletions crates/trusted-server-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,116 @@ mod consent_config;
#[path = "src/settings.rs"]
mod settings;

use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::path::Path;

const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml";
const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml";
const BACKENDS_CONFIG_PATH: &str = "../../crates/trusted-server-adapter-fastly/backends.toml";
const TEST_BACKENDS_CONFIG_PATH: &str =
"../../crates/trusted-server-adapter-fastly/test-backends.toml";

fn main() {
// Always rerun build.rs: integration settings are stored in a flat
// HashMap<String, JsonValue>, so we cannot enumerate all possible env
// var keys ahead of time. Emitting rerun-if-changed for a nonexistent
// file forces cargo to always rerun the build script.
println!("cargo:rerun-if-changed=_always_rebuild_sentinel_");
merge_toml();
rerun_if_changed();
}

fn rerun_if_changed() {
// Watch the root trusted-server.toml file for changes
println!("cargo:rerun-if-changed={}", TRUSTED_SERVER_INIT_CONFIG_PATH);
println!("cargo:rerun-if-changed={}", BACKENDS_CONFIG_PATH);
println!("cargo:rerun-if-changed={}", TEST_BACKENDS_CONFIG_PATH);
println!("cargo:rerun-if-env-changed=ROUTING_TEST_BACKENDS");

// Create a default Settings instance and convert to JSON to discover all fields
let default_settings = settings::Settings::default();
let settings_json = serde_json::to_value(&default_settings).unwrap();

let mut env_vars = HashSet::new();
collect_env_vars(&settings_json, &mut env_vars, &[]);

// Print rerun-if-env-changed for each variable
let mut sorted_vars: Vec<_> = env_vars.into_iter().collect();
sorted_vars.sort();

for var in sorted_vars {
println!("cargo:rerun-if-env-changed={}", var);
}
}

fn merge_toml() {
// Read init config
let init_config_path = Path::new(TRUSTED_SERVER_INIT_CONFIG_PATH);
let toml_content = fs::read_to_string(init_config_path)
.unwrap_or_else(|_| panic!("Failed to read {init_config_path:?}"));

// Merge base TOML with environment variable overrides and write output.
// Panics if admin endpoints are not covered by a handler.
let settings = settings::Settings::from_toml_and_env(&toml_content)
// Note: placeholder secret rejection is intentionally NOT done here.
// The base trusted-server.toml ships with placeholder secrets that
// production deployments override via TRUSTED_SERVER__* env vars at
// build time. Runtime startup (get_settings) rejects any remaining
// placeholders so a misconfigured deployment fails fast.
let mut settings = settings::Settings::from_toml_and_env(&toml_content)
.expect("Failed to parse settings at build time");

let merged_toml =
toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML");
// Merge optional customer-specific backends from
// crates/trusted-server-adapter-fastly/backends.toml, if present.
let backends_path = if std::env::var("ROUTING_TEST_BACKENDS").is_ok() {
Path::new(TEST_BACKENDS_CONFIG_PATH)
} else {
Path::new(BACKENDS_CONFIG_PATH)
};
if backends_path.exists() {
#[derive(serde::Deserialize)]
struct BackendsFile {
backends: Vec<settings::BackendRoutingConfig>,
}
let backends_toml = fs::read_to_string(backends_path)
.unwrap_or_else(|_| panic!("Failed to read {:?}", backends_path));
let backends_file: BackendsFile =
toml::from_str(&backends_toml).expect("Failed to parse backends.toml");
settings.backends.extend(backends_file.backends);
}

// Only write when content changes to avoid unnecessary recompilation.
let merged_toml =
toml::to_string_pretty(&settings).expect("Failed to serialize settings to TOML");
let dest_path = Path::new(TRUSTED_SERVER_OUTPUT_CONFIG_PATH);
let current = fs::read_to_string(dest_path).unwrap_or_default();
if current != merged_toml {
fs::write(dest_path, merged_toml)
.unwrap_or_else(|_| panic!("Failed to write {dest_path:?}"));
}
}

fn collect_env_vars(value: &Value, env_vars: &mut HashSet<String>, path: &[String]) {
if let Value::Object(map) = value {
for (key, val) in map {
let mut new_path = path.to_owned();
new_path.push(key.to_uppercase());

match val {
Value::String(_) | Value::Number(_) | Value::Bool(_) => {
// Leaf node - create environment variable
let env_var = format!(
"{}{}{}",
settings::ENVIRONMENT_VARIABLE_PREFIX,
settings::ENVIRONMENT_VARIABLE_SEPARATOR,
new_path.join(settings::ENVIRONMENT_VARIABLE_SEPARATOR)
);
env_vars.insert(env_var);
}
Value::Object(_) => {
// Recurse into nested objects
collect_env_vars(val, env_vars, &new_path);
}
// Arrays (e.g. `backends`) cannot be overridden per-element via env vars.
// Env overrides replace entire scalar fields; skip array values intentionally.
_ => {}
}
}
}
}
Loading
Loading