Files
Genarrative/server-rs/crates/api-server/src/app.rs
T
kdletters 1a7abfd059
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
AGC 项目定时快照上传(目标 OSS agc-dev) (#400)
## 交付内容

- 客户端新增 `project_snapshot` 模块:项目扫描与排除口径、增量索引、差异对比、上传编排、状态查询。
- 触发:工作区窗口存活期间的周期定时器 + 工作区窗口关闭(`CloseRequested`);应用退出只在有界预算内等待在途同步收尾,不重复发起。
- 服务端新增两条登录态路由 `POST /api/agc/project-snapshots/files` 与 `/manifest`,由 api-server 用服务端凭据写入私有前缀 `agc/project-snapshots/v1/{user}/{project}/`;客户端不持有 OSS 凭据、不直连 OSS。
- `platform-oss` 新增内部对象精确写入与探测、项目快照对象键构造;修复 `head_internal_object` 读 HEAD 响应长度恒为 0(会导致服务端"已存在即跳过"永不生效)。
- `shared-contracts` 新增 `agc_project_snapshots` DTO 与项目 ID、相对路径、摘要校验。
- 真实 OSS 存储层冒烟示例 + 客户端真实链路冒烟用例(`#[ignore]`,env 驱动)。
- 登记 `check-config.mjs` 的 native-only 命令白名单,恢复 `npm run agc` 可启动。

## 验证证据

- 存储层:`cargo run -p platform-oss --example agc_project_snapshot_live_smoke` 对真实 `agc-dev` 完成写入 → 读回(contentLength 32)→ 清单写入 → 探针清理。
- HTTP 层:登录态下 7 项校验(401 无 token、400 路径穿越/长度不一致/非法摘要/空正文/重复路径清单)全部符合预期且不写对象。
- 客户端链路(真实项目 gameagent-033b6cf3…):第一次 `synced uploaded=6 uploadedBytes=36694 remoteSkipped=6`,紧接着第二次 `no-op uploaded=0`。
- GUI 触发:`trigger=periodic ... uploaded=6`;把周期设为 600 秒排除干扰、改一个文件后关窗得到 `trigger=project-close ... revision=2 uploaded=1`,索引摘要由 `28d837cd84f8ab62` 推进到 `bff34e2e336d901f`(同步在进程退出前完成)。
- 门禁:AGC `project_snapshot` 14 passed(+1 ignored live)、api-server `project_snapshots` 3 passed、platform-oss 39 passed、shared-contracts 87 passed、`cargo fmt --check`(两处)、`check:encoding`、`check:doc-index`、`git diff --check`、`check-config.mjs` 全部通过。

## 已知未决(不阻塞本里程碑)

- 远端对象只增不减:没有删除路径,也没有 bucket 生命周期规则;要收口需要先定保留语义(清单引用 GC 还是 OSS 生命周期)。
- 部署环境需确认 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 或回退的 `ALIYUN_OSS_*` 具备目标 bucket 私有前缀的 `PutObject` 权限;未配置时接口返回 503、客户端失败关闭(不推进索引)。
- 缺少按用户/项目的配额与限流(error_reports 有每小时提交上限的先例,本功能没有)。
- 没有界面入口与状态可见性:`read_local_project_snapshot_state` 已注册但未接 UI,失败只写本机日志。

## 不做

- 不做云端下载/恢复、跨设备合并、版本回滚。
- 不改 `/api/external/v1` 与 External OpenAPI,不新增 SpacetimeDB 表。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/400
2026-09-17 18:34:51 +08:00

6089 lines
232 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
external_api_auth::ExternalApiPrincipal,
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::{
TrackingClientMarker, TrackingLoginSubject, record_route_tracking_event_after_success,
resolve_tracking_client_marker,
},
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()))
.merge(modules::raw::router(state.clone()))
.merge(modules::project_snapshots::router(state.clone()))
.merge(crate::error_reports::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>,
mut request: Request<Body>,
next: middleware::Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let client_marker = resolve_tracking_client_marker(request.headers());
if let Some(client_marker) = client_marker {
request.extensions_mut().insert(client_marker);
}
let response = next.run(request).await;
let authenticated = response
.extensions()
.get::<AuthenticatedAccessToken>()
.cloned();
let external_principal = response.extensions().get::<ExternalApiPrincipal>().cloned();
let login_subject = if matches!(client_marker, Some(TrackingClientMarker::Agc)) {
response.extensions().get::<TrackingLoginSubject>().cloned()
} else {
None
};
record_route_tracking_event_after_success(
&state,
&request_context,
&method,
&path,
response.status(),
authenticated.as_ref(),
external_principal.as_ref(),
login_subject.as_ref(),
client_marker,
)
.await;
response
}
#[cfg(test)]
mod tests {
use std::path::Path;
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")
}
async fn read_tracking_outbox_events(outbox_dir: &Path) -> Vec<Value> {
tokio::fs::read_to_string(outbox_dir.join("active.ndjson"))
.await
.expect("tracking middleware should enqueue outbox events")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("outbox line should be JSON"))
.collect()
}
fn find_tracking_event<'a>(records: &'a [Value], event_key: &str) -> &'a Value {
records
.iter()
.find(|record| record["event"]["event_key"] == event_key)
.unwrap_or_else(|| panic!("tracking event should exist: {event_key}"))
}
#[tokio::test]
async fn showcase_invalid_bearer_is_not_downgraded_and_is_not_cacheable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/editor/showcase/resources")
.header("authorization", "Bearer invalid-token")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("private, no-store")
);
assert_eq!(
response
.headers()
.get("vary")
.and_then(|value| value.to_str().ok()),
Some("Authorization")
);
}
#[tokio::test]
async fn agc_marker_does_not_bypass_authentication_or_change_failure_status() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/editor/projects")
.header("x-genarrative-client", "agc")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn agc_marker_flows_through_auth_tracking_middleware_to_outbox() {
let outbox_dir = std::env::temp_dir().join(format!(
"genarrative-api-tracking-middleware-{}",
OffsetDateTime::now_utc().unix_timestamp_nanos()
));
let _ = std::fs::remove_dir_all(&outbox_dir);
let config = AppConfig {
tracking_outbox_dir: outbox_dir.clone(),
tracking_outbox_batch_size: 100,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let user = seed_phone_user_with_password(&state, "13800138001", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &user, "sess_agc_tracking_middleware");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-client", "agc")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let outbox = tokio::fs::read_to_string(outbox_dir.join("active.ndjson"))
.await
.expect("tracking middleware should enqueue an outbox event");
let records = outbox
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("outbox line should be JSON"))
.collect::<Vec<_>>();
assert_eq!(records.len(), 1);
let event = &records[0]["event"];
assert_eq!(event["event_key"], "auth_me_view");
assert_eq!(event["user_id"], user.id);
assert_eq!(event["owner_user_id"], user.id);
let metadata = serde_json::from_str::<Value>(
event["metadata_json"]
.as_str()
.expect("tracking metadata should be serialized as a string"),
)
.expect("tracking metadata should be valid JSON");
assert_eq!(metadata["client"], "agc");
assert_eq!(metadata["route"], "/api/auth/me");
assert_eq!(metadata["status"], 200);
let _ = std::fs::remove_dir_all(outbox_dir);
}
#[tokio::test]
async fn agc_password_login_tracking_uses_verified_login_user_subject() {
let outbox_dir = std::env::temp_dir().join(format!(
"genarrative-api-agc-password-login-tracking-{}",
OffsetDateTime::now_utc().unix_timestamp_nanos()
));
let _ = std::fs::remove_dir_all(&outbox_dir);
let config = AppConfig {
tracking_outbox_dir: outbox_dir.clone(),
tracking_outbox_batch_size: 100,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let user = seed_phone_user_with_password(&state, "13800138002", TEST_PASSWORD).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header("x-genarrative-client", "agc")
.body(Body::from(
serde_json::json!({
"purePhoneNumber": "13800138002",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let records = read_tracking_outbox_events(&outbox_dir).await;
let event = find_tracking_event(&records, "auth_password_login_success");
assert_eq!(event["event"]["user_id"], user.id);
assert_eq!(event["event"]["owner_user_id"], user.id);
assert_eq!(event["event"]["scope_id"], user.id);
let metadata = serde_json::from_str::<Value>(
event["event"]["metadata_json"]
.as_str()
.expect("tracking metadata should be serialized as a string"),
)
.expect("tracking metadata should be valid JSON");
assert_eq!(metadata["client"], "agc");
assert_eq!(metadata["route"], "/api/auth/entry");
assert_eq!(metadata["status"], 200);
let _ = std::fs::remove_dir_all(outbox_dir);
}
#[tokio::test]
async fn agc_password_login_tracking_does_not_record_without_marker() {
let outbox_dir = std::env::temp_dir().join(format!(
"genarrative-api-unmarked-password-login-tracking-{}",
OffsetDateTime::now_utc().unix_timestamp_nanos()
));
let _ = std::fs::remove_dir_all(&outbox_dir);
let config = AppConfig {
tracking_outbox_dir: outbox_dir.clone(),
tracking_outbox_batch_size: 100,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let _user = seed_phone_user_with_password(&state, "13800138003", TEST_PASSWORD).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"purePhoneNumber": "13800138003",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert!(!outbox_dir.join("active.ndjson").exists());
let _ = std::fs::remove_dir_all(outbox_dir);
}
#[tokio::test]
async fn agc_phone_login_tracking_uses_verified_login_user_subject() {
let outbox_dir = std::env::temp_dir().join(format!(
"genarrative-api-agc-phone-login-tracking-{}",
OffsetDateTime::now_utc().unix_timestamp_nanos()
));
let _ = std::fs::remove_dir_all(&outbox_dir);
let config = AppConfig {
sms_auth_enabled: true,
tracking_outbox_dir: outbox_dir.clone(),
tracking_outbox_batch_size: 100,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
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": "13800138002",
"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("x-genarrative-client", "agc")
.body(Body::from(
serde_json::json!({
"purePhoneNumber": "13800138002",
"code": "123456"
})
.to_string(),
))
.expect("phone login request should build"),
)
.await
.expect("phone login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let body = login_response
.into_body()
.collect()
.await
.expect("phone login response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("phone login response should be valid JSON");
let user_id = payload["user"]["id"]
.as_str()
.expect("phone login response should include user id");
let records = read_tracking_outbox_events(&outbox_dir).await;
let event = find_tracking_event(&records, "auth_phone_login_success");
assert_eq!(event["event"]["user_id"], user_id);
assert_eq!(event["event"]["owner_user_id"], user_id);
assert_eq!(event["event"]["scope_id"], user_id);
let metadata = serde_json::from_str::<Value>(
event["event"]["metadata_json"]
.as_str()
.expect("tracking metadata should be serialized as a string"),
)
.expect("tracking metadata should be valid JSON");
assert_eq!(metadata["client"], "agc");
assert_eq!(metadata["route"], "/api/auth/phone/login");
assert_eq!(metadata["status"], 200);
let _ = std::fs::remove_dir_all(outbox_dir);
}
#[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_image_generation_rejects_scene_contract_bypasses() {
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, "13800138232", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_bypass");
let app = build_router(state);
let requests = [
(
"scene kind",
serde_json::json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
),
(
"scene asset kind",
serde_json::json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
),
];
for (case_name, request_body) in requests {
let response = app
.clone()
.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.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} must not bypass the dedicated scene contract"
);
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("/api/editor/scenes/generations"),
"{case_name} should point callers to the scene endpoint: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/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_scene_generation_rejects_invalid_required_inputs() {
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_scene_validation");
let app = build_router(state);
let requests = [
(
"empty sceneContent",
serde_json::json!({
"sceneContent": " ",
"stylePreset": "anime",
}),
"sceneContent",
),
(
"unknown stylePreset",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "unknown",
}),
"stylePreset",
),
(
"missing customStyle",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "custom",
}),
"customStyle",
),
];
for (case_name, request_body, expected_message) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.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,
"{case_name} should return 400"
);
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(expected_message),
"{case_name} should report {expected_message}: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_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, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_reference");
let app = build_router(state);
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/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 scene 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": "快速编辑图片",
"sourceReferenceId": "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("只接受已登记的项目资源 ID 或素材 ID"),
"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);
// 轮换竞争或重放都不能清空浏览器当前的 refresh cookie:清 cookie 会删掉并发
// 请求刚刚写入的有效 cookie,把一次竞争放大成登出。cookie 的失效只由会话吊销、
// 过期或身份变更决定,客户端在收敛重试后仍收到 401 时才清本地会话。
assert!(
stale_refresh_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_none_or(|value| !value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn refresh_session_rejects_missing_cookie_without_touching_cookies() {
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_none_or(|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"]["eleven_text_to_sound_v2"]["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 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 },
"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"]["eleven_text_to_sound_v2"]["price"],
Value::Number(16.into())
);
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}");
}
}
}