use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tensor_wasm_api::{
build_router_with_config, AppState, AuthConfig, TenantConfig, TokenScope, HEADER_TENANT,
};
use tensor_wasm_core::types::TenantId;
use tower::ServiceExt;
async fn body_json(body: Body) -> Value {
let bytes = body.collect().await.expect("body").to_bytes().to_vec();
serde_json::from_slice(&bytes).expect("body is JSON")
}
fn router_with_scopes(scopes: &[(&str, TokenScope)]) -> axum::Router {
let mut map: HashMap<String, TokenScope> = HashMap::new();
for (k, v) in scopes {
map.insert((*k).to_owned(), v.clone());
}
let auth = AuthConfig::from_scopes(map);
build_router_with_config(Arc::new(AppState::default()), auth, TenantConfig::default())
}
fn trivial_wasm_b64() -> String {
let wasm_bytes = wat::parse_str(r#"(module (func (export "_start")))"#).expect("WAT parses");
BASE64.encode(&wasm_bytes)
}
async fn deploy_as(router: &axum::Router, bearer: &str, tenant: u64) -> String {
let req = Request::builder()
.method(Method::POST)
.uri("/functions")
.header("authorization", format!("Bearer {bearer}"))
.header("content-type", "application/json")
.header(HEADER_TENANT, tenant.to_string())
.body(Body::from(
serde_json::to_vec(&json!({ "name": "t", "wasm_b64": trivial_wasm_b64() })).unwrap(),
))
.unwrap();
let resp = router.clone().oneshot(req).await.expect("deploy");
assert_eq!(resp.status(), StatusCode::OK, "deploy must succeed");
body_json(resp.into_body())
.await
.get("id")
.and_then(Value::as_str)
.map(str::to_owned)
.expect("id")
}
#[tokio::test]
async fn create_function_rejects_other_tenant_with_scoped_token() {
let router = router_with_scopes(&[("alpha", TokenScope::from_tenants([TenantId(1)]))]);
let req = Request::builder()
.method(Method::POST)
.uri("/functions")
.header("authorization", "Bearer alpha")
.header("content-type", "application/json")
.header(HEADER_TENANT, "2")
.body(Body::from(
serde_json::to_vec(&json!({ "name": "t", "wasm_b64": trivial_wasm_b64() })).unwrap(),
))
.unwrap();
let resp = router.oneshot(req).await.expect("create");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
}
#[tokio::test]
async fn delete_function_rejects_cross_tenant() {
let router = router_with_scopes(&[("wild", TokenScope::all())]);
let id = deploy_as(&router, "wild", 1).await;
let req = Request::builder()
.method(Method::DELETE)
.uri(format!("/functions/{id}"))
.header("authorization", "Bearer wild")
.header(HEADER_TENANT, "2")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.expect("delete");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
let req = Request::builder()
.method(Method::DELETE)
.uri(format!("/functions/{id}"))
.header("authorization", "Bearer wild")
.header(HEADER_TENANT, "1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.expect("delete-correct");
assert_eq!(
resp.status(),
StatusCode::NO_CONTENT,
"owning tenant must still be able to delete",
);
}
#[tokio::test]
async fn invoke_function_rejects_cross_tenant() {
let router = router_with_scopes(&[("wild", TokenScope::all())]);
let id = deploy_as(&router, "wild", 1).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{id}/invoke"))
.header("authorization", "Bearer wild")
.header("content-type", "application/json")
.header(HEADER_TENANT, "2")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.expect("invoke");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
}
#[tokio::test]
async fn invoke_function_async_rejects_cross_tenant() {
let router = router_with_scopes(&[("wild", TokenScope::all())]);
let id = deploy_as(&router, "wild", 1).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{id}/invoke-async"))
.header("authorization", "Bearer wild")
.header("content-type", "application/json")
.header(HEADER_TENANT, "2")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.expect("invoke-async");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
}
#[tokio::test]
async fn token_scope_layer_runs_before_resource_check() {
let router = router_with_scopes(&[("alpha", TokenScope::from_tenants([TenantId(1)]))]);
let id = deploy_as(&router, "alpha", 1).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{id}/invoke"))
.header("authorization", "Bearer alpha")
.header("content-type", "application/json")
.header(HEADER_TENANT, "3")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.clone().oneshot(req).await.expect("invoke");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
let unknown_id = "00000000-0000-0000-0000-000000000000";
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{unknown_id}/invoke"))
.header("authorization", "Bearer alpha")
.header("content-type", "application/json")
.header(HEADER_TENANT, "3")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.oneshot(req).await.expect("invoke unknown");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"out-of-scope caller must not be able to distinguish 404 from 403",
);
let body = body_json(resp.into_body()).await;
assert_eq!(
body.pointer("/error/kind").and_then(Value::as_str),
Some("tenant_scope_denied"),
"got {body}",
);
}
#[tokio::test]
async fn same_tenant_operations_still_succeed() {
let router = router_with_scopes(&[("wild", TokenScope::all())]);
let id = deploy_as(&router, "wild", 7).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{id}/invoke"))
.header("authorization", "Bearer wild")
.header("content-type", "application/json")
.header(HEADER_TENANT, "7")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.clone().oneshot(req).await.expect("invoke");
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::POST)
.uri(format!("/functions/{id}/invoke-async"))
.header("authorization", "Bearer wild")
.header("content-type", "application/json")
.header(HEADER_TENANT, "7")
.body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
.unwrap();
let resp = router.clone().oneshot(req).await.expect("invoke-async");
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let req = Request::builder()
.method(Method::DELETE)
.uri(format!("/functions/{id}"))
.header("authorization", "Bearer wild")
.header(HEADER_TENANT, "7")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.expect("delete");
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}