896fcecbc1
集中装配后台、External API 与编辑器受保护路由并保持既有方法和请求大小限制 将项目元数据及 External/MCP 鉴权迁移为组合根装配的窄依赖并补齐替代依赖测试 使用 RAII 修复请求取消和 panic 的在途计数并统一 HTTP 路由模板追踪 补齐 LLM 普通与流式调用的安全异步追踪和本地 Provider 回归 同步两处依赖锁文件、后端规范、运维指标合同及团队协作约定
672 lines
24 KiB
Rust
672 lines
24 KiB
Rust
use axum::{
|
|
Router,
|
|
extract::{DefaultBodyLimit, Request},
|
|
handler::Handler,
|
|
http::{HeaderValue, header},
|
|
middleware,
|
|
middleware::Next,
|
|
response::Response,
|
|
routing::{get, patch, post},
|
|
};
|
|
|
|
use crate::{
|
|
auth::require_bearer_auth,
|
|
editor_agent::api::editor_agent_message,
|
|
editor_agent::{
|
|
cancel_editor_agent_tool_call, confirm_editor_agent_tool_call,
|
|
create_editor_agent_conversation, delete_editor_agent_conversation,
|
|
get_editor_agent_conversation, list_editor_agent_conversations,
|
|
},
|
|
editor_project::{
|
|
EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, create_editor_asset, create_editor_asset_folder,
|
|
create_editor_project, create_editor_project_resource, delete_editor_asset,
|
|
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
|
|
extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene,
|
|
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
|
|
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
|
|
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
|
|
snap_editor_image_to_pixel_art, submit_editor_asset_showcase,
|
|
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
|
|
update_editor_project_resource_showcase,
|
|
},
|
|
editor_project_icon::{
|
|
generate_editor_icon_spritesheet, generate_icon_spec, split_editor_icon_spritesheet,
|
|
},
|
|
llm::icon_specs::{refine_art_style, refine_game_play},
|
|
state::AppState,
|
|
};
|
|
|
|
const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
|
|
|
|
async fn add_showcase_private_response_headers(request: Request, next: Next) -> Response {
|
|
let mut response = next.run(request).await;
|
|
response.headers_mut().insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("private, no-store"),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.append(header::VARY, HeaderValue::from_static("Authorization"));
|
|
response
|
|
}
|
|
|
|
pub fn router(state: AppState) -> Router<AppState> {
|
|
let auth = middleware::from_fn_with_state(state, require_bearer_auth);
|
|
let protected_routes = [
|
|
(
|
|
"/api/editor/projects/recent",
|
|
get(load_recent_editor_project),
|
|
),
|
|
(
|
|
"/api/editor/projects",
|
|
get(list_editor_projects).post(create_editor_project),
|
|
),
|
|
(
|
|
"/api/editor/projects/{project_id}",
|
|
get(get_editor_project)
|
|
.patch(
|
|
save_editor_project_layout
|
|
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)),
|
|
)
|
|
.delete(delete_editor_project),
|
|
),
|
|
(
|
|
"/api/editor/projects/{project_id}/metadata",
|
|
patch(rename_editor_project),
|
|
),
|
|
(
|
|
"/api/editor/projects/{project_id}/resources",
|
|
post(create_editor_project_resource),
|
|
),
|
|
(
|
|
"/api/editor/projects/{project_id}/agent-conversations",
|
|
get(list_editor_agent_conversations).post(create_editor_agent_conversation),
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}",
|
|
get(get_editor_agent_conversation).delete(delete_editor_agent_conversation),
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages",
|
|
post(editor_agent_message)
|
|
.layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)),
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm",
|
|
post(confirm_editor_agent_tool_call),
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel",
|
|
post(cancel_editor_agent_tool_call),
|
|
),
|
|
(
|
|
"/api/editor/project-resources/{resource_id}/showcase",
|
|
patch(update_editor_project_resource_showcase),
|
|
),
|
|
("/api/editor/assets/library", get(get_editor_asset_library)),
|
|
(
|
|
"/api/editor/assets/folders",
|
|
post(create_editor_asset_folder),
|
|
),
|
|
(
|
|
"/api/editor/assets/folders/{folder_id}",
|
|
patch(update_editor_asset_folder).delete(delete_editor_asset_folder),
|
|
),
|
|
("/api/editor/assets", post(create_editor_asset)),
|
|
(
|
|
"/api/editor/assets/{asset_id}",
|
|
patch(update_editor_asset).delete(delete_editor_asset),
|
|
),
|
|
(
|
|
"/api/editor/assets/{asset_id}/showcase-submissions",
|
|
post(submit_editor_asset_showcase),
|
|
),
|
|
(
|
|
"/api/editor/images/generations",
|
|
post(generate_editor_image),
|
|
),
|
|
(
|
|
"/api/editor/scenes/generations",
|
|
post(generate_editor_scene),
|
|
),
|
|
(
|
|
"/api/editor/llm/icon-specs/refine-game-play",
|
|
post(refine_game_play),
|
|
),
|
|
(
|
|
"/api/editor/llm/icon-specs/refine-art-style",
|
|
post(refine_art_style),
|
|
),
|
|
(
|
|
"/api/editor/icon-specs/generations",
|
|
post(generate_icon_spec),
|
|
),
|
|
("/api/editor/images/edits", post(edit_editor_image)),
|
|
(
|
|
"/api/editor/images/background-removals",
|
|
post(remove_editor_image_background),
|
|
),
|
|
(
|
|
"/api/editor/images/pixel-art-snaps",
|
|
post(snap_editor_image_to_pixel_art),
|
|
),
|
|
(
|
|
"/api/editor/icon-spritesheets/generations",
|
|
post(generate_editor_icon_spritesheet),
|
|
),
|
|
(
|
|
"/api/editor/icon-spritesheets/slices",
|
|
post(split_editor_icon_spritesheet),
|
|
),
|
|
(
|
|
"/api/editor/ui-designs/assets/extractions",
|
|
post(extract_editor_ui_design_assets),
|
|
),
|
|
];
|
|
// 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。
|
|
let protected_router = protected_routes
|
|
.into_iter()
|
|
.fold(Router::new(), |router, (path, methods)| {
|
|
router.route(path, methods.route_layer(auth.clone()))
|
|
});
|
|
// 点赞拒绝响应也必须带私有缓存头,保持该层位于鉴权外侧。
|
|
let showcase_likes_router = Router::new().route(
|
|
"/api/editor/showcase/assets/{showcase_id}/likes",
|
|
post(toggle_editor_showcase_asset_like)
|
|
.route_layer(auth)
|
|
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
|
|
);
|
|
|
|
Router::new()
|
|
.route(
|
|
"/api/editor/generation-pricing",
|
|
get(get_editor_generation_pricing),
|
|
)
|
|
.route(
|
|
"/api/editor/showcase/resources",
|
|
get(list_public_editor_project_resources)
|
|
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
|
|
)
|
|
.merge(protected_router)
|
|
.merge(showcase_likes_router)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::{
|
|
Router,
|
|
body::Body,
|
|
http::{Request, header},
|
|
middleware,
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
};
|
|
use tower::ServiceExt;
|
|
|
|
use super::add_showcase_private_response_headers;
|
|
|
|
async fn response_with_existing_vary() -> Response {
|
|
let mut response = "ok".into_response();
|
|
response
|
|
.headers_mut()
|
|
.insert(header::VARY, "Origin".parse().expect("valid Vary value"));
|
|
response
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn showcase_private_headers_preserve_existing_vary_fields() {
|
|
let app = Router::new().route(
|
|
"/",
|
|
get(response_with_existing_vary)
|
|
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
|
|
);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should succeed");
|
|
let vary_values = response
|
|
.headers()
|
|
.get_all(header::VARY)
|
|
.iter()
|
|
.map(|value| value.to_str().expect("Vary should be ASCII"))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(vary_values, vec!["Origin", "Authorization"]);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod route_contract_tests {
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode, header},
|
|
middleware,
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use tower::ServiceExt;
|
|
|
|
use super::router;
|
|
use crate::{config::AppConfig, request_context::attach_request_context, state::AppState};
|
|
|
|
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
|
|
("/api/editor/projects/recent", &["GET"]),
|
|
("/api/editor/projects", &["GET", "POST"]),
|
|
(
|
|
"/api/editor/projects/{project_id}",
|
|
&["GET", "PATCH", "DELETE"],
|
|
),
|
|
("/api/editor/projects/{project_id}/metadata", &["PATCH"]),
|
|
("/api/editor/projects/{project_id}/resources", &["POST"]),
|
|
(
|
|
"/api/editor/projects/{project_id}/agent-conversations",
|
|
&["GET", "POST"],
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}",
|
|
&["GET", "DELETE"],
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/editor/project-resources/{resource_id}/showcase",
|
|
&["PATCH"],
|
|
),
|
|
("/api/editor/assets/library", &["GET"]),
|
|
("/api/editor/assets/folders", &["POST"]),
|
|
(
|
|
"/api/editor/assets/folders/{folder_id}",
|
|
&["PATCH", "DELETE"],
|
|
),
|
|
("/api/editor/assets", &["POST"]),
|
|
("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]),
|
|
(
|
|
"/api/editor/assets/{asset_id}/showcase-submissions",
|
|
&["POST"],
|
|
),
|
|
("/api/editor/showcase/assets/{showcase_id}/likes", &["POST"]),
|
|
("/api/editor/images/generations", &["POST"]),
|
|
("/api/editor/scenes/generations", &["POST"]),
|
|
("/api/editor/llm/icon-specs/refine-game-play", &["POST"]),
|
|
("/api/editor/llm/icon-specs/refine-art-style", &["POST"]),
|
|
("/api/editor/icon-specs/generations", &["POST"]),
|
|
("/api/editor/images/edits", &["POST"]),
|
|
("/api/editor/images/background-removals", &["POST"]),
|
|
("/api/editor/images/pixel-art-snaps", &["POST"]),
|
|
("/api/editor/icon-spritesheets/generations", &["POST"]),
|
|
("/api/editor/icon-spritesheets/slices", &["POST"]),
|
|
("/api/editor/ui-designs/assets/extractions", &["POST"]),
|
|
];
|
|
|
|
fn app() -> axum::Router {
|
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
|
router(state.clone())
|
|
.layer(middleware::from_fn(attach_request_context))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() {
|
|
let app = app();
|
|
for (template, supported) in PROTECTED_ROUTES {
|
|
let path = template
|
|
.split('/')
|
|
.map(|segment| {
|
|
if segment.starts_with('{') {
|
|
"fixture"
|
|
} else {
|
|
segment
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
let mut allowed = supported.to_vec();
|
|
if allowed.contains(&"GET") {
|
|
allowed.push("HEAD");
|
|
}
|
|
allowed.sort_unstable();
|
|
for credential in [None, Some("Bearer invalid-token")] {
|
|
for method in [
|
|
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE",
|
|
] {
|
|
let mut request = Request::builder().method(method).uri(&path);
|
|
if let Some(credential) = credential {
|
|
request = request.header(header::AUTHORIZATION, credential);
|
|
}
|
|
let response = app
|
|
.clone()
|
|
.oneshot(request.body(Body::empty()).expect("request should build"))
|
|
.await
|
|
.expect("request should complete");
|
|
let expected = if allowed.contains(&method) {
|
|
StatusCode::UNAUTHORIZED
|
|
} else {
|
|
StatusCode::METHOD_NOT_ALLOWED
|
|
};
|
|
assert_eq!(
|
|
response.status(),
|
|
expected,
|
|
"{method} {path}, credential={credential:?}"
|
|
);
|
|
if expected == StatusCode::METHOD_NOT_ALLOWED {
|
|
let mut actual = response.headers()[header::ALLOW]
|
|
.to_str()
|
|
.expect("Allow should be ASCII")
|
|
.split(',')
|
|
.map(str::trim)
|
|
.collect::<Vec<_>>();
|
|
actual.sort_unstable();
|
|
assert_eq!(actual, allowed, "Allow for {method} {path}");
|
|
}
|
|
if method == "HEAD" {
|
|
assert!(
|
|
response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("HEAD body should collect")
|
|
.to_bytes()
|
|
.is_empty(),
|
|
"HEAD {path} must not return a body"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_paths_remain_not_found_before_authentication() {
|
|
for credential in [None, Some("Bearer invalid-token")] {
|
|
let mut request = Request::builder().uri("/api/editor/missing/unknown/route");
|
|
if let Some(credential) = credential {
|
|
request = request.header(header::AUTHORIZATION, credential);
|
|
}
|
|
let response = app()
|
|
.oneshot(request.body(Body::empty()).expect("request should build"))
|
|
.await
|
|
.expect("request should complete");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn public_reads_and_showcase_rejections_keep_cache_boundaries() {
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicUsize, Ordering},
|
|
};
|
|
use std::time::Duration;
|
|
|
|
// 精选读取会访问正式 facade;隔离上游返回 503,明确验证匿名请求已进入 handler。
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("fixture should bind");
|
|
let address = listener
|
|
.local_addr()
|
|
.expect("fixture should have an address");
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let fixture_calls = calls.clone();
|
|
let fixture = axum::Router::new().fallback(move || {
|
|
fixture_calls.fetch_add(1, Ordering::SeqCst);
|
|
async { StatusCode::SERVICE_UNAVAILABLE }
|
|
});
|
|
let server = tokio::spawn(async move {
|
|
axum::serve(listener, fixture)
|
|
.await
|
|
.expect("fixture should serve");
|
|
});
|
|
let state = AppState::new(AppConfig {
|
|
spacetime_server_url: format!("http://{address}"),
|
|
spacetime_database: "route-showcase-unavailable-fixture".to_string(),
|
|
spacetime_procedure_timeout: Duration::from_secs(2),
|
|
..AppConfig::default()
|
|
})
|
|
.expect("state should build");
|
|
let app = router(state.clone())
|
|
.layer(middleware::from_fn(attach_request_context))
|
|
.with_state(state);
|
|
|
|
let pricing = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/editor/generation-pricing")
|
|
.body(Body::empty())
|
|
.expect("pricing request should build"),
|
|
)
|
|
.await
|
|
.expect("pricing request should complete");
|
|
assert_eq!(pricing.status(), StatusCode::OK);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
|
|
let response = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
app.clone().oneshot(
|
|
Request::builder()
|
|
.uri("/api/editor/showcase/resources")
|
|
.body(Body::empty())
|
|
.expect("showcase request should build"),
|
|
),
|
|
)
|
|
.await
|
|
.expect("showcase request should remain bounded")
|
|
.expect("showcase request should complete");
|
|
server.abort();
|
|
let _ = server.await;
|
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
|
assert!(
|
|
calls.load(Ordering::SeqCst) > 0,
|
|
"anonymous request must reach the database facade"
|
|
);
|
|
assert_eq!(
|
|
response.headers()[header::CACHE_CONTROL],
|
|
"private, no-store"
|
|
);
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.get_all(header::VARY)
|
|
.iter()
|
|
.any(|value| value == "Authorization")
|
|
);
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("error body should collect")
|
|
.to_bytes();
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("error should be JSON");
|
|
assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR");
|
|
assert_eq!(payload["error"]["details"]["provider"], "spacetimedb");
|
|
assert!(
|
|
payload["error"]["details"]["message"]
|
|
.as_str()
|
|
.is_some_and(|message| message.contains("503"))
|
|
);
|
|
|
|
for (method, path) in [
|
|
("GET", "/api/editor/showcase/resources"),
|
|
("POST", "/api/editor/showcase/assets/fixture/likes"),
|
|
] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header(header::AUTHORIZATION, "Bearer invalid-token")
|
|
.body(Body::empty())
|
|
.expect("showcase request should build"),
|
|
)
|
|
.await
|
|
.expect("showcase request should complete");
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
assert_eq!(
|
|
response.headers()[header::CACHE_CONTROL],
|
|
"private, no-store"
|
|
);
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.get_all(header::VARY)
|
|
.iter()
|
|
.any(|value| value == "Authorization")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authentication_precedes_layout_and_agent_body_limits() {
|
|
let app = app();
|
|
for (method, path, limit) in [
|
|
(
|
|
"PATCH",
|
|
"/api/editor/projects/fixture",
|
|
super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
|
|
),
|
|
(
|
|
"POST",
|
|
"/api/editor/agent-conversations/fixture/messages",
|
|
super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES,
|
|
),
|
|
] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("x".repeat(limit + 1)))
|
|
.expect("oversized request should build"),
|
|
)
|
|
.await
|
|
.expect("oversized request should complete");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"{method} {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authenticated_layout_and_agent_requests_keep_distinct_body_limits() {
|
|
use platform_auth::{
|
|
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
|
sign_access_token,
|
|
};
|
|
|
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
|
let user = state
|
|
.seed_test_phone_user_with_password("13800138991", "secret123")
|
|
.await;
|
|
let session_id =
|
|
state.seed_test_refresh_session_for_user(&user, "session-route-body-limits");
|
|
let claims = AccessTokenClaims::from_input(
|
|
AccessTokenClaimsInput {
|
|
user_id: user.id.clone(),
|
|
session_id,
|
|
provider: AuthProvider::Password,
|
|
roles: vec!["user".to_string()],
|
|
token_version: user.token_version,
|
|
phone_verified: false,
|
|
binding_status: BindingStatus::Active,
|
|
display_name: Some(user.display_name.clone()),
|
|
},
|
|
state.auth_jwt_config(),
|
|
time::OffsetDateTime::now_utc(),
|
|
)
|
|
.expect("claims should build");
|
|
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
|
let app = router(state.clone())
|
|
.layer(middleware::from_fn(attach_request_context))
|
|
.with_state(state);
|
|
|
|
for (method, path, size, expected) in [
|
|
(
|
|
"PATCH",
|
|
"/api/editor/projects/fixture",
|
|
2 * 1024 * 1024 + 1,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
(
|
|
"PATCH",
|
|
"/api/editor/projects/fixture",
|
|
super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
),
|
|
(
|
|
"POST",
|
|
"/api/editor/agent-conversations/fixture/messages",
|
|
super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES + 1,
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
),
|
|
] {
|
|
// 用有效 JSON 字符串触发类型校验,避免进入业务 handler 或产生持久化副作用。
|
|
let body = format!("\"{}\"", "x".repeat(size));
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("body limit request should build"),
|
|
)
|
|
.await
|
|
.expect("body limit request should complete");
|
|
assert_eq!(response.status(), expected, "{method} {path}, size={size}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn method_auth_layer_leaves_405_outside_authentication() {
|
|
async fn reject(_: Request<Body>, _: axum::middleware::Next) -> StatusCode {
|
|
StatusCode::UNAUTHORIZED
|
|
}
|
|
let method_layer = axum::Router::new().route(
|
|
"/only-get",
|
|
axum::routing::get(|| async { "ok" }).route_layer(middleware::from_fn(reject)),
|
|
);
|
|
let router_layer = axum::Router::new()
|
|
.route("/only-get", axum::routing::get(|| async { "ok" }))
|
|
.route_layer(middleware::from_fn(reject));
|
|
for (app, expected) in [
|
|
(method_layer, StatusCode::METHOD_NOT_ALLOWED),
|
|
(router_layer, StatusCode::UNAUTHORIZED),
|
|
] {
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("OPTIONS")
|
|
.uri("/only-get")
|
|
.body(Body::empty())
|
|
.expect("request should build"),
|
|
)
|
|
.await
|
|
.expect("request should complete");
|
|
assert_eq!(response.status(), expected);
|
|
}
|
|
}
|
|
}
|