use axum::{ Router, body::Body, extract::{Extension, FromRef}, http::{Request, StatusCode}, middleware, response::Response, routing::{get, post}, }; use serde_json::json; use tower_http::{ classify::ServerErrorsFailureClass, trace::{DefaultOnRequest, TraceLayer}, }; use tracing::{Level, Span, error, info_span}; use crate::{ auth::{AuthenticatedAccessToken, require_bearer_auth}, backpressure::limit_concurrent_requests, creation_entry_config::require_creation_entry_route_enabled, error_middleware::normalize_error_response, http_error::AppError, modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, runtime_inventory::get_runtime_inventory_state, state::{AppState, BackpressureState}, telemetry::record_http_observability, tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ create_background_music_task, create_sound_effect_task, create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, publish_background_music_asset, publish_sound_effect_asset, publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, }, visual_novel::{ compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work, execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session, get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history, list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run, start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, submit_visual_novel_message, update_visual_novel_work, }, wechat_pay::{ handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, }, }; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { Router::new() .merge(modules::admin::router(state.clone())) .merge(modules::health::router(state.clone())) .merge(modules::internal::router(state.clone())) .merge(modules::auth::router(state.clone())) .merge(modules::profile::router(state.clone())) .merge(modules::assets::router(state.clone())) .merge(modules::platform::router(state.clone())) .merge(modules::story::router(state.clone())) .merge(modules::edutainment::router(state.clone())) .merge(modules::custom_world::router(state.clone())) .merge(modules::big_fish::router(state.clone())) .merge(modules::bark_battle::router(state.clone())) .merge(modules::match3d::router(state.clone())) .merge(modules::square_hole::router(state.clone())) .merge(modules::jump_hop::router(state.clone())) .merge(modules::wooden_fish::router(state.clone())) .merge(modules::public_work::router(state.clone())) .merge(modules::puzzle::router(state.clone())) .merge(visual_novel_router(state.clone())) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), ) .route( "/api/profile/recharge/wechat/virtual-notify", get(handle_wechat_virtual_payment_message_push_verify) .post(handle_wechat_virtual_payment_notify), ) .route( "/api/runtime/sessions/{runtime_session_id}/inventory", get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) // 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), require_creation_entry_route_enabled, )) // HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。 .layer(middleware::from_fn_with_state( BackpressureState::from_ref(&state), limit_concurrent_requests, )) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 .layer(middleware::from_fn(propagate_request_id_header)) // 用户行为埋点放在错误归一化外侧,只观察最终成功响应,不阻断主链路。 .layer(middleware::from_fn_with_state( state.clone(), record_api_tracking_after_success, )) // HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。 .layer(middleware::from_fn_with_state( state.clone(), record_http_observability, )) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); let route = crate::telemetry::observability_route(request.uri().path()); let scheme = crate::telemetry::resolve_request_scheme(request.headers()); let span_name = format!("{} {}", request.method(), route); info_span!( "http.request", otel.kind = "server", otel.name = %span_name, otel.status_code = tracing::field::Empty, http.response.status_code = tracing::field::Empty, method = %request.method(), http.request.method = %request.method(), http.route = %route, url.scheme = %scheme, url.path = %request.uri().path(), request_id = %request_id, status = tracing::field::Empty, latency_ms = tracing::field::Empty, ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response( |response: &axum::response::Response, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; let status = response.status().as_u16(); span.record("status", status); span.record("http.response.status_code", status); span.record( "otel.status_code", if response.status().is_server_error() { "ERROR" } else { "OK" }, ); span.record("latency_ms", latency_ms); }, ) .on_failure( |failure: ServerErrorsFailureClass, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; error!( parent: span, latency_ms, failure = %failure, "http request failed" ); }, ), ) // request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。 .layer(middleware::from_fn(attach_request_context)) .with_state(state) } pub fn build_spacetime_unavailable_router(message: String) -> Router { Router::new() .fallback(spacetime_unavailable_handler) .layer(Extension(SpacetimeUnavailableState { message: message.into(), })) // 依赖不可用模式不挂业务 state,统一返回 503,并继续保留 request_id / API 版本 / 耗时响应头。 .layer(middleware::from_fn(normalize_error_response)) .layer(middleware::from_fn(propagate_request_id_header)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); let route = crate::telemetry::observability_route(request.uri().path()); let scheme = crate::telemetry::resolve_request_scheme(request.headers()); let span_name = format!("{} {}", request.method(), route); info_span!( "http.request", otel.kind = "server", otel.name = %span_name, otel.status_code = tracing::field::Empty, http.response.status_code = tracing::field::Empty, method = %request.method(), http.request.method = %request.method(), http.route = %route, url.scheme = %scheme, url.path = %request.uri().path(), request_id = %request_id, status = tracing::field::Empty, latency_ms = tracing::field::Empty, ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response( |response: &axum::response::Response, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; let status = response.status().as_u16(); span.record("status", status); span.record("http.response.status_code", status); span.record( "otel.status_code", if response.status().is_server_error() { "ERROR" } else { "OK" }, ); span.record("latency_ms", latency_ms); }, ) .on_failure( |failure: ServerErrorsFailureClass, latency: std::time::Duration, span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; error!( parent: span, latency_ms, failure = %failure, "http request failed" ); }, ), ) .layer(middleware::from_fn(attach_request_context)) } #[derive(Clone, Debug)] struct SpacetimeUnavailableState { message: std::sync::Arc, } async fn spacetime_unavailable_handler( Extension(state): Extension, Extension(request_context): Extension, ) -> Response { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("SpacetimeDB 暂不可用,api-server 正在等待数据库恢复") .with_details(json!({ "provider": "spacetimedb", "reason": "spacetime_startup_unavailable", "message": state.message.as_ref(), })) .into_response_with_context(Some(&request_context)) } async fn record_api_tracking_after_success( axum::extract::State(state): axum::extract::State, Extension(request_context): Extension, request: Request, next: middleware::Next, ) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); let response = next.run(request).await; let authenticated = response .extensions() .get::() .cloned(); record_route_tracking_event_after_success( &state, &request_context, &method, &path, response.status(), authenticated.as_ref(), ) .await; response } fn visual_novel_router(state: AppState) -> Router { Router::new() .route( "/api/creation/visual-novel/sessions", post(create_visual_novel_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/sessions/{session_id}", get(get_visual_novel_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/sessions/{session_id}/messages", post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/sessions/{session_id}/messages/stream", post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/sessions/{session_id}/actions", post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/sessions/{session_id}/compile", post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/works", get(list_visual_novel_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/works/{profile_id}", get(get_visual_novel_work) .put(update_visual_novel_work) .patch(update_visual_novel_work) .delete(delete_visual_novel_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/works/{profile_id}/publish", post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/visual-novel/audio/background-music", post(create_visual_novel_background_music_task).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/creation/visual-novel/audio/background-music/{task_id}/asset", post(publish_visual_novel_background_music_asset).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/creation/visual-novel/audio/sound-effect", post(create_visual_novel_sound_effect_task).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/creation/visual-novel/audio/sound-effect/{task_id}/asset", post(publish_visual_novel_sound_effect_asset).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/creation/audio/background-music", post(create_background_music_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/audio/background-music/{task_id}/asset", post(publish_background_music_asset).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/audio/sound-effect", post(create_sound_effect_task).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/audio/sound-effect/{task_id}/asset", post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/visual-novel/gallery", get(list_visual_novel_gallery), ) .route( "/api/runtime/visual-novel/works/{profile_id}/runs", post(start_visual_novel_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}", get(get_visual_novel_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/actions/stream", post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/history", get(list_visual_novel_history).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/regenerate", post(regenerate_visual_novel_run) .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), ) } #[cfg(test)] mod tests { use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use reqwest::Client; use serde_json::Value; use time::OffsetDateTime; use tokio::net::TcpListener; use tower::ServiceExt; use crate::{config::AppConfig, state::AppState}; use super::{build_router, build_spacetime_unavailable_router}; const TEST_PASSWORD: &str = "secret123"; const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; async fn seed_phone_user_with_password( state: &AppState, phone_number: &str, password: &str, ) -> module_auth::AuthUser { state .seed_test_phone_user_with_password(phone_number, password) .await } fn sign_test_user_token( state: &AppState, user: &module_auth::AuthUser, session_id: &str, ) -> String { let now = OffsetDateTime::now_utc(); let active_session_id = state.seed_test_refresh_session_for_user(user, session_id); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), session_id: active_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(), now, ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } fn read_access_token(response_body: &[u8]) -> String { let payload: Value = serde_json::from_slice(response_body).expect("login payload should be json"); payload["token"] .as_str() .expect("access token should exist") .to_string() } /// 中文注释:后台路由测试通过真实登录流程取 token,避免绕过鉴权中间件。 async fn read_admin_access_token(app: Router) -> String { let response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "root", "password": "secret123" }) .to_string(), )) .expect("admin login request should build"), ) .await .expect("admin login request should succeed"); let body = response .into_body() .collect() .await .expect("admin login body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("admin login payload should be json"); payload["token"] .as_str() .expect("admin token should exist") .to_string() } async fn password_login_request( app: Router, phone_number: &str, password: &str, ) -> axum::response::Response { app.oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": phone_number, "password": password }) .to_string(), )) .expect("password login request should build"), ) .await .expect("password login request should succeed") } async fn password_login_request_with_client( app: Router, phone_number: &str, password: &str, client_instance_id: &str, forwarded_for: &str, ) -> axum::response::Response { app.oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("x-client-instance-id", client_instance_id) .header("x-forwarded-for", forwarded_for) .body(Body::from( serde_json::json!({ "phone": phone_number, "password": password }) .to_string(), )) .expect("password login request should build"), ) .await .expect("password login request should succeed") } fn build_internal_creative_agent_app() -> Router { let mut config = AppConfig::default(); config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string()); build_router(AppState::new(config).expect("state should build")) } fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request { Request::builder() .method(method) .uri(uri) .header("content-type", "application/json") .header("x-genarrative-authenticated-user-id", "user-creative-test") .header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET) .body(Body::from(body.to_string())) .expect("creative agent request should build") } async fn read_json_response(response: axum::response::Response) -> Value { let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); serde_json::from_slice(&body).expect("response body should be valid json") } async fn read_text_response(response: axum::response::Response) -> String { let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); String::from_utf8(body.to_vec()).expect("response body should be utf8") } #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-legacy") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get("x-request-id") .and_then(|value| value.to_str().ok()), Some("req-health-legacy") ); assert_eq!( response .headers() .get("x-api-version") .and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert_eq!( response .headers() .get("x-route-version") .and_then(|value| value.to_str().ok()), Some("2026-04-08") ); assert!(response.headers().contains_key("x-response-time-ms")); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["service"], Value::String("genarrative-api-server".to_string()) ); } #[tokio::test] async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() { let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string()); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .header("x-request-id", "req-spacetime-unavailable") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!( response .headers() .get("x-request-id") .and_then(|value| value.to_str().ok()), Some("req-spacetime-unavailable") ); let body = read_json_response(response).await; assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE"); assert_eq!( body["error"]["details"]["reason"], "spacetime_startup_unavailable" ); assert_eq!(body["error"]["details"]["provider"], "spacetimedb"); } #[tokio::test] async fn creation_entry_route_disabled_returns_service_unavailable() { let state = AppState::new(AppConfig::default()).expect("state should build"); state.set_test_creation_entry_route_enabled("puzzle", false); let app = build_router(state); let response = app .oneshot( Request::builder() .uri("/api/runtime/puzzle/works") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = read_json_response(response).await; assert_eq!( body["error"]["details"]["reason"], "creation_entry_disabled" ); assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); } #[tokio::test] async fn disabled_visual_novel_creation_route_returns_service_unavailable() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/creation/visual-novel/sessions") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "sourceMode": "idea", "seedText": "雨夜书店", "sourceAssetIds": [] }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = read_json_response(response).await; assert_eq!( body["error"]["details"]["reason"], "creation_entry_disabled" ); assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel"); } #[tokio::test] async fn disabled_rpg_route_returns_service_unavailable() { let state = AppState::new(AppConfig::default()).expect("state should build"); state.set_test_creation_entry_route_enabled("rpg", false); let app = build_router(state); let response = app .oneshot( Request::builder() .uri("/api/runtime/custom-world/agent/sessions") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = read_json_response(response).await; assert_eq!( body["error"]["details"]["reason"], "creation_entry_disabled" ); assert_eq!(body["error"]["details"]["creationTypeId"], "rpg"); } #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/healthz") .header("x-request-id", "req-health-envelope") .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("healthz request should build"), ) .await .expect("healthz request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("healthz body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("healthz body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["data"]["service"], Value::String("genarrative-api-server".to_string()) ); assert_eq!( payload["meta"]["requestId"], Value::String("req-health-envelope".to_string()) ); } #[tokio::test] async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() { let app = build_internal_creative_agent_app(); let create_response = app .clone() .oneshot(internal_creative_agent_request( "POST", "/api/runtime/creative-agent/sessions", serde_json::json!({ "text": "做一个生日拼图", "entryContext": "creation_home" }), )) .await .expect("create session request should succeed"); assert_eq!(create_response.status(), StatusCode::OK); let create_payload = read_json_response(create_response).await; let session_id = create_payload["session"]["sessionId"] .as_str() .expect("session id should exist"); let edit_response = app .clone() .oneshot(internal_creative_agent_request( "POST", &format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"), serde_json::json!({ "clientMessageId": "creative-edit-test", "instruction": "把标题改轻松一点", "targetPuzzleSessionId": "puzzle-session-unconfirmed", "currentDraft": { "workTitle": "旧标题", "workDescription": "旧描述", "summary": "旧描述", "themeTags": ["创意", "拼图", "灵感"], "levels": [{ "levelId": "puzzle-level-1", "levelName": "第一关", "pictureDescription": "旧图面", "pictureReference": null, "generationStatus": "idle", "candidates": [] }] } }), )) .await .expect("draft edit request should be handled"); assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST); let edit_payload = read_json_response(edit_response).await; assert_eq!( edit_payload["error"]["details"]["message"], Value::String("尚未绑定拼图草稿".to_string()) ); let session_response = app .oneshot(internal_creative_agent_request( "GET", &format!("/api/runtime/creative-agent/sessions/{session_id}"), Value::Null, )) .await .expect("get session request should succeed"); let session_payload = read_json_response(session_response).await; assert_eq!(session_payload["session"]["targetBinding"], Value::Null); } #[tokio::test] async fn creative_agent_message_stream_returns_template_confirmation_events() { let app = build_internal_creative_agent_app(); let create_response = app .clone() .oneshot(internal_creative_agent_request( "POST", "/api/runtime/creative-agent/sessions", serde_json::json!({ "text": "做一个生日拼图", "entryContext": "creation_home" }), )) .await .expect("create session request should succeed"); assert_eq!(create_response.status(), StatusCode::OK); let create_payload = read_json_response(create_response).await; let session_id = create_payload["session"]["sessionId"] .as_str() .expect("session id should exist"); let stream_response = app .clone() .oneshot(internal_creative_agent_request( "POST", &format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"), serde_json::json!({ "clientMessageId": "creative-message-stream-test", "content": [{ "type": "input_text", "text": "做一个温暖的生日拼图" }] }), )) .await .expect("message stream request should be handled"); assert_eq!(stream_response.status(), StatusCode::OK); assert_eq!( stream_response .headers() .get("content-type") .and_then(|value| value.to_str().ok()), Some("text/event-stream") ); let stream_body = read_text_response(stream_response).await; assert!(stream_body.contains("event: stage")); assert!(stream_body.contains("event: tool_started")); assert!(stream_body.contains("event: tool_completed")); assert!(stream_body.contains("event: puzzle_template_catalog")); assert!(!stream_body.contains("event: puzzle_template_selection")); assert!(!stream_body.contains("event: puzzle_cost_range")); assert!(stream_body.contains("event: done")); let tool_started_id = stream_body .lines() .skip_while(|line| *line != "event: tool_started") .nth(1) .and_then(|line| line.strip_prefix("data: ")) .and_then(|data| serde_json::from_str::(data).ok()) .and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string)) .expect("tool_started should include toolCallId"); let tool_completed_id = stream_body .lines() .skip_while(|line| *line != "event: tool_completed") .nth(1) .and_then(|line| line.strip_prefix("data: ")) .and_then(|data| serde_json::from_str::(data).ok()) .and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string)) .expect("tool_completed should include toolCallId"); assert_eq!(tool_started_id, tool_completed_id); let session_response = app .oneshot(internal_creative_agent_request( "GET", &format!("/api/runtime/creative-agent/sessions/{session_id}"), Value::Null, )) .await .expect("get session request should succeed"); let session_payload = read_json_response(session_response).await; assert_eq!( session_payload["session"]["stage"], Value::String("waiting_template_confirmation".to_string()) ); assert_eq!( session_payload["session"]["puzzleTemplateSelection"], Value::Null ); assert!( session_payload["session"]["puzzleTemplateCatalog"] .as_array() .map(|templates| templates.len() >= 3) .unwrap_or(false) ); } #[tokio::test] async fn creative_agent_confirm_template_rejects_non_puzzle_template() { let app = build_internal_creative_agent_app(); let create_response = app .clone() .oneshot(internal_creative_agent_request( "POST", "/api/runtime/creative-agent/sessions", serde_json::json!({ "text": "做一个角色扮演开场", "entryContext": "creation_home" }), )) .await .expect("create session request should succeed"); assert_eq!(create_response.status(), StatusCode::OK); let create_payload = read_json_response(create_response).await; let session_id = create_payload["session"]["sessionId"] .as_str() .expect("session id should exist"); let confirm_response = app .clone() .oneshot(internal_creative_agent_request( "POST", &format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"), serde_json::json!({ "selection": { "templateId": "rpg.unsupported", "title": "RPG", "reason": "用户想创建 RPG", "costRange": { "minPoints": 2, "maxPoints": 12, "pricingUnit": "point", "reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准" }, "supportedLevelMode": "single_or_multi", "selectedLevelMode": "single_level", "plannedLevelCount": 1, "requiresUserConfirmation": true } }), )) .await .expect("confirm template request should be handled"); assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST); let confirm_payload = read_json_response(confirm_response).await; assert_eq!( confirm_payload["error"]["details"]["provider"], Value::String("module-puzzle".to_string()) ); let session_response = app .oneshot(internal_creative_agent_request( "GET", &format!("/api/runtime/creative-agent/sessions/{session_id}"), Value::Null, )) .await .expect("get session request should succeed"); let session_payload = read_json_response(session_response).await; assert_eq!( session_payload["session"]["stage"], Value::String("idle".to_string()) ); assert_eq!(session_payload["session"]["targetBinding"], Value::Null); } #[tokio::test] async fn runtime_story_legacy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); for (method, uri) in [ ("POST", "/api/runtime/story/sessions"), ("POST", "/api/runtime/story/state/resolve"), ("GET", "/api/runtime/story/state/runtime-main"), ("POST", "/api/runtime/story/actions/resolve"), ("POST", "/api/runtime/story/initial"), ("POST", "/api/runtime/story/continue"), ] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("legacy runtime story request should build"), ) .await .expect("legacy runtime story request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = response .into_body() .collect() .await .expect("legacy runtime story body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("legacy runtime story body should be json"); assert_eq!(payload["ok"], Value::Bool(false)); assert_eq!( payload["error"]["code"], Value::String("NOT_FOUND".to_string()) ); assert_eq!( payload["error"]["message"], Value::String("资源不存在".to_string()) ); } } #[tokio::test] async fn deleted_old_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); // 中文注释:旧 custom-world 非 runtime 前缀没有任何新路由可匹配, // 因此必须稳定返回 404,避免前端继续误用旧入口。 for uri in [ "/api/custom-world/entity", "/api/custom-world/scene-npc", "/api/custom-world/scene-image", "/api/custom-world/cover-image", "/api/custom-world/cover-upload", ] { let response = app .clone() .oneshot( Request::builder() .method("POST") .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("deleted old route request should build"), ) .await .expect("deleted old route request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/runs/local-next-level") .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("deleted old puzzle route request should build"), ) .await .expect("deleted old puzzle route request should be handled"); // 中文注释:该路径会被现有 GET /runs/{run_id} 的动态段识别, // 但 POST 方法没有挂载,返回 405 代表旧 local-next-level handler 已移除。 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn generated_asset_read_proxy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); // 中文注释:生成资产仍可作为 legacyPublicPath 传给 /api/assets/read-url, // 但不能再通过 /generated-* 同源路由裸读 OSS 对象。 for uri in [ "/generated-character-drafts/hero/visual/candidate.png", "/generated-characters/hero/visual/master.png", "/generated-animations/hero/idle/frame01.png", "/generated-big-fish-assets/session-1/level/image.png", "/generated-puzzle-assets/session-1/candidate/image.png", "/generated-custom-world-scenes/world-1/camp/scene.png", "/generated-custom-world-covers/world-1/cover.webp", "/generated-bark-battle-assets/draft/player/image.webp", "/generated-qwen-sprites/master/candidate-01.png", ] { let response = app .clone() .oneshot( Request::builder() .method("GET") .uri(uri) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("generated asset proxy route request should build"), ) .await .expect("generated asset proxy route request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } } #[tokio::test] async fn internal_auth_claims_rejects_missing_bearer_token() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/claims") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn internal_auth_claims_returns_verified_claims() { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await; let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: seed_user.id.clone(), session_id: session_id.clone(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: seed_user.token_version, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some(seed_user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); let app = build_router(state); let response = app .oneshot( Request::builder() .uri("/_internal/auth/claims") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id)); assert_eq!(payload["claims"]["sid"], Value::String(session_id)); assert_eq!( payload["claims"]["ver"], Value::Number(serde_json::Number::from(seed_user.token_version)) ); } #[tokio::test] async fn internal_refresh_cookie_reports_missing_cookie() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/refresh-cookie") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["present"], Value::Bool(false)); assert_eq!( payload["cookieName"], Value::String("genarrative_refresh_session".to_string()) ); } #[tokio::test] async fn internal_refresh_cookie_reports_present_cookie() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/_internal/auth/refresh-cookie") .header( "cookie", "theme=dark; genarrative_refresh_session=token12345", ) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["present"], Value::Bool(true)); assert_eq!( payload["tokenLength"], Value::Number(serde_json::Number::from(10)) ); } #[tokio::test] async fn puzzle_agent_actions_accept_reference_image_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138024", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_reference_body"); let app = build_router(state); let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "action": "unsupported_large_reference_test", "referenceImageSrc": reference_image_src, }) .to_string(); assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/agent/sessions/puzzle-session-large/actions") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(request_body)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( body_text.contains("unsupported_large_reference_test"), "handler should parse the oversized reference payload before rejecting the action: {body_text}" ); assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] async fn puzzle_agent_session_creation_accepts_reference_image_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138025", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_form_reference_body"); let app = build_router(state); let request_body = format!( "{{\"seedText\":\"大参考图拼图\",\"pictureDescription\":\"一张用于验证 body limit 的参考图。\",\"referenceImageSrc\":\"data:image/png;base64,{}\"", "A".repeat(3 * 1024 * 1024) ); assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/puzzle/agent/sessions") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(request_body)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( body_text.contains("EOF") || body_text.contains("expected"), "handler should parse the oversized form payload before rejecting malformed JSON: {body_text}" ); assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = password_login_request(app, "13800138011", TEST_PASSWORD).await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { let config = AppConfig { dev_password_entry_auto_register_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_response = password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; let first_status = first_response.status(); let first_body = first_response .into_body() .collect() .await .expect("first response body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first response body should be valid json"); let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; assert_eq!(first_status, StatusCode::OK); assert!(first_payload["token"].as_str().is_some()); assert_eq!( first_payload["user"]["loginMethod"], Value::String("password".to_string()) ); assert_eq!(second_response.status(), StatusCode::OK); } #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await; let app = build_router(state); let response = password_login_request(app, "13800138012", TEST_PASSWORD).await; assert_eq!(response.status(), StatusCode::OK); assert!( response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["user"]["id"], Value::String(seed_user.id)); assert_eq!( payload["user"]["loginMethod"], Value::String("password".to_string()) ); assert_eq!( payload["user"]["createdAt"], Value::String(seed_user.created_at) ); assert!(payload["token"].as_str().is_some()); } #[tokio::test] async fn auth_login_options_returns_enabled_methods_in_stable_order() { let config = AppConfig { sms_auth_enabled: true, wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["phone", "password", "wechat"]) ); } #[tokio::test] async fn auth_login_options_keeps_password_entry_when_external_methods_disabled() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["password"]) ); } #[tokio::test] async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["cooldownSeconds"], Value::Number(serde_json::Number::from(60)) ); assert_eq!( payload["expiresInSeconds"], Value::Number(serde_json::Number::from(300)) ); assert_eq!( payload["providerRequestId"], Value::String("mock-request-id".to_string()) ); } #[tokio::test] async fn send_phone_code_rejects_same_scene_during_cooldown() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("first request should build"), ) .await .expect("first request should succeed"); assert_eq!(first_response.status(), StatusCode::OK); let cooldown_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("cooldown request should build"), ) .await .expect("cooldown request should succeed"); assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS); assert!( cooldown_response .headers() .get("retry-after") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.parse::().is_ok_and(|seconds| seconds > 0)) ); let body = cooldown_response .into_body() .collect() .await .expect("cooldown body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("cooldown body should be valid json"); assert_eq!( payload["error"]["code"], Value::String("TOO_MANY_REQUESTS".to_string()) ); assert_eq!( payload["error"]["message"], Value::String("验证码发送过于频繁,请稍后再试".to_string()) ); assert!( payload["error"]["details"]["retryAfterSeconds"] .as_u64() .is_some() ); } #[tokio::test] async fn phone_login_creates_user_and_sets_refresh_cookie() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .body(Body::from( serde_json::json!({ "phone": "13800138000", "code": "123456" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); assert!( login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let body = login_response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert!(payload["token"].as_str().is_some()); assert_eq!( payload["user"]["loginMethod"], Value::String("phone".to_string()) ); assert_eq!( payload["user"]["bindingStatus"], Value::String("active".to_string()) ); assert_eq!( payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); assert!(payload["user"]["createdAt"].as_str().is_some()); assert_eq!(payload["created"], Value::Bool(true)); assert!(payload["referral"].is_null()); } #[tokio::test] async fn phone_login_reuses_existing_user_for_same_phone_number() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "code": "123456" }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login request should succeed"); assert_eq!(first_login_response.status(), StatusCode::OK); let first_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first login payload should be json"); let send_code_again_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_again_response.status(), StatusCode::OK); let second_login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "code": "123456" }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login request should succeed"); assert_eq!(second_login_response.status(), StatusCode::OK); let second_body = second_login_response .into_body() .collect() .await .expect("second login body should collect") .to_bytes(); let second_payload: Value = serde_json::from_slice(&second_body).expect("second login payload should be json"); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); assert_eq!(first_payload["created"], Value::Bool(true)); assert_eq!(second_payload["created"], Value::Bool(false)); assert!(second_payload["referral"].is_null()); } #[tokio::test] async fn phone_login_invite_code_failure_does_not_block_created_user() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13600136000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13600136000", "code": "123456", "inviteCode": "SPRING2026" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); let body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("login payload should be json"); assert!(payload["token"].as_str().is_some()); assert_eq!(payload["created"], Value::Bool(true)); assert_eq!(payload["referral"]["ok"], Value::Bool(false)); assert_eq!( payload["referral"]["message"], Value::String("邀请码无效,已继续注册".to_string()) ); } #[tokio::test] async fn phone_login_existing_user_ignores_invite_code() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(first_send_code_response.status(), StatusCode::OK); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "code": "123456" }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login request should succeed"); assert_eq!(first_login_response.status(), StatusCode::OK); let second_send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(second_send_code_response.status(), StatusCode::OK); let second_login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "code": "123456", "inviteCode": "SPRING2026" }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login request should succeed"); assert_eq!(second_login_response.status(), StatusCode::OK); let body = second_login_response .into_body() .collect() .await .expect("second login body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("second login payload should be json"); assert_eq!(payload["created"], Value::Bool(false)); assert!(payload["referral"].is_null()); } #[tokio::test] async fn phone_login_exhausts_code_after_too_many_wrong_attempts() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); for _ in 0..4 { let wrong_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "000000" }) .to_string(), )) .expect("wrong login request should build"), ) .await .expect("wrong login request should succeed"); assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST); } let exhausted_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "000000" }) .to_string(), )) .expect("exhausted login request should build"), ) .await .expect("exhausted login request should succeed"); assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS); let exhausted_body = exhausted_response .into_body() .collect() .await .expect("exhausted body should collect") .to_bytes(); let exhausted_payload: Value = serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json"); assert_eq!( exhausted_payload["error"]["message"], Value::String("验证码错误次数过多,请重新获取验证码".to_string()) ); let stale_right_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "123456" }) .to_string(), )) .expect("stale login request should build"), ) .await .expect("stale login request should succeed"); assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST); let resend_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "scene": "login" }) .to_string(), )) .expect("resend request should build"), ) .await .expect("resend request should succeed"); assert_eq!(resend_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "123456" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); } #[test] fn phone_auth_sms_provider_errors_keep_upstream_http_semantics() { let invalid_config = crate::phone_auth::map_phone_auth_error( module_auth::PhoneAuthError::SmsProviderInvalidConfig( "阿里云短信 AccessKeyId 未配置".to_string(), ), ); assert_eq!( invalid_config.status_code(), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!(invalid_config.message(), "阿里云短信 AccessKeyId 未配置"); let upstream = crate::phone_auth::map_phone_auth_error( module_auth::PhoneAuthError::SmsProviderUpstream( "短信验证码发送失败:check frequency failed".to_string(), ), ); assert_eq!(upstream.status_code(), StatusCode::BAD_GATEWAY); assert_eq!( upstream.message(), "短信验证码发送失败:check frequency failed" ); } #[tokio::test] async fn wechat_start_returns_mock_callback_url_with_state() { let config = AppConfig { wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/wechat/start?redirectPath=%2Fplay") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("host", "localhost:3000") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); let authorization_url = payload["authorizationUrl"] .as_str() .expect("authorization url should exist"); assert!(authorization_url.contains("/api/auth/wechat/callback")); assert!(authorization_url.contains("mock_code=wx-mock-code")); assert!(authorization_url.contains("state=")); } #[tokio::test] async fn wechat_callback_creates_pending_bind_phone_session_with_wechat_provider() { let config = AppConfig { wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config.clone()).expect("state should build")); let start_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/wechat/start?redirectPath=%2Fplay") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("host", "localhost:3000") .body(Body::empty()) .expect("request should build"), ) .await .expect("wechat start should succeed"); let start_body = start_response .into_body() .collect() .await .expect("wechat start body should collect") .to_bytes(); let start_payload: Value = serde_json::from_slice(&start_body).expect("wechat start payload should be json"); let authorization_url = start_payload["authorizationUrl"] .as_str() .expect("authorization url should exist"); let callback_url = url::Url::parse(authorization_url).expect("authorization url should be valid"); let state = callback_url .query_pairs() .find(|(key, _)| key == "state") .map(|(_, value)| value.into_owned()) .expect("state query should exist"); let callback_response = app .clone() .oneshot( Request::builder() .uri(format!( "/api/auth/wechat/callback?state={state}&mock_code=wx-mock-code" )) .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("host", "localhost:3000") .body(Body::empty()) .expect("callback request should build"), ) .await .expect("callback request should succeed"); assert_eq!(callback_response.status(), StatusCode::SEE_OTHER); let location = callback_response .headers() .get("location") .and_then(|value| value.to_str().ok()) .expect("redirect location should exist"); let refresh_cookie = callback_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist"); assert!(location.starts_with("/play#")); assert!(location.contains("auth_provider=wechat")); assert!(location.contains("auth_binding_status=pending_bind_phone")); assert!(location.contains("auth_token=")); assert!(refresh_cookie.contains("genarrative_refresh_session=")); let auth_hash = location .split('#') .nth(1) .expect("hash fragment should exist"); let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes()) .into_owned() .collect::>(); let token = auth_params .get("auth_token") .expect("auth token should exist in hash"); let me_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("auth me request should build"), ) .await .expect("auth me request should succeed"); assert_eq!(me_response.status(), StatusCode::OK); let me_body = me_response .into_body() .collect() .await .expect("auth me body should collect") .to_bytes(); let me_payload: Value = serde_json::from_slice(&me_body).expect("auth me payload should be json"); assert_eq!( me_payload["user"]["loginMethod"], Value::String("wechat".to_string()) ); assert_eq!( me_payload["user"]["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); let claims_response = app .oneshot( Request::builder() .uri("/_internal/auth/claims") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("claims request should build"), ) .await .expect("claims request should succeed"); let claims_body = claims_response .into_body() .collect() .await .expect("claims body should collect") .to_bytes(); let claims_payload: Value = serde_json::from_slice(&claims_body).expect("claims payload should be json"); assert_eq!( claims_payload["claims"]["provider"], Value::String("wechat".to_string()) ); assert_eq!( claims_payload["claims"]["binding_status"], Value::String("pending_bind_phone".to_string()) ); assert_eq!( claims_payload["claims"]["phone_verified"], Value::Bool(false) ); } #[tokio::test] async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() { let config = AppConfig { wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/wechat/miniprogram-login") .header("content-type", "application/json") .header("x-client-type", "mini_program") .header("x-client-runtime", "wechat_mini_program") .header("x-client-platform", "ios") .header("x-client-instance-id", "mini-instance-001") .header("x-mini-program-app-id", "wx-mini-test") .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ "code": "wx-mini-code-001" }) .to_string(), )) .expect("mini program login request should build"), ) .await .expect("mini program login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); let refresh_cookie = login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist") .to_string(); let login_body = login_response .into_body() .collect() .await .expect("mini program login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("mini program login payload should be json"); let token = login_payload["token"] .as_str() .expect("system token should exist") .to_string(); assert_eq!( login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); assert_eq!( login_payload["user"]["loginMethod"], Value::String("wechat".to_string()) ); assert!(refresh_cookie.contains("genarrative_refresh_session=")); let sessions_response = app .oneshot( Request::builder() .uri("/api/auth/sessions") .header("authorization", format!("Bearer {token}")) .header("cookie", refresh_cookie) .body(Body::empty()) .expect("sessions request should build"), ) .await .expect("sessions request should succeed"); assert_eq!(sessions_response.status(), StatusCode::OK); let sessions_body = sessions_response .into_body() .collect() .await .expect("sessions body should collect") .to_bytes(); let sessions_payload: Value = serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); assert_eq!( sessions_payload["sessions"][0]["clientType"], Value::String("mini_program".to_string()) ); assert_eq!( sessions_payload["sessions"][0]["clientRuntime"], Value::String("wechat_mini_program".to_string()) ); assert_eq!( sessions_payload["sessions"][0]["miniProgramAppId"], Value::String("wx-mini-test".to_string()) ); } #[tokio::test] async fn wechat_miniprogram_bind_phone_code_activates_pending_user() { let config = AppConfig { wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/wechat/miniprogram-login") .header("content-type", "application/json") .header("x-client-type", "mini_program") .header("x-client-runtime", "wechat_mini_program") .header("x-client-platform", "ios") .header("x-client-instance-id", "mini-bind-instance-001") .header("x-mini-program-app-id", "wx-mini-test") .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ "code": "wx-mini-code-bind-001" }) .to_string(), )) .expect("mini program login request should build"), ) .await .expect("mini program login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); let login_body = login_response .into_body() .collect() .await .expect("mini program login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("mini program login payload should be json"); let token = login_payload["token"] .as_str() .expect("system token should exist") .to_string(); assert_eq!( login_payload["bindingStatus"], Value::String("pending_bind_phone".to_string()) ); let bind_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/wechat/bind-phone") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-client-type", "mini_program") .header("x-client-runtime", "wechat_mini_program") .header("x-client-platform", "ios") .header("x-client-instance-id", "mini-bind-instance-001") .header("x-mini-program-app-id", "wx-mini-test") .header("x-mini-program-env", "develop") .body(Body::from( serde_json::json!({ "wechatPhoneCode": "13800138000" }) .to_string(), )) .expect("bind request should build"), ) .await .expect("bind request should succeed"); assert_eq!(bind_response.status(), StatusCode::OK); assert!( bind_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let bind_body = bind_response .into_body() .collect() .await .expect("bind body should collect") .to_bytes(); let bind_payload: Value = serde_json::from_slice(&bind_body).expect("bind payload should be json"); assert_eq!( bind_payload["user"]["bindingStatus"], Value::String("active".to_string()) ); assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true)); assert_eq!( bind_payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); assert!( bind_payload["token"] .as_str() .is_some_and(|value| !value.is_empty()) ); } #[tokio::test] async fn wechat_bind_phone_merges_into_existing_phone_user() { let config = AppConfig { sms_auth_enabled: true, wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let phone_send_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("phone send request should build"), ) .await .expect("phone send request should succeed"); assert_eq!(phone_send_response.status(), StatusCode::OK); let phone_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "code": "123456" }) .to_string(), )) .expect("phone login request should build"), ) .await .expect("phone login request should succeed"); let phone_login_body = phone_login_response .into_body() .collect() .await .expect("phone login body should collect") .to_bytes(); let phone_login_payload: Value = serde_json::from_slice(&phone_login_body).expect("phone login payload should be json"); let phone_user_id = phone_login_payload["user"]["id"].clone(); let wechat_start_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/wechat/start?redirectPath=%2Fplay") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("host", "localhost:3000") .body(Body::empty()) .expect("wechat start request should build"), ) .await .expect("wechat start request should succeed"); let wechat_start_body = wechat_start_response .into_body() .collect() .await .expect("wechat start body should collect") .to_bytes(); let wechat_start_payload: Value = serde_json::from_slice(&wechat_start_body) .expect("wechat start payload should be json"); let authorization_url = wechat_start_payload["authorizationUrl"] .as_str() .expect("wechat authorization url should exist"); let callback_state = url::Url::parse(authorization_url) .expect("authorization url should be valid") .query_pairs() .find(|(key, _)| key == "state") .map(|(_, value)| value.into_owned()) .expect("state should exist"); let wechat_callback_response = app .clone() .oneshot( Request::builder() .uri(format!( "/api/auth/wechat/callback?state={callback_state}&mock_code=wx-mock-code" )) .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("host", "localhost:3000") .body(Body::empty()) .expect("wechat callback request should build"), ) .await .expect("wechat callback request should succeed"); let wechat_location = wechat_callback_response .headers() .get("location") .and_then(|value| value.to_str().ok()) .expect("wechat callback location should exist"); let wechat_hash = wechat_location .split('#') .nth(1) .expect("wechat callback hash should exist"); let wechat_auth_params = url::form_urlencoded::parse(wechat_hash.as_bytes()) .into_owned() .collect::>(); let wechat_token = wechat_auth_params .get("auth_token") .expect("wechat auth token should exist"); let bind_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "bind_phone" }) .to_string(), )) .expect("bind code request should build"), ) .await .expect("bind code request should succeed"); assert_eq!(bind_code_response.status(), StatusCode::OK); let bind_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/wechat/bind-phone") .header("authorization", format!("Bearer {wechat_token}")) .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "code": "123456" }) .to_string(), )) .expect("bind request should build"), ) .await .expect("bind request should succeed"); assert_eq!(bind_response.status(), StatusCode::OK); assert!( bind_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let bind_body = bind_response .into_body() .collect() .await .expect("bind body should collect") .to_bytes(); let bind_payload: Value = serde_json::from_slice(&bind_body).expect("bind payload should be json"); assert_eq!(bind_payload["user"]["id"], phone_user_id); assert_eq!( bind_payload["user"]["bindingStatus"], Value::String("active".to_string()) ); assert_eq!( bind_payload["user"]["loginMethod"], Value::String("phone".to_string()) ); assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true)); assert_eq!( bind_payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); } #[tokio::test] async fn auth_sessions_returns_multi_device_session_fields() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await; let app = build_router(state); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("x-client-instance-id", "chrome-instance-001") .body(Body::from( serde_json::json!({ "phone": "13800138013", "password": TEST_PASSWORD }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login should succeed"); let first_cookie = first_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("first cookie should exist") .to_string(); let first_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first login payload should be json"); let access_token = first_payload["token"] .as_str() .expect("access token should exist") .to_string(); let _second_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header("x-client-type", "mini_program") .header("x-client-runtime", "wechat_mini_program") .header("x-client-platform", "android") .header("x-client-instance-id", "mini-instance-001") .header("x-mini-program-app-id", "wx-session-test") .header("x-mini-program-env", "release") .header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger") .body(Body::from( serde_json::json!({ "phone": "13800138013", "password": TEST_PASSWORD }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login should succeed"); let sessions_response = app .oneshot( Request::builder() .uri("/api/auth/sessions") .header("authorization", format!("Bearer {access_token}")) .header("cookie", first_cookie) .body(Body::empty()) .expect("sessions request should build"), ) .await .expect("sessions request should succeed"); assert_eq!(sessions_response.status(), StatusCode::OK); let sessions_body = sessions_response .into_body() .collect() .await .expect("sessions body should collect") .to_bytes(); let sessions_payload: Value = serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); let sessions = sessions_payload["sessions"] .as_array() .expect("sessions should be array"); assert_eq!(sessions.len(), 2); assert!(sessions.iter().any(|session| { session["clientType"] == Value::String("web_browser".to_string()) && session["clientRuntime"] == Value::String("chrome".to_string()) && session["clientPlatform"] == Value::String("windows".to_string()) && session["sessionCount"] == Value::Number(1.into()) && session["sessionIds"] .as_array() .is_some_and(|ids| ids.len() == 1) && session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string()) && session["isCurrent"] == Value::Bool(true) })); assert!(sessions.iter().any(|session| { session["clientType"] == Value::String("mini_program".to_string()) && session["clientRuntime"] == Value::String("wechat_mini_program".to_string()) && session["sessionCount"] == Value::Number(1.into()) && session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) && session["miniProgramEnv"] == Value::String("release".to_string()) && session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string()) && session["isCurrent"] == Value::Bool(false) })); } #[tokio::test] async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; let app = build_router(state); let login_body = serde_json::json!({ "phone": "13800138028", "password": TEST_PASSWORD }) .to_string(); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("x-client-instance-id", "same-device") .header("x-forwarded-for", "203.0.113.10") .body(Body::from(login_body.clone())) .expect("first login request should build"), ) .await .expect("first login should succeed"); let first_cookie = first_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("first cookie should exist") .to_string(); let first_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let access_token = read_access_token(&first_body); app.clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .header("x-client-instance-id", "same-device") .header("x-forwarded-for", "203.0.113.10") .body(Body::from(login_body)) .expect("second login request should build"), ) .await .expect("second login should succeed"); let sessions_response = app .oneshot( Request::builder() .uri("/api/auth/sessions") .header("authorization", format!("Bearer {access_token}")) .header("cookie", first_cookie) .body(Body::empty()) .expect("sessions request should build"), ) .await .expect("sessions request should succeed"); assert_eq!(sessions_response.status(), StatusCode::OK); let sessions_body = sessions_response .into_body() .collect() .await .expect("sessions body should collect") .to_bytes(); let sessions_payload: Value = serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); let sessions = sessions_payload["sessions"] .as_array() .expect("sessions should be array"); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into())); assert_eq!(sessions[0]["isCurrent"], Value::Bool(true)); assert_eq!( sessions[0]["ipMasked"], Value::String("203.0.*.*".to_string()) ); assert_eq!( sessions[0]["sessionIds"] .as_array() .expect("session ids should exist") .len(), 2 ); } #[tokio::test] async fn password_entry_reuses_same_user_for_same_phone() { let state = AppState::new(AppConfig::default()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138014", TEST_PASSWORD).await; let app = build_router(state); let first_response = password_login_request(app.clone(), "13800138014", TEST_PASSWORD).await; let first_body = first_response .into_body() .collect() .await .expect("first body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first payload should be json"); let second_response = password_login_request(app, "13800138014", TEST_PASSWORD).await; let second_body = second_response .into_body() .collect() .await .expect("second body should collect") .to_bytes(); let second_payload: Value = serde_json::from_slice(&second_body).expect("second payload should be json"); assert_eq!(first_payload["user"]["id"], Value::String(seed_user.id)); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); } #[tokio::test] async fn password_entry_rejects_wrong_password() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138015", TEST_PASSWORD).await; let app = build_router(state); let response = password_login_request(app, "13800138015", "secret999").await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn password_reset_allows_login_with_new_password_only() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let state = AppState::new(config).expect("state should build"); seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; let app = build_router(state); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138026", "scene": "reset_password" }) .to_string(), )) .expect("reset code request should build"), ) .await .expect("reset code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let reset_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/password/reset") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138026", "code": "123456", "newPassword": "secret456" }) .to_string(), )) .expect("reset password request should build"), ) .await .expect("reset password request should succeed"); assert_eq!(reset_response.status(), StatusCode::OK); assert!( reset_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let old_password_response = password_login_request(app.clone(), "13800138026", TEST_PASSWORD).await; assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED); let new_password_response = password_login_request(app, "13800138026", "secret456").await; assert_eq!(new_password_response.status(), StatusCode::OK); } #[tokio::test] async fn password_change_allows_login_with_new_password_only() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await; let refresh_cookie = login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist") .to_string(); let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let token = read_access_token(&login_body); let change_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/password/change") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "currentPassword": TEST_PASSWORD, "newPassword": "secret456" }) .to_string(), )) .expect("change password request should build"), ) .await .expect("change password request should succeed"); assert_eq!(change_response.status(), StatusCode::OK); assert!( change_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); let old_me_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("me request should build"), ) .await .expect("me request should succeed"); assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED); let old_refresh_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", refresh_cookie) .body(Body::empty()) .expect("refresh request should build"), ) .await .expect("refresh request should succeed"); assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED); let old_password_response = password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await; assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED); let new_password_response = password_login_request(app, "13800138027", "secret456").await; assert_eq!(new_password_response.status(), StatusCode::OK); } #[tokio::test] async fn password_entry_rejects_email_or_username_identifier() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "user@example.com", "password": TEST_PASSWORD }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn auth_me_returns_current_user_and_available_login_methods() { let config = AppConfig { sms_auth_enabled: true, wechat_auth_enabled: true, ..AppConfig::default() }; let state = AppState::new(config).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let token = read_access_token(&login_body); let response = app .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["user"]["id"], Value::String(seed_user.id)); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["phone", "password", "wechat"]) ); } #[tokio::test] async fn auth_me_returns_unauthorized_when_user_missing() { let config = AppConfig::default(); let state = AppState::new(config).expect("state should build"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_missing".to_string(), session_id: "sess_missing".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some("ghost".to_string()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); let app = build_router(state); let response = app .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn refresh_session_rotates_cookie_and_returns_new_access_token() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await; let first_cookie = login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist") .to_string(); let refresh_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", first_cookie.clone()) .body(Body::empty()) .expect("refresh request should build"), ) .await .expect("refresh request should succeed"); assert_eq!(refresh_response.status(), StatusCode::OK); let second_cookie = refresh_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("rotated refresh cookie should exist") .to_string(); assert_ne!(first_cookie, second_cookie); let refresh_body = refresh_response .into_body() .collect() .await .expect("refresh body should collect") .to_bytes(); let refresh_payload: Value = serde_json::from_slice(&refresh_body).expect("refresh payload should be json"); assert!(refresh_payload["token"].as_str().is_some()); let stale_refresh_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", first_cookie) .body(Body::empty()) .expect("stale refresh request should build"), ) .await .expect("stale refresh request should succeed"); assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED); assert!( stale_refresh_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); } #[tokio::test] async fn refresh_session_rejects_missing_cookie_and_clears_cookie() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!( response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); } #[tokio::test] async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await; let app = build_router(state); let first_login_response = password_login_request_with_client( app.clone(), "13800138030", TEST_PASSWORD, "revoke-current-device", "203.0.113.30", ) .await; let first_cookie = first_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("first cookie should exist") .to_string(); let first_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let first_access_token = read_access_token(&first_body); let second_login_response = password_login_request_with_client( app.clone(), "13800138030", TEST_PASSWORD, "revoke-remote-device", "203.0.113.31", ) .await; let second_cookie = second_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("second cookie should exist") .to_string(); let second_body = second_login_response .into_body() .collect() .await .expect("second login body should collect") .to_bytes(); let second_access_token = read_access_token(&second_body); let remote_sessions_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/sessions") .header("authorization", format!("Bearer {first_access_token}")) .header("cookie", first_cookie.clone()) .body(Body::empty()) .expect("sessions request should build"), ) .await .expect("sessions request should succeed"); assert_eq!(remote_sessions_response.status(), StatusCode::OK); let remote_sessions_body = remote_sessions_response .into_body() .collect() .await .expect("sessions body should collect") .to_bytes(); let remote_sessions_payload: Value = serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json"); let remote_session_id = remote_sessions_payload["sessions"] .as_array() .expect("sessions should be array") .iter() .find(|session| session["isCurrent"] == Value::Bool(false)) .and_then(|session| session["sessionId"].as_str()) .expect("remote session id should exist") .to_string(); let revoke_response = app .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/api/auth/sessions/{remote_session_id}/revoke")) .header("authorization", format!("Bearer {first_access_token}")) .header("cookie", first_cookie) .body(Body::empty()) .expect("revoke request should build"), ) .await .expect("revoke request should succeed"); assert_eq!(revoke_response.status(), StatusCode::OK); let current_me_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {first_access_token}")) .body(Body::empty()) .expect("current me request should build"), ) .await .expect("current me request should succeed"); assert_eq!(current_me_response.status(), StatusCode::OK); let remote_me_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {second_access_token}")) .body(Body::empty()) .expect("remote me request should build"), ) .await .expect("remote me request should succeed"); assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED); let remote_refresh_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", second_cookie) .body(Body::empty()) .expect("remote refresh request should build"), ) .await .expect("remote refresh request should succeed"); assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_clears_cookie_and_invalidates_current_access_token() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await; let refresh_cookie = login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist") .to_string(); let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("login payload should be json"); let access_token = login_payload["token"] .as_str() .expect("token should exist") .to_string(); let logout_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/logout") .header("authorization", format!("Bearer {access_token}")) .header("cookie", refresh_cookie) .body(Body::empty()) .expect("logout request should build"), ) .await .expect("logout request should succeed"); assert_eq!(logout_response.status(), StatusCode::OK); assert!( logout_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); let logout_body = logout_response .into_body() .collect() .await .expect("logout body should collect") .to_bytes(); let logout_payload: Value = serde_json::from_slice(&logout_body).expect("logout payload should be json"); assert_eq!(logout_payload["ok"], Value::Bool(true)); let me_response = app .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {access_token}")) .body(Body::empty()) .expect("me request should build"), ) .await .expect("me request should succeed"); assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await; let refresh_cookie = login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("refresh cookie should exist") .to_string(); let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("login payload should be json"); let access_token = login_payload["token"] .as_str() .expect("token should exist") .to_string(); let logout_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/logout") .header("authorization", format!("Bearer {access_token}")) .body(Body::empty()) .expect("logout request should build"), ) .await .expect("logout request should succeed"); assert_eq!(logout_response.status(), StatusCode::OK); assert!( logout_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); let refresh_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", refresh_cookie) .body(Body::empty()) .expect("refresh request should build"), ) .await .expect("refresh request should succeed"); assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_all_clears_cookie_and_invalidates_all_sessions() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await; let app = build_router(state); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .body(Body::from( serde_json::json!({ "phone": "13800138020", "password": TEST_PASSWORD }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login should succeed"); let first_refresh_cookie = first_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("first refresh cookie should exist") .to_string(); let first_login_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let first_login_payload: Value = serde_json::from_slice(&first_login_body).expect("first login payload should be json"); let first_access_token = first_login_payload["token"] .as_str() .expect("first access token should exist") .to_string(); let second_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .header("x-client-runtime", "firefox") .header("x-client-instance-id", "logout-all-instance-002") .body(Body::from( serde_json::json!({ "phone": "13800138020", "password": TEST_PASSWORD }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login should succeed"); let second_refresh_cookie = second_login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .expect("second refresh cookie should exist") .to_string(); let logout_all_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/logout-all") .header("authorization", format!("Bearer {first_access_token}")) .header("cookie", first_refresh_cookie.clone()) .body(Body::empty()) .expect("logout-all request should build"), ) .await .expect("logout-all request should succeed"); assert_eq!(logout_all_response.status(), StatusCode::OK); assert!( logout_all_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); let logout_all_body = logout_all_response .into_body() .collect() .await .expect("logout-all body should collect") .to_bytes(); let logout_all_payload: Value = serde_json::from_slice(&logout_all_body).expect("logout-all payload should be json"); assert_eq!(logout_all_payload["ok"], Value::Bool(true)); let me_response = app .clone() .oneshot( Request::builder() .uri("/api/auth/me") .header("authorization", format!("Bearer {first_access_token}")) .body(Body::empty()) .expect("me request should build"), ) .await .expect("me request should succeed"); assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); let first_refresh_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", first_refresh_cookie) .body(Body::empty()) .expect("first refresh request should build"), ) .await .expect("first refresh request should succeed"); assert_eq!(first_refresh_response.status(), StatusCode::UNAUTHORIZED); let second_refresh_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/refresh") .header("cookie", second_refresh_cookie) .body(Body::empty()) .expect("second refresh request should build"), ) .await .expect("second refresh request should succeed"); assert_eq!(second_refresh_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { let state = AppState::new(AppConfig::default()).expect("state should build"); seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await; let app = build_router(state); let login_response = password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("login payload should be json"); let access_token = login_payload["token"] .as_str() .expect("access token should exist") .to_string(); let logout_all_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/logout-all") .header("authorization", format!("Bearer {access_token}")) .body(Body::empty()) .expect("logout-all request should build"), ) .await .expect("logout-all request should succeed"); assert_eq!(logout_all_response.status(), StatusCode::OK); assert!( logout_all_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); } #[tokio::test] async fn admin_page_route_is_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/admin") .body(Body::empty()) .expect("admin page request should build"), ) .await .expect("admin page request should succeed"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn admin_login_returns_token_when_configured() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "root", "password": "secret123" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert!(payload["token"].as_str().is_some()); assert_eq!( payload["admin"]["username"], Value::String("root".to_string()) ); } #[tokio::test] async fn admin_route_rejects_regular_user_token() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let state = AppState::new(config).expect("state should build"); seed_phone_user_with_password(&state, "13800138022", TEST_PASSWORD).await; let app = build_router(state.clone()); let login_response = password_login_request(app.clone(), "13800138022", TEST_PASSWORD).await; let login_body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let login_payload: Value = serde_json::from_slice(&login_body).expect("login payload should be json"); let access_token = login_payload["token"] .as_str() .expect("token should exist") .to_string(); let response = app .oneshot( Request::builder() .uri("/admin/api/me") .header("authorization", format!("Bearer {access_token}")) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::FORBIDDEN); } /// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。 #[tokio::test] async fn admin_creation_entry_banners_route_saves_html_form_payload() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let admin_token = read_admin_access_token(app.clone()).await; let response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/creation-entry/config/banners") .header("authorization", format!("Bearer {admin_token}")) .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "eventBannersJson": serde_json::json!([ { "title": "后台表单公告", "htmlCode": "
入口公告 HTML
" } ]).to_string() }) .to_string(), )) .expect("banners request should build"), ) .await .expect("banners request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("banners body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json"); assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告"); assert_eq!(payload["eventBanners"][0]["renderMode"], "html"); assert_eq!( payload["eventBanners"][0]["htmlCode"], "
入口公告 HTML
" ); } /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 #[tokio::test] async fn admin_creation_entry_banners_route_rejects_script_html() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let admin_token = read_admin_access_token(app.clone()).await; let response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/creation-entry/config/banners") .header("authorization", format!("Bearer {admin_token}")) .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "eventBannersJson": serde_json::json!([ { "title": "危险公告", "htmlCode": "" } ]).to_string() }) .to_string(), )) .expect("banners request should build"), ) .await .expect("banners request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn admin_debug_http_can_probe_healthz_when_authenticated() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); let local_addr = listener .local_addr() .expect("listener should expose local addr"); config.bind_host = "127.0.0.1".to_string(); config.bind_port = local_addr.port(); let app = build_router(AppState::new(config).expect("state should build")); let server = tokio::spawn(async move { axum::serve(listener, app) .await .expect("test admin server should serve"); }); let http_client = Client::new(); let base_url = format!("http://{}", local_addr); let login_payload: Value = http_client .post(format!("{base_url}/admin/api/login")) .json(&serde_json::json!({ "username": "root", "password": "secret123" })) .send() .await .expect("login request should succeed") .json() .await .expect("login payload should be json"); let access_token = login_payload["token"] .as_str() .expect("token should exist") .to_string(); let payload: Value = http_client .post(format!("{base_url}/admin/api/debug/http")) .bearer_auth(access_token) .json(&serde_json::json!({ "method": "GET", "path": "/healthz", "headers": [], "body": "" })) .send() .await .expect("debug request should succeed") .json() .await .expect("debug payload should be json"); server.abort(); let _ = server.await; assert_eq!(payload["status"], Value::Number(200.into())); } #[tokio::test] async fn admin_debug_http_requires_authentication() { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let debug_response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/debug/http") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "method": "GET", "path": "/healthz", "headers": [], "body": "" }) .to_string(), )) .expect("debug request should build"), ) .await .expect("debug request should succeed"); assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn visual_novel_creation_route_requires_authentication() { let state = AppState::new(AppConfig::default()).expect("state should build"); state.set_test_creation_entry_route_enabled("visual-novel", true); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/creation/visual-novel/sessions") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "sourceMode": "idea", "seedText": "雨夜书店", "sourceAssetIds": [] }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn visual_novel_forbidden_playback_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let legacy_playback_segment = concat!("re", "play"); for path in [ format!("/api/creation/visual-novel/{legacy_playback_segment}"), format!("/api/runtime/visual-novel/{legacy_playback_segment}"), format!("/api/runtime/visual-novel/{legacy_playback_segment}s"), format!("/api/visual/{legacy_playback_segment}"), format!("/api/galgame/{legacy_playback_segment}"), format!("/api/txt/{legacy_playback_segment}"), ] { let response = app .clone() .oneshot( Request::builder() .uri(path.as_str()) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); } } }