use std::sync::Arc;
use axum::Json;
use axum::body::Body;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, State};
use axum::http::{Request, StatusCode, header};
use axum::middleware::Next;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use crate::error::ApiError;
use crate::http::NodeState;
pub async fn bearer_auth_middleware(
req: Request<Body>,
next: Next,
expected_token: String,
) -> impl IntoResponse {
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
match auth_header {
Some(value) if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") => {
let provided = &value[7..];
if bool::from(provided.as_bytes().ct_eq(expected_token.as_bytes())) {
next.run(req).await.into_response()
} else {
ApiError::unauthorized().into_response()
}
}
_ => ApiError::unauthorized().into_response(),
}
}
const ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
fn is_localhost_host(host: &str) -> bool {
let hostname = if host.starts_with('[') {
host.find(']').map_or(host, |end| &host[..=end])
} else {
host.split(':').next().unwrap_or(host)
};
ALLOWED_HOSTS
.iter()
.any(|h| hostname.eq_ignore_ascii_case(h))
}
pub async fn localhost_host_middleware(req: Request<Body>, next: Next) -> impl IntoResponse {
let host = req
.headers()
.get(header::HOST)
.and_then(|v| v.to_str().ok());
match host {
Some(h) if is_localhost_host(h) => next.run(req).await.into_response(),
_ => {
ApiError::forbidden("forbidden: dev API only accessible via localhost").into_response()
}
}
}
pub async fn security_headers_middleware(req: Request<Body>, next: Next) -> impl IntoResponse {
if req.method() == axum::http::Method::OPTIONS {
return ApiError::forbidden("forbidden: CORS requests not allowed on dev API")
.into_response();
}
let mut response = next.run(req).await;
let headers = response.headers_mut();
headers.insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
headers.insert(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
);
response
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthResponse {
pub uptime_seconds: u64,
pub relay_connections: u64,
pub storage_status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct IdentityResponse {
pub did: String,
pub document: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelayStatusResponse {
pub bound_addr: String,
pub active_connections: u64,
pub blob_count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContextResponse {
pub id: String,
pub name: Option<String>,
pub mode: String,
pub subscriber_count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateContextRequest {
pub id: String,
pub name: Option<String>,
}
pub async fn health_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
let uptime = state.start_time.elapsed().as_secs();
let relay_connections = {
let tracker = state.connection_tracker.read().await;
tracker.values().sum::<usize>() as u64
};
(
StatusCode::OK,
Json(HealthResponse {
uptime_seconds: uptime,
relay_connections,
storage_status: "ok".to_owned(),
}),
)
}
pub async fn identity_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
let document = serde_json::to_value(&state.did_document)
.unwrap_or_else(|_| serde_json::Value::String(state.did.clone()));
(
StatusCode::OK,
Json(IdentityResponse {
did: state.did.clone(),
document,
}),
)
}
pub async fn relay_status_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
use scp_transport::native::storage::BlobStorage as _;
let active_connections = {
let tracker = state.connection_tracker.read().await;
tracker.values().sum::<usize>() as u64
};
let blob_count = state.blob_storage.count().await.unwrap_or(0) as u64;
(
StatusCode::OK,
Json(RelayStatusResponse {
bound_addr: state.relay_addr.to_string(),
active_connections,
blob_count,
}),
)
}
async fn subscriber_count_for_context(state: &NodeState, hex_id: &str) -> u64 {
let Ok(bytes) = hex::decode(hex_id) else {
return 0;
};
let Ok(routing_id) = <[u8; 32]>::try_from(bytes) else {
return 0;
};
let registry = state.subscription_registry.read().await;
registry
.get(&routing_id)
.map_or(0, |entries| entries.len() as u64)
}
pub async fn list_contexts_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
let snapshot: Vec<(String, Option<String>)> = {
let contexts = state.broadcast_contexts.read().await;
contexts
.values()
.map(|ctx| (ctx.id.clone(), ctx.name.clone()))
.collect()
};
let mut responses = Vec::with_capacity(snapshot.len());
for (id, name) in snapshot {
let subscriber_count = subscriber_count_for_context(&state, &id).await;
responses.push(ContextResponse {
id,
name,
mode: "broadcast".to_owned(),
subscriber_count,
});
}
(StatusCode::OK, Json(responses))
}
pub async fn get_context_handler(
State(state): State<Arc<NodeState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let id = id.to_ascii_lowercase();
let ctx_data = {
let contexts = state.broadcast_contexts.read().await;
contexts
.get(&id)
.map(|ctx| (ctx.id.clone(), ctx.name.clone()))
};
match ctx_data {
None => ApiError::not_found(format!("context {id} not found")).into_response(),
Some((ctx_id, ctx_name)) => {
let subscriber_count = subscriber_count_for_context(&state, &ctx_id).await;
(
StatusCode::OK,
Json(ContextResponse {
id: ctx_id,
name: ctx_name,
mode: "broadcast".to_owned(),
subscriber_count,
}),
)
.into_response()
}
}
}
const MAX_CONTEXT_ID_LEN: usize = 64;
const MAX_CONTEXT_NAME_LEN: usize = 256;
pub async fn create_context_handler(
State(state): State<Arc<NodeState>>,
body: Result<Json<CreateContextRequest>, JsonRejection>,
) -> impl IntoResponse {
let Ok(Json(body)) = body else {
return ApiError::bad_request("invalid JSON body").into_response();
};
if body.id.is_empty() || body.id.len() > MAX_CONTEXT_ID_LEN {
return ApiError::bad_request(format!(
"context id must be 1-{MAX_CONTEXT_ID_LEN} characters"
))
.into_response();
}
if !body.id.bytes().all(|b| b.is_ascii_hexdigit()) {
return ApiError::bad_request("context id must contain only hex characters")
.into_response();
}
let id = body.id.to_ascii_lowercase();
if let Some(ref name) = body.name {
if name.chars().count() > MAX_CONTEXT_NAME_LEN {
return ApiError::bad_request(format!(
"context name must be at most {MAX_CONTEXT_NAME_LEN} characters"
))
.into_response();
}
if name.chars().any(char::is_control) {
return ApiError::bad_request("context name must not contain control characters")
.into_response();
}
}
let mut contexts = state.broadcast_contexts.write().await;
if contexts.contains_key(&id) {
return ApiError::conflict(format!("context {id} already exists")).into_response();
}
if contexts.len() >= crate::MAX_BROADCAST_CONTEXTS {
return ApiError::bad_request(format!(
"broadcast context limit ({}) reached",
crate::MAX_BROADCAST_CONTEXTS
))
.into_response();
}
let ctx = crate::http::BroadcastContext {
id: id.clone(),
name: body.name,
};
let response = ContextResponse {
id: ctx.id.clone(),
name: ctx.name.clone(),
mode: "broadcast".to_owned(),
subscriber_count: 0,
};
contexts.insert(id.clone(), ctx);
drop(contexts);
let location = format!("/scp/dev/v1/contexts/{id}");
let mut headers = axum::http::HeaderMap::new();
if let Ok(val) = axum::http::HeaderValue::from_str(&location) {
headers.insert(axum::http::header::LOCATION, val);
}
(StatusCode::CREATED, headers, Json(response)).into_response()
}
pub async fn delete_context_handler(
State(state): State<Arc<NodeState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let id = id.to_ascii_lowercase();
let mut contexts = state.broadcast_contexts.write().await;
if contexts.remove(&id).is_some() {
StatusCode::NO_CONTENT.into_response()
} else {
ApiError::not_found(format!("context {id} not found")).into_response()
}
}
const DEV_API_MAX_BODY_SIZE: usize = 64 * 1024;
pub fn dev_router(state: Arc<NodeState>, token: String) -> axum::Router {
use axum::middleware;
use axum::routing::get;
let expected = token;
axum::Router::new()
.route("/scp/dev/v1/health", get(health_handler))
.route("/scp/dev/v1/identity", get(identity_handler))
.route("/scp/dev/v1/relay/status", get(relay_status_handler))
.route(
"/scp/dev/v1/contexts",
get(list_contexts_handler).post(create_context_handler),
)
.route(
"/scp/dev/v1/contexts/{id}",
get(get_context_handler).delete(delete_context_handler),
)
.layer(axum::extract::DefaultBodyLimit::max(DEV_API_MAX_BODY_SIZE))
.layer(middleware::from_fn(move |req, next| {
bearer_auth_middleware(req, next, expected.clone())
}))
.layer(middleware::from_fn(localhost_host_middleware))
.layer(middleware::from_fn(security_headers_middleware))
.with_state(state)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use scp_transport::native::storage::BlobStorageBackend;
use tokio::sync::RwLock;
use tower::ServiceExt;
use crate::http::NodeState;
use super::*;
const TEST_TOKEN: &str = "scp_local_token_abcdef1234567890abcdef1234567890";
fn localhost_request() -> axum::http::request::Builder {
Request::builder().header(header::HOST, "localhost")
}
fn test_state(token: &str) -> Arc<NodeState> {
Arc::new(NodeState {
did: "did:dht:test123".to_owned(),
relay_url: "wss://localhost/scp/v1".to_owned(),
broadcast_contexts: RwLock::new(HashMap::new()),
relay_addr: "127.0.0.1:9000".parse::<SocketAddr>().unwrap(),
bridge_secret: zeroize::Zeroizing::new([0u8; 32]),
dev_token: Some(token.to_owned()),
dev_bind_addr: Some("127.0.0.1:9100".parse::<SocketAddr>().unwrap()),
projected_contexts: RwLock::new(HashMap::new()),
blob_storage: Arc::new(BlobStorageBackend::default()),
relay_config: scp_transport::native::server::RelayConfig::default(),
start_time: Instant::now(),
http_bind_addr: SocketAddr::from(([0, 0, 0, 0], 8443)),
shutdown_token: tokio_util::sync::CancellationToken::new(),
cors_origins: None,
projection_rate_limiter: scp_transport::relay::rate_limit::PublishRateLimiter::new(
1000,
),
tls_config: None,
cert_resolver: None,
did_document: scp_identity::document::DidDocument {
context: vec!["https://www.w3.org/ns/did/v1".to_owned()],
id: "did:dht:test123".to_owned(),
verification_method: vec![],
authentication: vec![],
assertion_method: vec![],
also_known_as: vec![],
service: vec![],
},
connection_tracker: scp_transport::relay::rate_limit::new_connection_tracker(),
subscription_registry: scp_transport::relay::subscription::new_registry(),
acme_challenges: None,
bridge_state: Arc::new(crate::bridge_handlers::BridgeState::new()),
})
}
#[tokio::test]
async fn valid_token_passes_middleware() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("uptime_seconds").is_some());
assert!(json.get("relay_connections").is_some());
assert!(json.get("storage_status").is_some());
}
#[tokio::test]
async fn invalid_token_returns_401() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, "Bearer wrong_token")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "unauthorized");
assert_eq!(json["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn non_localhost_host_returns_403() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = Request::builder()
.uri("/scp/dev/v1/health")
.header(header::HOST, "evil.example.com")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "FORBIDDEN");
}
#[tokio::test]
async fn ipv6_localhost_host_accepted() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = Request::builder()
.uri("/scp/dev/v1/health")
.header(header::HOST, "[::1]:8080")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn ipv6_localhost_host_without_port_accepted() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = Request::builder()
.uri("/scp/dev/v1/health")
.header(header::HOST, "[::1]")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn missing_header_returns_401() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "unauthorized");
assert_eq!(json["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn identity_handler_returns_did() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/identity")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["did"], "did:dht:test123");
assert!(json.get("document").is_some());
}
#[tokio::test]
async fn relay_status_handler_returns_addr() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/relay/status")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["bound_addr"], "127.0.0.1:9000");
assert_eq!(json["active_connections"], 0);
assert_eq!(json["blob_count"], 0);
}
#[tokio::test]
async fn all_responses_are_json_content_type() {
let token = TEST_TOKEN;
let state = test_state(token);
let paths = [
"/scp/dev/v1/health",
"/scp/dev/v1/identity",
"/scp/dev/v1/relay/status",
];
for path in paths {
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri(path)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "path: {path}");
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("missing Content-Type header")
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"path {path} has Content-Type: {content_type}"
);
}
}
#[tokio::test]
async fn list_contexts_returns_empty_array() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json, serde_json::json!([]));
}
#[tokio::test]
async fn list_contexts_returns_registered_contexts() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"aa11bb22".to_owned(),
crate::http::BroadcastContext {
id: "aa11bb22".to_owned(),
name: Some("Test Context".to_owned()),
},
);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "aa11bb22");
assert_eq!(arr[0]["name"], "Test Context");
assert_eq!(arr[0]["mode"], "broadcast");
assert_eq!(arr[0]["subscriber_count"], 0);
}
#[tokio::test]
async fn get_context_returns_found() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"abcdef01".to_owned(),
crate::http::BroadcastContext {
id: "abcdef01".to_owned(),
name: Some("My Context".to_owned()),
},
);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts/abcdef01")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], "abcdef01");
assert_eq!(json["name"], "My Context");
assert_eq!(json["mode"], "broadcast");
assert_eq!(json["subscriber_count"], 0);
}
#[tokio::test]
async fn get_context_returns_404_for_unknown() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts/nonexistent")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "NOT_FOUND");
}
#[tokio::test]
async fn create_context_returns_201() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"id": "cc33dd44",
"name": "New Context"
}))
.unwrap(),
))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], "cc33dd44");
assert_eq!(json["name"], "New Context");
assert_eq!(json["mode"], "broadcast");
assert_eq!(json["subscriber_count"], 0);
let contexts = state.broadcast_contexts.read().await;
assert_eq!(contexts.len(), 1);
assert!(contexts.contains_key("cc33dd44"));
drop(contexts);
}
#[tokio::test]
async fn create_context_without_name() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"ee55ff66"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], "ee55ff66");
assert!(json["name"].is_null());
}
#[tokio::test]
async fn delete_context_returns_204() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"d00aed".to_owned(),
crate::http::BroadcastContext {
id: "d00aed".to_owned(),
name: None,
},
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("DELETE")
.uri("/scp/dev/v1/contexts/d00aed")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert!(state.broadcast_contexts.read().await.is_empty());
}
#[tokio::test]
async fn delete_context_returns_404_for_unknown() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("DELETE")
.uri("/scp/dev/v1/contexts/nonexistent")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "NOT_FOUND");
}
#[tokio::test]
async fn context_endpoints_require_auth() {
let token = TEST_TOKEN;
let state = test_state(token);
let uris_and_methods: Vec<(&str, &str)> = vec![
("GET", "/scp/dev/v1/contexts"),
("GET", "/scp/dev/v1/contexts/any-id"),
("DELETE", "/scp/dev/v1/contexts/any-id"),
];
for (method, uri) in uris_and_methods {
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method(method)
.uri(uri)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"{method} {uri} should require auth"
);
}
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"aabb0011"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_context_rejects_non_hex_id() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"not-valid-hex!"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
}
#[tokio::test]
async fn create_context_rejects_empty_id() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":""}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_context_rejects_duplicate_id() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"aabb0011".to_owned(),
crate::http::BroadcastContext {
id: "aabb0011".to_owned(),
name: None,
},
);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"aabb0011"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "CONFLICT");
}
#[tokio::test]
async fn wrong_bearer_token_returns_401_with_error_shape() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, "Bearer wrong_token_here")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("missing Content-Type on 401")
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"401 response should be JSON, got: {content_type}"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "unauthorized");
assert_eq!(json["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn bearer_scheme_case_insensitive() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, format!("bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"lowercase 'bearer' should pass"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, format!("BEARER {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"uppercase 'BEARER' should pass"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, format!("BeArEr {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"mixed case 'BeArEr' should pass"
);
}
#[tokio::test]
async fn non_bearer_auth_scheme_returns_401() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn create_context_rejects_oversized_id() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let oversized_id = "a".repeat(MAX_CONTEXT_ID_LEN + 1);
let body_json = serde_json::json!({ "id": oversized_id });
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&body_json).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
assert!(
json["error"]
.as_str()
.unwrap()
.contains(&MAX_CONTEXT_ID_LEN.to_string()),
"error message should mention the max length"
);
}
#[tokio::test]
async fn create_context_rejects_oversized_name() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let oversized_name = "a".repeat(MAX_CONTEXT_NAME_LEN + 1);
let body_json = serde_json::json!({ "id": "aabb", "name": oversized_name });
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&body_json).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
assert!(
json["error"]
.as_str()
.unwrap()
.contains(&MAX_CONTEXT_NAME_LEN.to_string()),
"error message should mention the max length"
);
}
#[tokio::test]
async fn create_context_rejects_control_chars_in_name() {
let token = TEST_TOKEN;
let state = test_state(token);
let names_with_control = [
"name\x00with_null",
"name\x1fwith_unit_sep",
"\ttabbed",
"new\nline",
];
for bad_name in names_with_control {
let router = dev_router(Arc::clone(&state), token.to_owned());
let body_json = serde_json::json!({ "id": "aabb", "name": bad_name });
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&body_json).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"name with control char should be rejected: {bad_name:?}"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
}
}
#[tokio::test]
async fn malformed_json_returns_400_with_json_body() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id": 42}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("missing Content-Type on malformed JSON 400")
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"malformed JSON error should be JSON, got: {content_type}"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
assert!(
json.get("error").is_some(),
"error response must include 'error' field"
);
}
#[tokio::test]
async fn invalid_json_syntax_returns_400_with_json_body() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("not json at all"))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("missing Content-Type")
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"invalid JSON syntax error should be JSON, got: {content_type}"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "BAD_REQUEST");
}
#[tokio::test]
async fn context_id_normalized_to_lowercase() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"AABB","name":"Upper"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["id"], "aabb",
"created ID should be normalized to lowercase"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts/aabb")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"lowercase lookup should find it"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/contexts/AABB")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"uppercase lookup should also find it (normalized)"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("POST")
.uri("/scp/dev/v1/contexts")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":"aabb"}"#))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"lowercase duplicate of uppercase should conflict"
);
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.method("DELETE")
.uri("/scp/dev/v1/contexts/AaBb")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NO_CONTENT,
"mixed-case delete should find the normalized ID"
);
}
async fn assert_json_content_type(
state: &Arc<NodeState>,
token: &str,
method: &str,
path: &str,
body: Option<&str>,
expected_status: StatusCode,
desc: &str,
) {
let router = dev_router(Arc::clone(state), token.to_owned());
let mut builder = localhost_request()
.method(method)
.uri(path)
.header(header::AUTHORIZATION, format!("Bearer {token}"));
if body.is_some() {
builder = builder.header(header::CONTENT_TYPE, "application/json");
}
let req = builder
.body(body.map_or_else(Body::empty, |b| Body::from(b.to_owned())))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), expected_status, "{desc}: wrong status");
if expected_status != StatusCode::NO_CONTENT {
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.unwrap_or_else(|| panic!("{desc}: missing Content-Type header"))
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"{desc}: Content-Type should be JSON, got: {content_type}"
);
}
}
#[tokio::test]
async fn success_endpoints_return_json_content_type() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"deadbeef".to_owned(),
crate::http::BroadcastContext {
id: "deadbeef".to_owned(),
name: Some("Test".to_owned()),
},
);
let cases: &[(&str, &str, Option<&str>, StatusCode, &str)] = &[
(
"GET",
"/scp/dev/v1/health",
None,
StatusCode::OK,
"health 200",
),
(
"GET",
"/scp/dev/v1/identity",
None,
StatusCode::OK,
"identity 200",
),
(
"GET",
"/scp/dev/v1/relay/status",
None,
StatusCode::OK,
"relay status 200",
),
(
"GET",
"/scp/dev/v1/contexts",
None,
StatusCode::OK,
"list contexts 200",
),
(
"GET",
"/scp/dev/v1/contexts/deadbeef",
None,
StatusCode::OK,
"get context 200",
),
];
for &(method, path, body, expected_status, desc) in cases {
assert_json_content_type(&state, token, method, path, body, expected_status, desc)
.await;
}
}
#[tokio::test]
async fn error_and_create_endpoints_return_json_content_type() {
let token = TEST_TOKEN;
let state = test_state(token);
state.broadcast_contexts.write().await.insert(
"deadbeef".to_owned(),
crate::http::BroadcastContext {
id: "deadbeef".to_owned(),
name: Some("Test".to_owned()),
},
);
let error_cases: &[(&str, &str, Option<&str>, StatusCode, &str)] = &[
(
"GET",
"/scp/dev/v1/contexts/nonexistent",
None,
StatusCode::NOT_FOUND,
"get 404",
),
(
"DELETE",
"/scp/dev/v1/contexts/nonexistent",
None,
StatusCode::NOT_FOUND,
"del 404",
),
(
"POST",
"/scp/dev/v1/contexts",
Some(r#"{"id":""}"#),
StatusCode::BAD_REQUEST,
"empty 400",
),
(
"POST",
"/scp/dev/v1/contexts",
Some(r#"{"id":"deadbeef"}"#),
StatusCode::CONFLICT,
"dup 409",
),
];
for &(method, path, body, expected_status, desc) in error_cases {
assert_json_content_type(&state, token, method, path, body, expected_status, desc)
.await;
}
assert_json_content_type(
&state,
token,
"POST",
"/scp/dev/v1/contexts",
Some(r#"{"id":"cafe0001","name":"Test I"}"#),
StatusCode::CREATED,
"create 201",
)
.await;
let router = dev_router(Arc::clone(&state), token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "unauth 401");
let content_type = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("unauth 401: missing Content-Type")
.to_str()
.unwrap();
assert!(
content_type.contains("application/json"),
"unauth 401: Content-Type should be JSON, got: {content_type}"
);
}
#[tokio::test]
async fn responses_include_security_headers() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff"
);
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
assert_eq!(resp.headers().get(header::X_FRAME_OPTIONS).unwrap(), "DENY");
}
#[tokio::test]
async fn security_headers_on_error_responses() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = localhost_request()
.uri("/scp/dev/v1/health")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers().get(header::X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff"
);
}
#[tokio::test]
async fn cors_preflight_rejected() {
let token = TEST_TOKEN;
let state = test_state(token);
let router = dev_router(state, token.to_owned());
let req = Request::builder()
.method(axum::http::Method::OPTIONS)
.uri("/scp/dev/v1/health")
.header(header::HOST, "localhost")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], "FORBIDDEN");
}
}