ee2cad377f
在图片工具栏接入完美像素交互、占位流程与历史保护 新增严格像素网格检测和登录态同步处理接口 补齐素材类型校验、持久化边界、并发限制及技术文档 修复 Windows rlib 门禁长文件名解析
5549 lines
211 KiB
Rust
5549 lines
211 KiB
Rust
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,
|
||
backpressure::limit_concurrent_requests,
|
||
error_middleware::normalize_error_response,
|
||
http_error::AppError,
|
||
modules,
|
||
request_context::{RequestContext, attach_request_context, resolve_request_id},
|
||
response_headers::propagate_request_id_header,
|
||
state::{AppState, BackpressureState},
|
||
telemetry::record_http_observability,
|
||
tracking::record_route_tracking_event_after_success,
|
||
wechat::pay::{
|
||
handle_wechat_pay_notify, handle_wechat_pay_refund_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::external_api::router(state.clone()))
|
||
.merge(modules::frontend_runtime_config::router(state.clone()))
|
||
.merge(modules::assets::router(state.clone()))
|
||
.merge(modules::editor_project::router(state.clone()))
|
||
.merge(modules::platform::router(state.clone()))
|
||
.merge(modules::external_generation::router(state.clone()))
|
||
.merge(modules::platform_support::router(state.clone()))
|
||
.route(
|
||
"/api/profile/recharge/wechat/notify",
|
||
post(handle_wechat_pay_notify),
|
||
)
|
||
.route(
|
||
"/api/profile/recharge/wechat/refund-notify",
|
||
post(handle_wechat_pay_refund_notify),
|
||
)
|
||
.route(
|
||
"/api/profile/recharge/wechat/virtual-notify",
|
||
get(handle_wechat_virtual_payment_message_push_verify)
|
||
.post(handle_wechat_virtual_payment_notify),
|
||
)
|
||
// 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<Body>| {
|
||
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<Body>| {
|
||
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<str>,
|
||
}
|
||
|
||
async fn spacetime_unavailable_handler(
|
||
Extension(state): Extension<SpacetimeUnavailableState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
request: Request<Body>,
|
||
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::<AuthenticatedAccessToken>()
|
||
.cloned();
|
||
record_route_tracking_event_after_success(
|
||
&state,
|
||
&request_context,
|
||
&method,
|
||
&path,
|
||
response.status(),
|
||
authenticated.as_ref(),
|
||
)
|
||
.await;
|
||
response
|
||
}
|
||
|
||
#[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 spacetime_client::{SpacetimeClientHealthSnapshot, SpacetimeClientStage};
|
||
use time::OffsetDateTime;
|
||
use tokio::net::TcpListener;
|
||
use tower::ServiceExt;
|
||
|
||
use crate::{
|
||
config::{AppConfig, ExternalGenerationMode},
|
||
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!({
|
||
"purePhoneNumber": 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!({
|
||
"purePhoneNumber": phone_number,
|
||
"password": password
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("password login request should build"),
|
||
)
|
||
.await
|
||
.expect("password login request should succeed")
|
||
}
|
||
|
||
#[cfg(any())]
|
||
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"))
|
||
}
|
||
|
||
#[cfg(any())]
|
||
fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request<Body> {
|
||
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-06-16")
|
||
);
|
||
assert_eq!(
|
||
response
|
||
.headers()
|
||
.get("x-route-version")
|
||
.and_then(|value| value.to_str().ok()),
|
||
Some("2026-06-16")
|
||
);
|
||
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 frontend_runtime_config_defaults_agent_sidebar_to_disabled() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/runtime/frontend-config")
|
||
.body(Body::empty())
|
||
.expect("frontend config request should build"),
|
||
)
|
||
.await
|
||
.expect("frontend config request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let payload = read_json_response(response).await;
|
||
assert_eq!(
|
||
payload["imageEditorAgentSidebarEnabled"],
|
||
Value::Bool(false)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn frontend_runtime_config_returns_agent_sidebar_env_flag() {
|
||
let config = AppConfig {
|
||
image_editor_agent_sidebar_enabled: true,
|
||
..AppConfig::default()
|
||
};
|
||
let app = build_router(AppState::new(config).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/runtime/frontend-config")
|
||
.body(Body::empty())
|
||
.expect("frontend config request should build"),
|
||
)
|
||
.await
|
||
.expect("frontend config request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let payload = read_json_response(response).await;
|
||
assert_eq!(payload["imageEditorAgentSidebarEnabled"], Value::Bool(true));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled() {
|
||
let config = AppConfig {
|
||
image_editor_agent_sidebar_enabled: true,
|
||
..AppConfig::default()
|
||
};
|
||
let state = AppState::new(config).expect("state should build");
|
||
state.set_test_feature_gate_config(vec![test_feature_gate(
|
||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
|
||
)]);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/runtime/frontend-config")
|
||
.body(Body::empty())
|
||
.expect("frontend config request should build"),
|
||
)
|
||
.await
|
||
.expect("frontend config request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let payload = read_json_response(response).await;
|
||
assert_eq!(
|
||
payload["imageEditorAgentSidebarEnabled"],
|
||
Value::Bool(false)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn frontend_runtime_config_allows_whitelisted_agent_sidebar_user() {
|
||
let config = AppConfig {
|
||
image_editor_agent_sidebar_enabled: true,
|
||
..AppConfig::default()
|
||
};
|
||
let state = AppState::new(config).expect("state should build");
|
||
let user = seed_phone_user_with_password(&state, "13800138192", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &user, "sess_agent_sidebar_gate");
|
||
let mut gate = test_feature_gate(module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY);
|
||
gate.allow_user_ids = vec![user.id.clone()];
|
||
state.set_test_feature_gate_config(vec![gate]);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/runtime/frontend-config")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.body(Body::empty())
|
||
.expect("frontend config request should build"),
|
||
)
|
||
.await
|
||
.expect("frontend config request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let payload = read_json_response(response).await;
|
||
assert_eq!(payload["imageEditorAgentSidebarEnabled"], Value::Bool(true));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user() {
|
||
let config = AppConfig {
|
||
image_editor_agent_sidebar_enabled: true,
|
||
..AppConfig::default()
|
||
};
|
||
let state = AppState::new(config).expect("state should build");
|
||
let user = seed_phone_user_with_password(&state, "13800138193", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &user, "sess_agent_sidebar_denied");
|
||
state.set_test_feature_gate_config(vec![test_feature_gate(
|
||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
|
||
)]);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("GET")
|
||
.uri("/api/editor/projects/project-1/agent-conversations")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.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"],
|
||
"image_editor_agent_sidebar_disabled"
|
||
);
|
||
assert_eq!(
|
||
body["error"]["details"]["gateKey"],
|
||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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()
|
||
.method("POST")
|
||
.uri("/api/runtime/puzzle/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"], "puzzle");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn creation_entry_feature_gate_denies_anonymous_user() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state.set_test_feature_gate_config(vec![test_feature_gate("creation-entry:puzzle")]);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/puzzle/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"], "puzzle");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn creation_entry_feature_gate_allows_whitelisted_user() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let user = seed_phone_user_with_password(&state, "13800138191", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &user, "sess_creation_entry_gate");
|
||
let mut gate = test_feature_gate("creation-entry:puzzle");
|
||
gate.allow_user_ids = vec![user.id.clone()];
|
||
state.set_test_feature_gate_config(vec![gate]);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/puzzle/agent/sessions")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.body(Body::empty())
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn disabled_creation_entry_does_not_block_published_runtime_routes() {
|
||
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()
|
||
.method("POST")
|
||
.uri("/api/runtime/puzzle/runs")
|
||
.body(Body::empty())
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||
let body = read_json_response(response).await;
|
||
assert_ne!(
|
||
body["error"]["details"]["reason"],
|
||
"creation_entry_disabled"
|
||
);
|
||
}
|
||
|
||
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
|
||
module_runtime::FeatureGateConfigSnapshot {
|
||
gate_key: gate_key.to_string(),
|
||
enabled: true,
|
||
rollout_percent: 0,
|
||
allow_user_ids: vec![],
|
||
allow_user_tags: vec![],
|
||
deny_user_ids: vec![],
|
||
description: String::new(),
|
||
updated_at_micros: 1,
|
||
}
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn disabled_public_work_like_returns_service_unavailable() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state.set_test_public_work_interaction_enabled(
|
||
"puzzle",
|
||
crate::creation_entry_config::PublicWorkInteractionAction::Like,
|
||
false,
|
||
);
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/puzzle/gallery/profile-1/like")
|
||
.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"],
|
||
"public_work_interaction_disabled"
|
||
);
|
||
assert_eq!(body["error"]["details"]["sourceType"], "puzzle");
|
||
assert_eq!(body["error"]["details"]["action"], "like");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn disabled_rpg_creation_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");
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn role_asset_workflow_returns_default_prompts_without_oss_cache() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/custom-world/asset-studio/role/hero-01/workflow")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::json!({
|
||
"role": {
|
||
"id": "hero-01",
|
||
"name": "沈砺",
|
||
"title": "灰炬向导",
|
||
"role": "边路同行者",
|
||
"actionDescription": "持灯快步穿过灰烬街巷"
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("workflow request should build"),
|
||
)
|
||
.await
|
||
.expect("workflow request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let body = read_json_response(response).await;
|
||
assert_eq!(body["ok"], Value::Bool(true));
|
||
assert_eq!(body["cache"], Value::Null);
|
||
assert!(
|
||
body["workflow"]["animationPromptText"]
|
||
.as_str()
|
||
.unwrap_or_default()
|
||
.contains("持灯快步穿过灰烬街巷")
|
||
);
|
||
}
|
||
|
||
#[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 readyz_reports_readiness_and_draining_state() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let app = build_router(state.clone());
|
||
|
||
let ready_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/readyz")
|
||
.header("x-request-id", "req-ready")
|
||
.body(Body::empty())
|
||
.expect("readyz request should build"),
|
||
)
|
||
.await
|
||
.expect("readyz request should succeed");
|
||
assert_eq!(ready_response.status(), StatusCode::OK);
|
||
let ready_body = read_json_response(ready_response).await;
|
||
assert_eq!(ready_body["ok"], Value::Bool(true));
|
||
assert_eq!(ready_body["ready"], Value::Bool(true));
|
||
|
||
state.mark_not_ready();
|
||
let draining_response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/readyz")
|
||
.header("x-request-id", "req-draining")
|
||
.body(Body::empty())
|
||
.expect("readyz request should build"),
|
||
)
|
||
.await
|
||
.expect("readyz request should succeed");
|
||
assert_eq!(draining_response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||
let draining_body = read_json_response(draining_response).await;
|
||
assert_eq!(
|
||
draining_body["error"]["details"]["reason"],
|
||
"api_server_draining"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn readyz_reports_spacetime_health_stage() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state.set_test_spacetime_health(SpacetimeClientHealthSnapshot {
|
||
ok: false,
|
||
stage: SpacetimeClientStage::ProcedureResult,
|
||
checked_at_micros: 1_713_680_000_000_000,
|
||
elapsed_ms: 2_000,
|
||
timeout_ms: 2_000,
|
||
error: Some("SpacetimeDB procedure 调用超时".to_string()),
|
||
last_success_at_micros: Some(1_713_679_999_000_000),
|
||
last_error: Some("SpacetimeDB procedure 调用超时".to_string()),
|
||
});
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/readyz")
|
||
.header("x-request-id", "req-ready-spacetime")
|
||
.body(Body::empty())
|
||
.expect("readyz request should build"),
|
||
)
|
||
.await
|
||
.expect("readyz request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||
let body = read_json_response(response).await;
|
||
assert_eq!(body["error"]["details"]["reason"], "spacetime_unhealthy");
|
||
assert_eq!(
|
||
body["error"]["details"]["spacetime"]["stage"],
|
||
"procedure_result"
|
||
);
|
||
assert_eq!(
|
||
body["error"]["details"]["spacetime"]["timeoutMs"],
|
||
Value::from(2_000)
|
||
);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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::<Value>(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::<Value>(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)
|
||
);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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 retired_creation_template_routes_are_not_mounted() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
for (method, uri) in [
|
||
("POST", "/api/runtime/custom-world/agent/sessions"),
|
||
("POST", "/api/runtime/puzzle/agent/sessions"),
|
||
("POST", "/api/creation/puzzle-clear/sessions"),
|
||
("POST", "/api/runtime/big-fish/agent/sessions"),
|
||
("POST", "/api/creation/wooden-fish/sessions"),
|
||
("POST", "/api/creation/square-hole/sessions"),
|
||
("POST", "/api/creation/visual-novel/sessions"),
|
||
("POST", "/api/creation/bark-battle/drafts"),
|
||
("POST", "/api/creation/edutainment/baby-object-match/assets"),
|
||
("POST", "/api/runtime/creative-agent/sessions"),
|
||
("GET", "/api/creation-entry/config"),
|
||
("GET", "/api/public-works/PZ-12345678"),
|
||
("POST", "/api/auth/runtime-guest-token"),
|
||
("GET", "/api/runtime/save/snapshot"),
|
||
("GET", "/api/profile/browse-history"),
|
||
] {
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method(method)
|
||
.uri(uri)
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::empty())
|
||
.expect("retired creation route request should build"),
|
||
)
|
||
.await
|
||
.expect("retired creation route request should be handled");
|
||
|
||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn platform_runtime_settings_route_remains_mounted() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("GET")
|
||
.uri("/api/runtime/settings")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::empty())
|
||
.expect("platform runtime settings request should build"),
|
||
)
|
||
.await
|
||
.expect("platform runtime settings request should be handled");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[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))
|
||
);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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"));
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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"));
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body");
|
||
let app = build_router(state);
|
||
let request_body = format!(
|
||
"{{\"templateId\":\"wooden-fish\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
|
||
"A".repeat(3 * 1024 * 1024)
|
||
);
|
||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/creation/wooden-fish/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("hitSoundAsset") || body_text.contains("missing field"),
|
||
"handler should parse the oversized wooden fish payload before rejecting invalid JSON fields: {body_text}"
|
||
);
|
||
assert!(!body_text.contains("length limit exceeded"));
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body");
|
||
let app = build_router(state);
|
||
let request_body = format!(
|
||
"{{\"actionType\":\"replace-hit-sound\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
|
||
"A".repeat(3 * 1024 * 1024)
|
||
);
|
||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/creation/wooden-fish/sessions/wooden-fish-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("hitSoundAsset") || body_text.contains("missing field"),
|
||
"handler should parse the oversized wooden fish action payload before rejecting invalid JSON fields: {body_text}"
|
||
);
|
||
assert!(!body_text.contains("length limit exceeded"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_character_animation_rejects_inline_data_url_before_queueing() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"sourceLayerId": "layer-character-large",
|
||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||
"sourceWidth": 1024,
|
||
"sourceHeight": 1024,
|
||
"promptText": "待机呼吸循环。",
|
||
"resolution": "480p",
|
||
"ratio": "same",
|
||
"frameCount": 32,
|
||
"durationSeconds": 4,
|
||
"model": "seedance2.0-fast"
|
||
})
|
||
.to_string();
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/character-animations/generations")
|
||
.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("先上传 OSS"),
|
||
"handler should reject inline character sources: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_character_animation_requires_bearer_auth() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
let request_body = serde_json::json!({
|
||
"sourceLayerId": "layer-character",
|
||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||
"sourceWidth": 1024,
|
||
"sourceHeight": 1024,
|
||
"promptText": "待机呼吸循环。",
|
||
"resolution": "480p",
|
||
"ratio": "same",
|
||
"frameCount": 32,
|
||
"durationSeconds": 4,
|
||
"model": "seedance2.0-fast"
|
||
})
|
||
.to_string();
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/character-animations/generations")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(request_body))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138188", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_body");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||
"aspectRatio": "1:1",
|
||
"imageSize": "1K",
|
||
})
|
||
.to_string();
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/ui-designs/assets/extractions")
|
||
.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("先上传 OSS"),
|
||
"handler should reject inline UI design sources: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_image_generation_rejects_inline_data_url_before_queueing() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"prompt": "生成宣发素材",
|
||
"kind": "publication-material",
|
||
"size": "720x540",
|
||
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
|
||
})
|
||
.to_string();
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/generations")
|
||
.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("先上传 OSS"),
|
||
"handler should reject inline editor references: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138228", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_body");
|
||
let app = build_router(state);
|
||
let requests = [
|
||
(
|
||
"/api/editor/images/generations",
|
||
serde_json::json!({
|
||
"prompt": "生成像素图片",
|
||
"style": {"unexpected": true},
|
||
}),
|
||
),
|
||
(
|
||
"/api/editor/icon-spritesheets/generations",
|
||
serde_json::json!({
|
||
"referenceImageSrc": "/generated-images/editor/icon-spec.png",
|
||
"iconDescriptions": ["宝箱"],
|
||
"style": ["pixelArt"],
|
||
}),
|
||
),
|
||
];
|
||
|
||
for (path, request_body) in requests {
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri(path)
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(request_body.to_string()))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(
|
||
response.status(),
|
||
StatusCode::BAD_REQUEST,
|
||
"{path} should normalize JSON data errors to 400"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_pixel_art_snap_requires_bearer_auth() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
let request_body = serde_json::json!({
|
||
"sourceImageSrc": "editor-resource-source",
|
||
"projectId": "proj-source",
|
||
"canvasCompletion": {
|
||
"dialogId": "dialog-pixel-art",
|
||
"title": "完美像素",
|
||
"placeholder": {
|
||
"x": 0,
|
||
"y": 0,
|
||
"width": 128,
|
||
"height": 128,
|
||
"originalWidth": 128,
|
||
"originalHeight": 128
|
||
}
|
||
}
|
||
});
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/pixel-art-snaps")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(request_body.to_string()))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_pixel_art_snap_rejects_inline_source_before_processing() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||
"projectId": "proj-source",
|
||
"sourceResourceId": "editor-resource-source",
|
||
"assetKind": "character",
|
||
"generationInputs": { "fields": [], "references": [] },
|
||
"canvasCompletion": {
|
||
"dialogId": "dialog-pixel-art",
|
||
"title": "完美像素",
|
||
"placeholder": {
|
||
"x": 0,
|
||
"y": 0,
|
||
"width": 128,
|
||
"height": 128,
|
||
"originalWidth": 128,
|
||
"originalHeight": 128
|
||
}
|
||
}
|
||
});
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/pixel-art-snaps")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(request_body.to_string()))
|
||
.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("先上传 OSS"),
|
||
"handler should reject inline pixel-art sources: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"sourceImageSrc": "editor-resource-source",
|
||
"projectId": "proj-does-not-exist",
|
||
"canvasCompletion": {
|
||
"title": "完美像素",
|
||
"placeholder": {
|
||
"x": 0,
|
||
"y": 0,
|
||
"width": 128,
|
||
"height": 128,
|
||
"originalWidth": 128,
|
||
"originalHeight": 128
|
||
}
|
||
}
|
||
});
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/pixel-art-snaps")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(request_body.to_string()))
|
||
.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("canvasCompletion.dialogId"),
|
||
"handler should reject missing dialog ids before project lookup: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138229", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_media_type");
|
||
let app = build_router(state);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/generations")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.body(Body::from(r#"{"prompt":"生成图片","style":"pixelArt"}"#))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_image_edit_rejects_inline_data_url_before_queueing() {
|
||
let state = AppState::new(AppConfig {
|
||
external_generation_mode: ExternalGenerationMode::Queue,
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let seed_user = seed_phone_user_with_password(&state, "13800138029", TEST_PASSWORD).await;
|
||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_edit_body");
|
||
let app = build_router(state);
|
||
let request_body = serde_json::json!({
|
||
"prompt": "快速编辑图片",
|
||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||
"size": "1024x1024",
|
||
"model": "gpt-image-2"
|
||
})
|
||
.to_string();
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/editor/images/edits")
|
||
.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("先上传 OSS"),
|
||
"handler should reject inline editor edit sources: {body_text}"
|
||
);
|
||
}
|
||
|
||
#[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!(payload["user"].get("createdAt").is_none());
|
||
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!({
|
||
"countryCode": "86",
|
||
"purePhoneNumber": "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_foreign_country_code() {
|
||
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!({
|
||
"countryCode": "1",
|
||
"purePhoneNumber": "12025550123",
|
||
"scene": "login"
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||
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["error"]["code"],
|
||
Value::String("BAD_REQUEST".to_string())
|
||
);
|
||
assert_eq!(
|
||
payload["error"]["message"],
|
||
Value::String("仅支持中国大陆手机号(+86)".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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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::<u64>().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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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"].get("createdAt").is_none());
|
||
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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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::<std::collections::HashMap<String, String>>();
|
||
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_label() {
|
||
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",
|
||
"displayName": "微信旅人"
|
||
})
|
||
.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["created"], Value::Bool(true));
|
||
assert_eq!(
|
||
login_payload["user"]["loginMethod"],
|
||
Value::String("wechat".to_string())
|
||
);
|
||
assert_eq!(
|
||
login_payload["user"]["wechatDisplayName"],
|
||
Value::String("微信旅人".to_string())
|
||
);
|
||
assert_eq!(
|
||
login_payload["user"]["displayName"],
|
||
Value::String("微信旅人".to_string())
|
||
);
|
||
assert_eq!(
|
||
login_payload["user"]["wechatAccount"],
|
||
Value::String("wx-mini-code-001".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]["clientLabel"],
|
||
Value::String("微信小程序 / iPhone".to_string())
|
||
);
|
||
assert_eq!(
|
||
sessions_payload["sessions"][0]["sessionCount"],
|
||
Value::Number(1.into())
|
||
);
|
||
assert_eq!(
|
||
sessions_payload["sessions"][0]["isCurrent"],
|
||
Value::Bool(true)
|
||
);
|
||
assert_eq!(
|
||
sessions_payload["sessions"][0]["sessionIds"]
|
||
.as_array()
|
||
.expect("session ids should exist")
|
||
.len(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[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",
|
||
"displayName": "微信旅人"
|
||
})
|
||
.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())
|
||
);
|
||
assert_eq!(login_payload["created"], Value::Bool(true));
|
||
|
||
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",
|
||
"displayName": "微信旅人"
|
||
})
|
||
.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_eq!(
|
||
bind_payload["user"]["phoneNumber"],
|
||
Value::String("+8613800138000".to_string())
|
||
);
|
||
assert_eq!(
|
||
bind_payload["user"]["wechatAccount"],
|
||
Value::String("wx-mini-code-bind-001".to_string())
|
||
);
|
||
assert_eq!(
|
||
bind_payload["user"]["wechatDisplayName"],
|
||
Value::String("微信旅人".to_string())
|
||
);
|
||
assert_eq!(
|
||
bind_payload["user"]["displayName"],
|
||
Value::String("微信旅人".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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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::<std::collections::HashMap<String, String>>();
|
||
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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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 wechat_bind_start_binds_oauth_identity_to_current_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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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 phone_token = phone_login_payload["token"]
|
||
.as_str()
|
||
.expect("phone token should exist");
|
||
|
||
let bind_start_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/auth/wechat/bind-start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5")
|
||
.header("authorization", format!("Bearer {phone_token}"))
|
||
.header(
|
||
"user-agent",
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.50",
|
||
)
|
||
.header("host", "localhost:3000")
|
||
.body(Body::empty())
|
||
.expect("wechat bind start request should build"),
|
||
)
|
||
.await
|
||
.expect("wechat bind start should succeed");
|
||
assert_eq!(bind_start_response.status(), StatusCode::OK);
|
||
let bind_start_body = bind_start_response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("wechat bind start body should collect")
|
||
.to_bytes();
|
||
let bind_start_payload: Value = serde_json::from_slice(&bind_start_body)
|
||
.expect("wechat bind start payload should be json");
|
||
let authorization_url = bind_start_payload["authorizationUrl"]
|
||
.as_str()
|
||
.expect("wechat bind 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 callback_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri(format!(
|
||
"/api/auth/wechat/callback?state={callback_state}&mock_code=wx-bind-code"
|
||
))
|
||
.header(
|
||
"user-agent",
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.50",
|
||
)
|
||
.header("host", "localhost:3000")
|
||
.body(Body::empty())
|
||
.expect("wechat callback request should build"),
|
||
)
|
||
.await
|
||
.expect("wechat 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");
|
||
assert!(location.starts_with("/play?clientRuntime=wechat_h5#"));
|
||
assert!(location.contains("auth_provider=wechat"));
|
||
assert!(location.contains("auth_binding_status=active"));
|
||
|
||
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::<std::collections::HashMap<String, String>>();
|
||
let bound_token = auth_params
|
||
.get("auth_token")
|
||
.expect("bound auth token should exist");
|
||
|
||
let me_response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/auth/me")
|
||
.header("authorization", format!("Bearer {bound_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"]["id"], phone_user_id);
|
||
assert_eq!(
|
||
me_payload["user"]["loginMethod"],
|
||
Value::String("phone".to_string())
|
||
);
|
||
assert_eq!(me_payload["user"]["wechatBound"], Value::Bool(true));
|
||
assert_eq!(
|
||
me_payload["user"]["wechatAccount"],
|
||
Value::String("wx-bind-code".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn auth_sessions_returns_multi_device_session_summaries() {
|
||
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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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["clientLabel"] == Value::String("Windows / Chrome".to_string())
|
||
&& session["sessionCount"] == Value::Number(1.into())
|
||
&& session["sessionIds"]
|
||
.as_array()
|
||
.is_some_and(|ids| ids.len() == 1)
|
||
&& session["isCurrent"] == Value::Bool(true)
|
||
}));
|
||
assert!(sessions.iter().any(|session| {
|
||
session["clientLabel"] == Value::String("微信小程序 / Android".to_string())
|
||
&& session["sessionCount"] == Value::Number(1.into())
|
||
&& session["sessionIds"]
|
||
.as_array()
|
||
.is_some_and(|ids| ids.len() == 1)
|
||
&& 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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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["user"]["phoneNumber"],
|
||
Value::String("+8613800138016".to_string())
|
||
);
|
||
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_current_device_keeps_other_device_session_alive() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
seed_phone_user_with_password(&state, "13800138031", TEST_PASSWORD).await;
|
||
let app = build_router(state);
|
||
|
||
let first_login_response = password_login_request_with_client(
|
||
app.clone(),
|
||
"13800138031",
|
||
TEST_PASSWORD,
|
||
"logout-current-device",
|
||
"203.0.113.41",
|
||
)
|
||
.await;
|
||
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_access_token = read_access_token(&first_login_body);
|
||
|
||
let second_login_response = password_login_request_with_client(
|
||
app.clone(),
|
||
"13800138031",
|
||
TEST_PASSWORD,
|
||
"logout-other-device",
|
||
"203.0.113.42",
|
||
)
|
||
.await;
|
||
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 second_login_body = second_login_response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("second login body should collect")
|
||
.to_bytes();
|
||
let second_access_token = read_access_token(&second_login_body);
|
||
|
||
let logout_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/auth/logout")
|
||
.header("authorization", format!("Bearer {first_access_token}"))
|
||
.header("cookie", first_refresh_cookie)
|
||
.body(Body::empty())
|
||
.expect("logout request should build"),
|
||
)
|
||
.await
|
||
.expect("logout request should succeed");
|
||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||
|
||
let first_me_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/auth/me")
|
||
.header("authorization", format!("Bearer {first_access_token}"))
|
||
.body(Body::empty())
|
||
.expect("first me request should build"),
|
||
)
|
||
.await
|
||
.expect("first me request should succeed");
|
||
assert_eq!(first_me_response.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
let second_me_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/auth/me")
|
||
.header("authorization", format!("Bearer {second_access_token}"))
|
||
.body(Body::empty())
|
||
.expect("second me request should build"),
|
||
)
|
||
.await
|
||
.expect("second me request should succeed");
|
||
assert_eq!(second_me_response.status(), StatusCode::OK);
|
||
|
||
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::OK);
|
||
}
|
||
|
||
#[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!({
|
||
"purePhoneNumber": "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!({
|
||
"purePhoneNumber": "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 会保存进独立公告配置。
|
||
#[cfg(any())]
|
||
#[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": "<section>入口公告 HTML</section>"
|
||
}
|
||
]).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"],
|
||
"<section>入口公告 HTML</section>"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn public_editor_generation_pricing_route_returns_default_config() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/editor/generation-pricing")
|
||
.body(Body::empty())
|
||
.expect("pricing request should build"),
|
||
)
|
||
.await
|
||
.expect("pricing request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let body = response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("pricing body should collect")
|
||
.to_bytes();
|
||
let payload: Value = serde_json::from_slice(&body).expect("pricing payload should be json");
|
||
|
||
assert_eq!(
|
||
payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"],
|
||
Value::Number(12.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["gpt-image-2"]["prices"]["1K"],
|
||
Value::Number(3.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["gpt-image-2"]["prices"]["2K"],
|
||
Value::Number(5.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["audio1.0"]["price"],
|
||
Value::Number(5.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["chirp-v5"]["price"],
|
||
Value::Number(12.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["seedance2.0"]["unit"],
|
||
Value::String("perSecond".to_string())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["seedance2.0"]["prices"]["720p"],
|
||
Value::Number(24.into())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() {
|
||
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
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/admin/api/editor-generation-pricing")
|
||
.header("authorization", format!("Bearer {admin_token}"))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::json!({
|
||
"models": {
|
||
"gemini-3.1-flash-image-preview": {
|
||
"unit": "perGeneration",
|
||
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
|
||
},
|
||
"gpt-image-2": {
|
||
"unit": "perGeneration",
|
||
"prices": { "1K": 31, "2K": 62 }
|
||
},
|
||
"seedance2.0-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"seedance2.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 13, "720p": 26, "1080p": 52 }
|
||
},
|
||
"kling3.0": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 16, "720p": 32, "1080p": 64 }
|
||
},
|
||
"kling3.0-omni": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 21, "720p": 42, "1080p": 84 }
|
||
},
|
||
"veo3.1": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"veo3.1-fast": {
|
||
"unit": "perSecond",
|
||
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
|
||
},
|
||
"audio1.0": { "unit": "perGeneration", "price": 15 },
|
||
"chirp-v5": { "unit": "perGeneration", "price": 9 }
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("pricing save request should build"),
|
||
)
|
||
.await
|
||
.expect("pricing save request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
|
||
let public_response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/editor/generation-pricing")
|
||
.body(Body::empty())
|
||
.expect("public pricing request should build"),
|
||
)
|
||
.await
|
||
.expect("public pricing request should succeed");
|
||
let body = public_response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("public pricing body should collect")
|
||
.to_bytes();
|
||
let payload: Value =
|
||
serde_json::from_slice(&body).expect("public pricing payload should be json");
|
||
|
||
assert_eq!(
|
||
payload["models"]["gpt-image-2"]["prices"]["2K"],
|
||
Value::Number(62.into())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["audio1.0"]["unit"],
|
||
Value::String("perGeneration".to_string())
|
||
);
|
||
assert_eq!(
|
||
payload["models"]["seedance2.0"]["prices"]["720p"],
|
||
Value::Number(26.into())
|
||
);
|
||
}
|
||
|
||
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
||
#[cfg(any())]
|
||
#[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": "<script>alert(1)</script>"
|
||
}
|
||
]).to_string()
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("banners request should build"),
|
||
)
|
||
.await
|
||
.expect("banners request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
/// 中文注释:验证后台作品互动配置保存后回到同一份入口配置响应。
|
||
#[cfg(any())]
|
||
#[tokio::test]
|
||
async fn admin_public_work_interactions_route_saves_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/interactions")
|
||
.header("authorization", format!("Bearer {admin_token}"))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::json!({
|
||
"publicWorkInteractions": [
|
||
{
|
||
"sourceType": "puzzle",
|
||
"likeEnabled": false,
|
||
"remixEnabled": true,
|
||
"likeDisabledMessage": "拼图点赞维护中。",
|
||
"remixDisabledMessage": "拼图作品改造暂不可用。"
|
||
}
|
||
]
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("interactions request should build"),
|
||
)
|
||
.await
|
||
.expect("interactions request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
let body = response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("interactions body should collect")
|
||
.to_bytes();
|
||
let payload: Value =
|
||
serde_json::from_slice(&body).expect("interactions payload should be json");
|
||
let puzzle = payload["publicWorkInteractions"]
|
||
.as_array()
|
||
.expect("interactions should be array")
|
||
.iter()
|
||
.find(|item| item["sourceType"] == "puzzle")
|
||
.expect("puzzle interaction should exist");
|
||
|
||
assert_eq!(puzzle["likeEnabled"], false);
|
||
assert_eq!(puzzle["remixEnabled"], true);
|
||
assert_eq!(puzzle["likeDisabledMessage"], "拼图点赞维护中。");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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);
|
||
}
|
||
|
||
#[cfg(any())]
|
||
#[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}");
|
||
}
|
||
}
|
||
}
|