use axum::{
Router,
body::Body,
extract::Extension,
http::Request,
middleware,
routing::{delete, get, post},
};
use tower_http::{
classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer},
};
use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message,
submit_big_fish_input, submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
publish_character_animation, save_character_workflow_cache,
},
character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual,
},
creation_agent_document_input::parse_creation_agent_document_input,
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
get_custom_world_agent_session, get_custom_world_gallery_detail,
get_custom_world_gallery_detail_by_code, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
},
custom_world_ai::{
generate_custom_world_cover_image, generate_custom_world_entity,
generate_custom_world_scene_image, generate_custom_world_scene_npc,
upload_custom_world_cover_image,
},
error_middleware::normalize_error_response,
health::health_check,
legacy_generated_assets::{
proxy_generated_animations, proxy_generated_big_fish_assets,
proxy_generated_character_drafts, proxy_generated_characters,
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
proxy_generated_qwen_sprites,
},
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
swap_puzzle_pieces,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{begin_story_session, continue_story, get_story_session_state},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new()
.route("/admin", get(admin_console_page))
.route("/admin/api/login", post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
health_check(Extension(request_context)).await
}),
)
.route(
"/_internal/auth/claims",
get(inspect_auth_claims).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/_internal/auth/refresh-cookie",
get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
)
.route(
"/generated-characters/{*path}",
get(proxy_generated_characters),
)
.route(
"/generated-animations/{*path}",
get(proxy_generated_animations),
)
.route(
"/generated-big-fish-assets/{*path}",
get(proxy_generated_big_fish_assets),
)
.route(
"/generated-custom-world-scenes/{*path}",
get(proxy_generated_custom_world_scenes),
)
.route(
"/generated-custom-world-covers/{*path}",
get(proxy_generated_custom_world_covers),
)
.route(
"/generated-qwen-sprites/{*path}",
get(proxy_generated_qwen_sprites),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions",
get(auth_sessions)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/llm/chat/completions",
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/turn/stream",
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout-all",
post(logout_all).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks",
post(create_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/start",
post(start_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/chunks",
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/references",
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/complete",
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/fail",
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/cancel",
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials),
)
.route("/api/assets/objects/confirm", post(confirm_asset_object))
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity),
)
.route(
"/api/assets/character-visual/generate",
post(generate_character_visual),
)
.route(
"/api/assets/character-visual/jobs/{task_id}",
get(get_character_visual_job),
)
.route(
"/api/assets/character-visual/publish",
post(publish_character_visual),
)
.route(
"/api/assets/character-animation/generate",
post(generate_character_animation),
)
.route(
"/api/assets/character-animation/jobs/{task_id}",
get(get_character_animation_job),
)
.route(
"/api/assets/character-animation/publish",
post(publish_character_animation),
)
.route(
"/api/assets/character-animation/import-video",
post(import_character_animation_video),
)
.route(
"/api/assets/character-animation/templates",
get(list_character_animation_templates),
)
.route(
"/api/assets/character-workflow-cache",
post(save_character_workflow_cache),
)
.route(
"/api/assets/character-workflow-cache/{character_id}",
get(get_character_workflow_cache),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/runtime/settings",
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/save/snapshot",
get(get_runtime_snapshot)
.put(put_runtime_snapshot)
.delete(delete_runtime_snapshot)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library",
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}",
get(get_custom_world_library_detail)
.put(put_custom_world_library_profile)
.delete(delete_custom_world_library_profile)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/publish",
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/unpublish",
post(unpublish_custom_world_library_profile).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world-gallery",
get(list_custom_world_gallery),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world-gallery/by-code/{code}",
get(get_custom_world_gallery_detail_by_code),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}",
get(get_custom_world_agent_session)
.delete(delete_custom_world_agent_session)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions",
post(create_big_fish_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}",
get(get_big_fish_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/messages",
post(submit_big_fish_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/messages/stream",
post(stream_big_fish_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/actions",
post(execute_big_fish_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works",
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works/{session_id}",
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}",
get(get_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/messages",
post(submit_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/messages/stream",
post(stream_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/actions",
post(execute_puzzle_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works",
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works/{profile_id}",
get(get_puzzle_work_detail)
.put(put_puzzle_work)
.delete(delete_puzzle_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery))
.route(
"/api/runtime/puzzle/gallery/{profile_id}",
get(get_puzzle_gallery_detail),
)
.route(
"/api/runtime/puzzle/runs",
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/local-next-level",
post(advance_local_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/swap",
post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/sessions/{runtime_session_id}/inventory",
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/resolve",
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/{session_id}",
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/actions/resolve",
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/initial",
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/continue",
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions",
post(begin_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/state",
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/continue",
post(continue_story).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles",
post(create_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/{battle_state_id}",
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/npc/battle",
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/resolve",
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/entry", post(password_entry))
.route(
"/api/auth/password/change",
post(change_password).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/password/reset", post(reset_password))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
.layer(middleware::from_fn(propagate_request_id_header))
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request
| {
let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
info_span!(
"http.request",
method = %request.method(),
uri = %request.uri(),
request_id = %request_id,
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
move |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();
let slow_request = latency_ms >= slow_request_threshold_ms;
span.record("status", status);
span.record("latency_ms", latency_ms);
if slow_request {
warn!(
parent: span,
status,
latency_ms,
slow_request = true,
"http request completed slowly"
);
} else {
info!(
parent: span,
status,
latency_ms,
slow_request = false,
"http request completed"
);
}
},
)
.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)
}
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use reqwest::Client;
use serde_json::Value;
use time::OffsetDateTime;
use tokio::net::TcpListener;
use tower::ServiceExt;
use crate::{config::AppConfig, state::AppState};
use super::build_router;
const TEST_PASSWORD: &str = "secret123";
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
}
async fn password_login_request(
app: Router,
phone_number: &str,
password: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": phone_number,
"password": password
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed")
}
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/healthz")
.header("x-request-id", "req-health-legacy")
.body(Body::empty())
.expect("healthz request should build"),
)
.await
.expect("healthz request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok()),
Some("req-health-legacy")
);
assert_eq!(
response
.headers()
.get("x-api-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
);
assert_eq!(
response
.headers()
.get("x-route-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
);
assert!(response.headers().contains_key("x-response-time-ms"));
let body = response
.into_body()
.collect()
.await
.expect("healthz body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("healthz body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["service"],
Value::String("genarrative-api-server".to_string())
);
}
#[tokio::test]
async fn 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 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 claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_auth_debug".to_string(),
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("sess_auth_debug".to_string())
);
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(seed_user.token_version))
);
}
#[tokio::test]
async fn internal_refresh_cookie_reports_missing_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/refresh-cookie")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["present"], Value::Bool(false));
assert_eq!(
payload["cookieName"],
Value::String("genarrative_refresh_session".to_string())
);
}
#[tokio::test]
async fn internal_refresh_cookie_reports_present_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/refresh-cookie")
.header(
"cookie",
"theme=dark; genarrative_refresh_session=token12345",
)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["present"], Value::Bool(true));
assert_eq!(
payload["tokenLength"],
Value::Number(serde_json::Number::from(10))
);
}
#[tokio::test]
async fn 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_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["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 send_phone_code_returns_mock_cooldown_and_expire_seconds() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["cooldownSeconds"],
Value::Number(serde_json::Number::from(60))
);
assert_eq!(
payload["expiresInSeconds"],
Value::Number(serde_json::Number::from(300))
);
assert_eq!(
payload["providerRequestId"],
Value::String("mock-request-id".to_string())
);
}
#[tokio::test]
async fn send_phone_code_rejects_same_scene_during_cooldown() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("first request should build"),
)
.await
.expect("first request should succeed");
assert_eq!(first_response.status(), StatusCode::OK);
let cooldown_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("cooldown request should build"),
)
.await
.expect("cooldown request should succeed");
assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(
cooldown_response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.parse::().is_ok_and(|seconds| seconds > 0))
);
let body = cooldown_response
.into_body()
.collect()
.await
.expect("cooldown body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("cooldown body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("TOO_MANY_REQUESTS".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("验证码发送过于频繁,请稍后再试".to_string())
);
assert!(
payload["error"]["details"]["retryAfterSeconds"]
.as_u64()
.is_some()
);
}
#[tokio::test]
async fn phone_login_creates_user_and_sets_refresh_cookie() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
assert!(
login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let body = login_response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(
payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(
payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn phone_login_reuses_existing_user_for_same_phone_number() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login request should succeed");
assert_eq!(first_login_response.status(), StatusCode::OK);
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first login payload should be json");
let send_code_again_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_again_response.status(), StatusCode::OK);
let second_login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13900139000",
"code": "123456"
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login request should succeed");
assert_eq!(second_login_response.status(), StatusCode::OK);
let second_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_payload: Value =
serde_json::from_slice(&second_body).expect("second login payload should be json");
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
}
#[tokio::test]
async fn phone_login_exhausts_code_after_too_many_wrong_attempts() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
for _ in 0..4 {
let wrong_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("wrong login request should build"),
)
.await
.expect("wrong login request should succeed");
assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST);
}
let exhausted_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "000000"
})
.to_string(),
))
.expect("exhausted login request should build"),
)
.await
.expect("exhausted login request should succeed");
assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS);
let exhausted_body = exhausted_response
.into_body()
.collect()
.await
.expect("exhausted body should collect")
.to_bytes();
let exhausted_payload: Value =
serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json");
assert_eq!(
exhausted_payload["error"]["message"],
Value::String("验证码错误次数过多,请重新获取验证码".to_string())
);
let stale_right_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("stale login request should build"),
)
.await
.expect("stale login request should succeed");
assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST);
let resend_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"scene": "login"
})
.to_string(),
))
.expect("resend request should build"),
)
.await
.expect("resend request should succeed");
assert_eq!(resend_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13700137000",
"code": "123456"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn wechat_start_returns_mock_callback_url_with_state() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let authorization_url = payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
assert!(authorization_url.contains("/api/auth/wechat/callback"));
assert!(authorization_url.contains("mock_code=wx-mock-code"));
assert!(authorization_url.contains("state="));
}
#[tokio::test]
async fn wechat_callback_creates_pending_bind_phone_session_with_wechat_provider() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("wechat start should succeed");
let start_body = start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let start_payload: Value =
serde_json::from_slice(&start_body).expect("wechat start payload should be json");
let authorization_url = start_payload["authorizationUrl"]
.as_str()
.expect("authorization url should exist");
let callback_url =
url::Url::parse(authorization_url).expect("authorization url should be valid");
let state = callback_url
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state query should exist");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("callback request should build"),
)
.await
.expect("callback request should succeed");
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
let location = callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("redirect location should exist");
let refresh_cookie = callback_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist");
assert!(location.starts_with("/play#"));
assert!(location.contains("auth_provider=wechat"));
assert!(location.contains("auth_binding_status=pending_bind_phone"));
assert!(location.contains("auth_token="));
assert!(refresh_cookie.contains("genarrative_refresh_session="));
let auth_hash = location
.split('#')
.nth(1)
.expect("hash fragment should exist");
let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes())
.into_owned()
.collect::>();
let token = auth_params
.get("auth_token")
.expect("auth token should exist in hash");
let me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("auth me request should build"),
)
.await
.expect("auth me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_body = me_response
.into_body()
.collect()
.await
.expect("auth me body should collect")
.to_bytes();
let me_payload: Value =
serde_json::from_slice(&me_body).expect("auth me payload should be json");
assert_eq!(
me_payload["user"]["loginMethod"],
Value::String("wechat".to_string())
);
assert_eq!(
me_payload["user"]["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
let claims_response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/claims")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("claims request should build"),
)
.await
.expect("claims request should succeed");
let claims_body = claims_response
.into_body()
.collect()
.await
.expect("claims body should collect")
.to_bytes();
let claims_payload: Value =
serde_json::from_slice(&claims_body).expect("claims payload should be json");
assert_eq!(
claims_payload["claims"]["provider"],
Value::String("wechat".to_string())
);
assert_eq!(
claims_payload["claims"]["binding_status"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(
claims_payload["claims"]["phone_verified"],
Value::Bool(false)
);
}
#[tokio::test]
async fn wechat_bind_phone_merges_into_existing_phone_user() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let phone_send_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("phone send request should build"),
)
.await
.expect("phone send request should succeed");
assert_eq!(phone_send_response.status(), StatusCode::OK);
let phone_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("phone login request should build"),
)
.await
.expect("phone login request should succeed");
let phone_login_body = phone_login_response
.into_body()
.collect()
.await
.expect("phone login body should collect")
.to_bytes();
let phone_login_payload: Value =
serde_json::from_slice(&phone_login_body).expect("phone login payload should be json");
let phone_user_id = phone_login_payload["user"]["id"].clone();
let wechat_start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/start?redirectPath=%2Fplay")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat start request should build"),
)
.await
.expect("wechat start request should succeed");
let wechat_start_body = wechat_start_response
.into_body()
.collect()
.await
.expect("wechat start body should collect")
.to_bytes();
let wechat_start_payload: Value = serde_json::from_slice(&wechat_start_body)
.expect("wechat start payload should be json");
let authorization_url = wechat_start_payload["authorizationUrl"]
.as_str()
.expect("wechat authorization url should exist");
let callback_state = url::Url::parse(authorization_url)
.expect("authorization url should be valid")
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state should exist");
let wechat_callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={callback_state}&mock_code=wx-mock-code"
))
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat callback request should build"),
)
.await
.expect("wechat callback request should succeed");
let wechat_location = wechat_callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("wechat callback location should exist");
let wechat_hash = wechat_location
.split('#')
.nth(1)
.expect("wechat callback hash should exist");
let wechat_auth_params = url::form_urlencoded::parse(wechat_hash.as_bytes())
.into_owned()
.collect::>();
let wechat_token = wechat_auth_params
.get("auth_token")
.expect("wechat auth token should exist");
let bind_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "bind_phone"
})
.to_string(),
))
.expect("bind code request should build"),
)
.await
.expect("bind code request should succeed");
assert_eq!(bind_code_response.status(), StatusCode::OK);
let bind_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/bind-phone")
.header("authorization", format!("Bearer {wechat_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("bind request should build"),
)
.await
.expect("bind request should succeed");
assert_eq!(bind_response.status(), StatusCode::OK);
assert!(
bind_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let bind_body = bind_response
.into_body()
.collect()
.await
.expect("bind body should collect")
.to_bytes();
let bind_payload: Value =
serde_json::from_slice(&bind_body).expect("bind payload should be json");
assert_eq!(bind_payload["user"]["id"], phone_user_id);
assert_eq!(
bind_payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(
bind_payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true));
assert_eq!(
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
}
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.header("x-client-instance-id", "chrome-instance-001")
.body(Body::from(
serde_json::json!({
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login should succeed");
let first_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first cookie should exist")
.to_string();
let first_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first login payload should be json");
let access_token = first_payload["token"]
.as_str()
.expect("access token should exist")
.to_string();
let _second_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header("x-client-type", "mini_program")
.header("x-client-runtime", "wechat_mini_program")
.header("x-client-platform", "android")
.header("x-client-instance-id", "mini-instance-001")
.header("x-mini-program-app-id", "wx-session-test")
.header("x-mini-program-env", "release")
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
.body(Body::from(
serde_json::json!({
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login should succeed");
let sessions_response = app
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {access_token}"))
.header("cookie", first_cookie)
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(sessions_response.status(), StatusCode::OK);
let sessions_body = sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
let sessions = sessions_payload["sessions"]
.as_array()
.expect("sessions should be array");
assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("web_browser".to_string())
&& session["clientRuntime"] == Value::String("chrome".to_string())
&& session["clientPlatform"] == Value::String("windows".to_string())
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
&& session["isCurrent"] == Value::Bool(true)
}));
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("mini_program".to_string())
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
&& session["miniProgramEnv"] == Value::String("release".to_string())
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
&& session["isCurrent"] == Value::Bool(false)
}));
}
#[tokio::test]
async fn 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_entry_rejects_email_or_username_identifier() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "user@example.com",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn auth_me_returns_current_user_and_available_login_methods() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: seed_user.id.clone(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: seed_user.token_version,
phone_verified: false,
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("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "password", "wechat"])
);
}
#[tokio::test]
async fn auth_me_returns_unauthorized_when_user_missing() {
let config = AppConfig::default();
let state = AppState::new(config).expect("state should build");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_missing".to_string(),
session_id: "sess_missing".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("ghost".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await;
let first_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let refresh_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", first_cookie.clone())
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(refresh_response.status(), StatusCode::OK);
let second_cookie = refresh_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("rotated refresh cookie should exist")
.to_string();
assert_ne!(first_cookie, second_cookie);
let refresh_body = refresh_response
.into_body()
.collect()
.await
.expect("refresh body should collect")
.to_bytes();
let refresh_payload: Value =
serde_json::from_slice(&refresh_body).expect("refresh payload should be json");
assert!(refresh_payload["token"].as_str().is_some());
let stale_refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", first_cookie)
.body(Body::empty())
.expect("stale refresh request should build"),
)
.await
.expect("stale refresh request should succeed");
assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED);
assert!(
stale_refresh_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn refresh_session_rejects_missing_cookie_and_clears_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(
response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn logout_clears_cookie_and_invalidates_current_access_token() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let logout_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/logout")
.header("authorization", format!("Bearer {access_token}"))
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("logout request should build"),
)
.await
.expect("logout request should succeed");
assert_eq!(logout_response.status(), StatusCode::OK);
assert!(
logout_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let logout_body = logout_response
.into_body()
.collect()
.await
.expect("logout body should collect")
.to_bytes();
let logout_payload: Value =
serde_json::from_slice(&logout_body).expect("logout payload should be json");
assert_eq!(logout_payload["ok"], Value::Bool(true));
let me_response = app
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {access_token}"))
.body(Body::empty())
.expect("me request should build"),
)
.await
.expect("me request should succeed");
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
let 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
.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"))
);
}
#[tokio::test]
async fn logout_all_clears_cookie_and_invalidates_all_sessions() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
)
.body(Body::from(
serde_json::json!({
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login should succeed");
let first_refresh_cookie = first_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("first refresh cookie should exist")
.to_string();
let first_login_body = first_login_response
.into_body()
.collect()
.await
.expect("first login body should collect")
.to_bytes();
let first_login_payload: Value =
serde_json::from_slice(&first_login_body).expect("first login payload should be json");
let first_access_token = first_login_payload["token"]
.as_str()
.expect("first access token should exist")
.to_string();
let second_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.header("x-client-runtime", "firefox")
.header("x-client-instance-id", "logout-all-instance-002")
.body(Body::from(
serde_json::json!({
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login should succeed");
let second_refresh_cookie = second_login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("second refresh cookie should exist")
.to_string();
let logout_all_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/logout-all")
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_refresh_cookie.clone())
.body(Body::empty())
.expect("logout-all request should build"),
)
.await
.expect("logout-all request should succeed");
assert_eq!(logout_all_response.status(), StatusCode::OK);
assert!(
logout_all_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
let logout_all_body = logout_all_response
.into_body()
.collect()
.await
.expect("logout-all body should collect")
.to_bytes();
let logout_all_payload: Value =
serde_json::from_slice(&logout_all_body).expect("logout-all payload should be json");
assert_eq!(logout_all_payload["ok"], Value::Bool(true));
let me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {first_access_token}"))
.body(Body::empty())
.expect("me request should build"),
)
.await
.expect("me request should succeed");
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
let first_refresh_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", first_refresh_cookie)
.body(Body::empty())
.expect("first refresh request should build"),
)
.await
.expect("first refresh request should succeed");
assert_eq!(first_refresh_response.status(), StatusCode::UNAUTHORIZED);
let second_refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", second_refresh_cookie)
.body(Body::empty())
.expect("second refresh request should build"),
)
.await
.expect("second refresh request should succeed");
assert_eq!(second_refresh_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await;
let app = build_router(state);
let login_response =
password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("access token should exist")
.to_string();
let logout_all_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/logout-all")
.header("authorization", format!("Bearer {access_token}"))
.body(Body::empty())
.expect("logout-all request should build"),
)
.await
.expect("logout-all request should succeed");
assert_eq!(logout_all_response.status(), StatusCode::OK);
assert!(
logout_all_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn admin_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);
}
#[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);
}
}