#![cfg(feature = "server")]
use axon::axon_server::{build_router, ServerConfig};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn server_cfg() -> ServerConfig {
ServerConfig {
host: "127.0.0.1".into(),
port: 0,
channel: "memory".into(),
auth_token: String::new(),
log_level: "INFO".into(),
log_format: "json".into(),
log_file: None,
database_url: None,
config_path: None,
strict_type_driven_transport: false,
default_backend: None,
schemas_dir: None,
}
}
async fn deploy(app: axum::Router, src: &str) {
let body = serde_json::json!({
"source": src,
"source_file": "test.axon",
"backend": "stub",
});
let req = Request::builder()
.method("POST")
.uri("/v1/deploy")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_eq!(status, StatusCode::OK, "deploy failed: {json}");
assert_eq!(
json.get("success").and_then(|v| v.as_bool()),
Some(true),
"deploy success=false: {json}"
);
}
fn jwt_with_caps(caps: &[&str]) -> String {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\",\"typ\":\"JWT\"}");
let payload_json = serde_json::json!({"capabilities": caps});
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
format!("{header}.{payload}.")
}
fn post_with(
path: &str,
body: &serde_json::Value,
bearer: Option<&str>,
) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json");
if let Some(b) = bearer {
builder = builder.header("authorization", format!("Bearer {b}"));
}
builder
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
}
const ADMIN_FLOW: &str =
"flow Touch() -> String { let result = \"ok\" return result }\n\
axonendpoint AdminEndpoint { method: POST path: \"/admin/policy\" \
execute: Touch requires: [admin] }";
const MULTI_CAP_FLOW: &str =
"flow Touch() -> String { let result = \"ok\" return result }\n\
axonendpoint LegalEndpoint { method: POST path: \"/legal/discovery\" \
execute: Touch requires: [legal.read, legal.write] }";
const PUBLIC_FLOW: &str =
"flow Touch() -> String { let result = \"ok\" return result }\n\
axonendpoint Pub { public: true method: POST path: \"/pub\" execute: Touch }";
#[tokio::test]
async fn no_requires_declared_passes_without_bearer_d9() {
let app = build_router(server_cfg());
deploy(app.clone(), PUBLIC_FLOW).await;
let resp = app
.oneshot(post_with("/pub", &serde_json::json!({}), None))
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_ne!(p["error"], "missing_capability");
}
#[tokio::test]
async fn bearer_with_required_capability_passes() {
let app = build_router(server_cfg());
deploy(app.clone(), ADMIN_FLOW).await;
let token = jwt_with_caps(&["admin"]);
let resp = app
.oneshot(post_with(
"/admin/policy",
&serde_json::json!({}),
Some(&token),
))
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_ne!(p["error"], "missing_capability");
}
#[tokio::test]
async fn bearer_without_required_capability_returns_403() {
let app = build_router(server_cfg());
deploy(app.clone(), ADMIN_FLOW).await;
let token = jwt_with_caps(&["legal.read"]);
let resp = app
.oneshot(post_with(
"/admin/policy",
&serde_json::json!({}),
Some(&token),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(p["error"], "missing_capability");
assert_eq!(p["d_letter"], "D8");
}
#[tokio::test]
async fn no_bearer_on_protected_endpoint_returns_403() {
let app = build_router(server_cfg());
deploy(app.clone(), ADMIN_FLOW).await;
let resp = app
.oneshot(post_with(
"/admin/policy",
&serde_json::json!({}),
None,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(p["error"], "missing_capability");
let missing = p["missing"].as_array().unwrap();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "admin");
}
#[tokio::test]
async fn multi_capability_requires_all_slugs() {
let app = build_router(server_cfg());
deploy(app.clone(), MULTI_CAP_FLOW).await;
let partial = jwt_with_caps(&["legal.read"]);
let resp = app
.clone()
.oneshot(post_with(
"/legal/discovery",
&serde_json::json!({}),
Some(&partial),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let missing: Vec<String> = p["missing"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(missing, vec!["legal.write".to_string()]);
let full = jwt_with_caps(&["legal.read", "legal.write"]);
let resp = app
.oneshot(post_with(
"/legal/discovery",
&serde_json::json!({}),
Some(&full),
))
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_ne!(p["error"], "missing_capability");
}
#[tokio::test]
async fn deny_payload_shape_is_locked() {
let app = build_router(server_cfg());
deploy(app.clone(), MULTI_CAP_FLOW).await;
let token = jwt_with_caps(&["other.cap"]);
let resp = app
.oneshot(post_with(
"/legal/discovery",
&serde_json::json!({}),
Some(&token),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(p["error"], "missing_capability");
assert!(p["missing"].is_array());
assert!(p["required"].is_array());
assert!(p["have"].is_array());
assert!(p["endpoint"].is_string());
assert!(p["method"].is_string());
assert!(p["path"].is_string());
assert!(p["hint"].is_string());
assert_eq!(p["d_letter"], "D8");
assert_eq!(p["required"][0], "legal.read");
assert_eq!(p["required"][1], "legal.write");
assert_eq!(p["have"][0], "other.cap");
}
#[tokio::test]
async fn auth_gate_fires_before_idempotency_lookup() {
let app = build_router(server_cfg());
deploy(app.clone(), ADMIN_FLOW).await;
let admin_token = jwt_with_caps(&["admin"]);
let mut req1 = post_with(
"/admin/policy",
&serde_json::json!({"op": "x"}),
Some(&admin_token),
);
req1.headers_mut()
.insert("idempotency-key", "shared-key".parse().unwrap());
let _ = app.clone().oneshot(req1).await.unwrap();
let mut req2 = post_with(
"/admin/policy",
&serde_json::json!({"op": "x"}),
None, );
req2.headers_mut()
.insert("idempotency-key", "shared-key".parse().unwrap());
let resp2 = app.oneshot(req2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::FORBIDDEN);
assert!(resp2.headers().get("idempotency-status").is_none());
}
#[tokio::test]
async fn invalid_capability_slug_rejected_at_parse_time() {
let app = build_router(server_cfg());
let src = "flow Touch() -> String { let result = \"ok\" return result }\n\
axonendpoint BadCap { method: POST path: \"/x\" execute: Touch \
requires: [Admin] }";
let body = serde_json::json!({
"source": src,
"source_file": "test.axon",
"backend": "stub",
});
let req = Request::builder()
.method("POST")
.uri("/v1/deploy")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
let success = json.get("success").and_then(|v| v.as_bool());
assert_eq!(
success,
Some(false),
"deploy must reject invalid slug 'Admin' at parse time, got success={success:?}"
);
}
#[tokio::test]
async fn v1_execute_legacy_unaffected_by_requires_gate() {
let app = build_router(server_cfg());
deploy(app.clone(), ADMIN_FLOW).await;
let body = serde_json::json!({"flow": "Touch", "backend": "stub"});
let req = Request::builder()
.method("POST")
.uri("/v1/execute")
.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(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_ne!(p["error"], "missing_capability");
}
#[tokio::test]
async fn body_validation_fires_before_auth_for_efficiency() {
let app = build_router(server_cfg());
let src = "type Req { amount: Integer }\n\
flow Touch() -> String { let result = \"ok\" return result }\n\
axonendpoint Gated { method: POST path: \"/gated\" execute: Touch \
body: Req requires: [admin] }";
deploy(app.clone(), src).await;
let resp = app
.oneshot(post_with(
"/gated",
&serde_json::json!({"amount": "not-int"}),
None,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let p: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(p["error"], "body_schema_violation");
}