use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use tower_http::limit::RequestBodyLimitLayer;
use zeph_common::http_middleware::{
AuthConfig, RateLimitState, auth_middleware, rate_limit_middleware,
};
use super::handlers::{agent_card_handler, jsonrpc_handler, stream_handler};
use super::state::AppState;
use crate::ibct::{Ibct, IbctKey};
#[cfg(test)]
const DEFAULT_MAX_BODY_SIZE: usize = 1024 * 1024;
const DEFAULT_IBCT_BODY_PEEK: usize = 8 * 1024 * 1024;
#[derive(Clone)]
pub struct IbctConfig {
keys: Arc<Vec<IbctKey>>,
endpoint: Arc<str>,
max_body_size: usize,
}
impl IbctConfig {
#[must_use]
pub fn new(keys: Vec<IbctKey>, endpoint: impl Into<Arc<str>>, max_body_size: usize) -> Self {
Self {
keys: Arc::new(keys),
endpoint: endpoint.into(),
max_body_size,
}
}
fn is_enforced(&self) -> bool {
!self.keys.is_empty()
}
}
impl Default for IbctConfig {
fn default() -> Self {
Self::new(Vec::new(), "", DEFAULT_IBCT_BODY_PEEK)
}
}
#[tracing::instrument(skip_all, name = "a2a.server.ibct")]
pub async fn ibct_middleware(
axum::extract::State(cfg): axum::extract::State<IbctConfig>,
req: Request<Body>,
next: Next,
) -> Response {
if !cfg.is_enforced() {
return next.run(req).await;
}
let header = req
.headers()
.get("x-zeph-ibct")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let Some(header) = header else {
tracing::warn!("a2a ibct: missing X-Zeph-IBCT header while ibct_keys is configured");
return StatusCode::UNAUTHORIZED.into_response();
};
let token = match Ibct::decode(&header) {
Ok(t) => t,
Err(e) => {
tracing::warn!("a2a ibct: failed to decode X-Zeph-IBCT header: {e}");
return StatusCode::UNAUTHORIZED.into_response();
}
};
let (parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, cfg.max_body_size).await {
Ok(b) => b,
Err(e) => {
tracing::warn!("a2a ibct: failed to buffer request body: {e}");
return StatusCode::BAD_REQUEST.into_response();
}
};
let task_id = extract_task_id(&bytes);
if let Err(e) = token.verify(&cfg.keys, &cfg.endpoint, &task_id) {
tracing::warn!("a2a ibct: verification failed: {e}");
return StatusCode::FORBIDDEN.into_response();
}
let req = Request::from_parts(parts, Body::from(bytes));
next.run(req).await
}
fn extract_task_id(bytes: &[u8]) -> String {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return String::new();
};
let Some(params) = value.get("params") else {
return String::new();
};
let is_task_id_method = matches!(
value.get("method").and_then(serde_json::Value::as_str),
Some("tasks/get" | "tasks/cancel")
);
if is_task_id_method {
return params
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.unwrap_or_default();
}
params
.get("message")
.and_then(|m| m.get("taskId"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.unwrap_or_default()
}
#[cfg(test)]
pub fn build_router_with_config(
state: AppState,
auth_token: Option<&str>,
rate_limit: u32,
) -> Router {
build_router_with_full_config(
state,
AuthConfig::new(auth_token, false),
rate_limit,
DEFAULT_MAX_BODY_SIZE,
IbctConfig::default(),
)
}
pub fn build_router_with_full_config(
state: AppState,
auth_cfg: AuthConfig,
rate_limit: u32,
max_body_size: usize,
ibct_cfg: IbctConfig,
) -> Router {
let rate_state = RateLimitState::new(rate_limit, &[]);
let protected = Router::new()
.route("/a2a", post(jsonrpc_handler))
.route("/a2a/stream", post(stream_handler))
.layer(middleware::from_fn_with_state(ibct_cfg, ibct_middleware))
.layer(middleware::from_fn_with_state(auth_cfg, auth_middleware))
.layer(middleware::from_fn_with_state(
rate_state,
rate_limit_middleware,
))
.layer(RequestBodyLimitLayer::new(max_body_size));
Router::new()
.route("/.well-known/agent.json", get(agent_card_handler))
.merge(protected)
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::body::Body;
use tokio::sync::Mutex;
use tower::ServiceExt;
use zeph_common::http_middleware::{MAX_RATE_LIMIT_ENTRIES, RATE_WINDOW};
use super::*;
use crate::server::testing::test_state;
#[tokio::test]
async fn auth_allows_valid_token() {
let app = build_router_with_config(test_state(), Some("secret-token"), 0);
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": "1",
"method": "tasks/get",
"params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("authorization", "Bearer secret-token")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn auth_rejects_missing_token() {
let app = build_router_with_config(test_state(), Some("secret-token"), 0);
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": "1",
"method": "tasks/get",
"params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn auth_rejects_wrong_token() {
let app = build_router_with_config(test_state(), Some("secret-token"), 0);
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": "1",
"method": "tasks/get",
"params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("authorization", "Bearer wrong-token")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn agent_card_skips_auth() {
let app = build_router_with_config(test_state(), Some("secret-token"), 0);
let req = axum::http::Request::builder()
.uri("/.well-known/agent.json")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn no_auth_when_token_unset() {
let app = build_router_with_config(test_state(), None, 0);
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": "1",
"method": "tasks/get",
"params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn body_size_limit() {
let app = build_router_with_config(test_state(), None, 0);
let oversized = vec![b'a'; DEFAULT_MAX_BODY_SIZE + 1];
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(oversized))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 413);
}
#[tokio::test]
async fn auth_rejects_bearer_prefix_only() {
let app = build_router_with_config(test_state(), Some("secret"), 0);
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("authorization", "Bearer ")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn auth_rejects_non_bearer_scheme() {
let app = build_router_with_config(test_state(), Some("secret"), 0);
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("authorization", "Basic c2VjcmV0")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn rate_limit_rejects_after_exceeding() {
use tower::Service;
let state = test_state();
let mut app = build_router_with_config(state, None, 2);
let make_req = || {
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
};
let resp = app.call(make_req()).await.unwrap();
assert_eq!(resp.status(), 200, "request 1 should pass");
let resp = app.call(make_req()).await.unwrap();
assert_eq!(resp.status(), 200, "request 2 should pass");
let resp = app.call(make_req()).await.unwrap();
assert_eq!(resp.status(), 429, "request 3 should be rate-limited");
}
#[tokio::test]
async fn failed_auth_requests_are_rate_limited() {
use tower::Service;
let mut app = build_router_with_config(test_state(), Some("secret-token"), 2);
let make_req = || {
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("authorization", "Bearer wrong-token")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
};
let resp = app.call(make_req()).await.unwrap();
assert_eq!(resp.status(), 401);
let resp = app.call(make_req()).await.unwrap();
assert_eq!(resp.status(), 401);
let resp = app.call(make_req()).await.unwrap();
assert_eq!(
resp.status(),
429,
"third failed-auth request from the same IP must be rate-limited"
);
}
fn ip_from_index(i: usize) -> IpAddr {
IpAddr::V4(std::net::Ipv4Addr::new(
u8::try_from((i >> 16) & 0xFF).unwrap(),
u8::try_from((i >> 8) & 0xFF).unwrap(),
u8::try_from(i & 0xFF).unwrap(),
1,
))
}
#[tokio::test]
async fn max_entries_cap_rejects_when_all_entries_fresh() {
let counters = Arc::new(Mutex::new(HashMap::new()));
{
let mut map = counters.lock().await;
let fresh = Instant::now();
for i in 0..MAX_RATE_LIMIT_ENTRIES {
let ip = ip_from_index(i);
map.insert(ip, (1, fresh));
}
assert_eq!(map.len(), MAX_RATE_LIMIT_ENTRIES);
}
let new_ip = IpAddr::V4(std::net::Ipv4Addr::BROADCAST);
let now = Instant::now();
let mut map = counters.lock().await;
let before = map.len();
map.retain(|_, (_, ts)| now.duration_since(*ts) < RATE_WINDOW);
let after = map.len();
assert_eq!(after, before, "retain must preserve fresh entries");
assert!(
after >= MAX_RATE_LIMIT_ENTRIES && !map.contains_key(&new_ip),
"new IP should be rejected when map is still at capacity after eviction"
);
}
#[tokio::test]
async fn max_entries_cap_allows_after_stale_eviction() {
let counters = Arc::new(Mutex::new(HashMap::new()));
{
let mut map = counters.lock().await;
let stale = Instant::now().checked_sub(Duration::from_mins(2)).unwrap();
for i in 0..MAX_RATE_LIMIT_ENTRIES {
let ip = ip_from_index(i);
map.insert(ip, (1, stale));
}
}
let now = Instant::now();
let mut map = counters.lock().await;
map.retain(|_, (_, ts)| now.duration_since(*ts) < RATE_WINDOW);
assert_eq!(map.len(), 0, "stale entries must be evicted by retain");
}
#[tokio::test]
async fn eviction_removes_stale_entries() {
let counters = Arc::new(Mutex::new(HashMap::new()));
let stale_time = Instant::now().checked_sub(Duration::from_mins(2)).unwrap();
let fresh_time = Instant::now();
let stale_ip = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1));
let fresh_ip = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2));
{
let mut map = counters.lock().await;
map.insert(stale_ip, (5, stale_time));
map.insert(fresh_ip, (3, fresh_time));
}
let now = Instant::now();
let mut map = counters.lock().await;
map.retain(|_, (_, ts)| now.duration_since(*ts) < RATE_WINDOW);
assert!(
!map.contains_key(&stale_ip),
"stale entry should be evicted"
);
assert!(map.contains_key(&fresh_ip), "fresh entry should remain");
}
#[tokio::test]
async fn require_auth_rejects_when_no_token_configured() {
let app = build_router_with_full_config(
test_state(),
AuthConfig::new(None, true),
0,
DEFAULT_MAX_BODY_SIZE,
IbctConfig::default(),
);
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn build_router_with_rate_limit_succeeds() {
let _router = build_router_with_full_config(
test_state(),
AuthConfig::new(None, false),
5,
1024 * 1024,
IbctConfig::default(),
);
}
#[tokio::test]
async fn require_auth_false_allows_unauthenticated() {
let app = build_router_with_full_config(
test_state(),
AuthConfig::new(None, false),
0,
DEFAULT_MAX_BODY_SIZE,
IbctConfig::default(),
);
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "x"}
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
}
}
#[cfg(all(test, feature = "ibct"))]
mod ibct_middleware_tests {
use axum::Router;
use axum::body::Body;
use std::time::Duration;
use tower::ServiceExt;
use super::{AuthConfig, DEFAULT_MAX_BODY_SIZE, IbctConfig, build_router_with_full_config};
use crate::ibct::{Ibct, IbctKey, ibct_scope_origin};
use crate::server::testing::test_state;
const TEST_ENDPOINT: &str = "http://localhost:8080";
fn test_key() -> IbctKey {
IbctKey {
key_id: "k1".into(),
key_bytes: b"router-test-secret-key".to_vec(),
}
}
fn ibct_app(keys: Vec<IbctKey>) -> Router {
build_router_with_full_config(
test_state(),
AuthConfig::new(None, false),
0,
DEFAULT_MAX_BODY_SIZE,
IbctConfig::new(keys, TEST_ENDPOINT, DEFAULT_MAX_BODY_SIZE),
)
}
fn get_task_request(header: Option<&str>) -> axum::http::Request<Body> {
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": {"id": "task-1"}
});
let mut builder = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json");
if let Some(h) = header {
builder = builder.header("x-zeph-ibct", h);
}
builder
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
#[tokio::test]
async fn no_keys_configured_bypasses_ibct_check() {
let app = ibct_app(vec![]);
let resp = app.oneshot(get_task_request(None)).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn missing_header_rejected_when_configured() {
let app = ibct_app(vec![test_key()]);
let resp = app.oneshot(get_task_request(None)).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn malformed_header_rejected() {
let app = ibct_app(vec![test_key()]);
let resp = app
.oneshot(get_task_request(Some("not-valid-base64-json")))
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn valid_header_with_matching_task_id_accepted() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let token = Ibct::issue("task-1", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn tampered_signature_rejected() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let mut token = Ibct::issue("task-1", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
token.signature = "deadbeef".repeat(8);
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(resp.status(), 403);
}
#[tokio::test]
async fn wrong_task_id_rejected() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let token = Ibct::issue("task-999", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(resp.status(), 403);
}
#[tokio::test]
async fn wrong_endpoint_rejected() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let token = Ibct::issue(
"task-1",
"http://evil.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(resp.status(), 403);
}
#[tokio::test]
async fn unknown_key_id_rejected() {
let app = ibct_app(vec![test_key()]);
let other_key = IbctKey {
key_id: "k99".into(),
key_bytes: b"other-secret".to_vec(),
};
let token =
Ibct::issue("task-1", TEST_ENDPOINT, Duration::from_mins(5), &other_key).unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(resp.status(), 403);
}
#[tokio::test]
async fn new_task_send_message_matches_empty_sentinel() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let token = Ibct::issue("", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "message/send",
"params": { "message": { "role": "user", "parts": [{"kind": "text", "text": "hi"}] } }
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("x-zeph-ibct", token.encode().unwrap())
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn stream_endpoint_enforces_ibct_too() {
let key = test_key();
let app = ibct_app(vec![key]);
let body = serde_json::json!({
"params": { "message": { "role": "user", "parts": [{"kind": "text", "text": "hi"}] } }
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a/stream")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn pathful_endpoint_scope_is_rejected() {
let key = test_key();
let app = ibct_app(vec![key.clone()]);
let pathful_endpoint = format!("{TEST_ENDPOINT}/a2a");
let token = Ibct::issue("task-1", &pathful_endpoint, Duration::from_mins(5), &key).unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(
resp.status(),
403,
"a token scoped to a pathful endpoint must not match the server's pathless card.url"
);
}
#[tokio::test]
async fn origin_scoped_token_accepted_on_both_routes() {
let key = test_key();
let get_app = ibct_app(vec![key.clone()]);
let token_a2a = Ibct::issue("task-1", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let resp = get_app
.oneshot(get_task_request(Some(&token_a2a.encode().unwrap())))
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"/a2a must accept an origin-scoped token"
);
let stream_app = ibct_app(vec![key.clone()]);
let token_stream = Ibct::issue("", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let stream_body = serde_json::json!({
"params": { "message": { "role": "user", "parts": [{"kind": "text", "text": "hi"}] } }
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a/stream")
.header("content-type", "application/json")
.header("x-zeph-ibct", token_stream.encode().unwrap())
.body(Body::from(serde_json::to_vec(&stream_body).unwrap()))
.unwrap();
let resp = stream_app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
200,
"/a2a/stream must accept the SAME origin-scoped endpoint value as /a2a"
);
}
#[tokio::test]
async fn ibct_body_peek_respects_configured_max_body_size() {
let key = test_key();
let small_max_body_size = 64;
let app = build_router_with_full_config(
test_state(),
AuthConfig::new(None, false),
0,
1024 * 1024,
IbctConfig::new(vec![key.clone()], TEST_ENDPOINT, small_max_body_size),
);
let token = Ibct::issue("task-1", TEST_ENDPOINT, Duration::from_mins(5), &key).unwrap();
let oversized_params = serde_json::json!({"id": "x".repeat(200)});
let body = serde_json::json!({
"jsonrpc": "2.0", "id": "1",
"method": "tasks/get", "params": oversized_params
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/a2a")
.header("content-type", "application/json")
.header("x-zeph-ibct", token.encode().unwrap())
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
400,
"ibct_middleware's to_bytes must be bounded by IbctConfig::max_body_size"
);
}
#[tokio::test]
async fn non_canonical_card_url_still_matches_client_issued_token() {
let key = test_key();
let client_endpoint = ibct_scope_origin("http://localhost:8080/a2a");
for non_canonical_card_url in [
"http://localhost:8080/", "http://localhost:8080/a2a", "HTTP://LOCALHOST:8080", ] {
let normalized_card_url = ibct_scope_origin(non_canonical_card_url);
assert_eq!(
normalized_card_url, TEST_ENDPOINT,
"ibct_scope_origin({non_canonical_card_url:?}) should normalize to {TEST_ENDPOINT:?}"
);
let app = build_router_with_full_config(
test_state(),
AuthConfig::new(None, false),
0,
DEFAULT_MAX_BODY_SIZE,
IbctConfig::new(
vec![key.clone()],
normalized_card_url,
DEFAULT_MAX_BODY_SIZE,
),
);
let token =
Ibct::issue("task-1", &client_endpoint, Duration::from_mins(5), &key).unwrap();
let resp = app
.oneshot(get_task_request(Some(&token.encode().unwrap())))
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"non-canonical card.url {non_canonical_card_url:?} must still match an \
origin-scoped client token after normalization"
);
}
}
}