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_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_history, 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_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, remix_big_fish_gallery_work, stream_big_fish_message, 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, put_role_asset_workflow, resolve_role_asset_workflow, 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, generate_custom_world_profile, get_custom_world_agent_card_detail, get_custom_world_agent_operation, get_custom_world_agent_result_view, 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, record_custom_world_gallery_play, remix_custom_world_gallery_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_puzzle_assets, proxy_generated_qwen_sprites, }, llm::proxy_llm_chat_completions, login_options::auth_login_options, logout::logout, logout_all::logout_all, match3d::{ click_match3d_item, create_match3d_agent_session, delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, profile_identity::update_profile_identity, puzzle::{ advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work, 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, remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, 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_chat_plain::{ generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary, stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue, stream_runtime_npc_recruit_dialogue, }, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ admin_disable_profile_redeem_code, admin_upsert_profile_invite_code, admin_upsert_profile_redeem_code, 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, redeem_profile_reward_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::{ begin_runtime_story_session, 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/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( "/admin/api/profile/redeem-codes", post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/redeem-codes/disable", post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, )), ) .route( "/admin/api/profile/invite-codes", post(admin_upsert_profile_invite_code).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-puzzle-assets/{*path}", get(proxy_generated_puzzle_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/profile/me", axum::routing::patch(update_profile_identity).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/character/suggestions", post(generate_runtime_character_chat_suggestions).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/runtime/chat/character/summary", post(generate_runtime_character_chat_summary).route_layer( middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) .route( "/api/runtime/chat/character/reply/stream", post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/chat/npc/dialogue/stream", post(stream_runtime_npc_chat_dialogue).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/chat/npc/recruit/stream", post(stream_runtime_npc_recruit_dialogue).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/runtime/custom-world/asset-studio/role/{character_id}/workflow", post(resolve_role_asset_workflow).put(put_role_asset_workflow), ) .route("/api/assets/read-url", get(get_asset_read_url)) .route( "/api/assets/history", get(get_asset_history).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .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/{owner_user_id}/{profile_id}/remix", post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play", post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .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/agent/sessions/{session_id}/result-view", get(get_custom_world_agent_result_view).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/gallery", get(list_big_fish_gallery)) .route( "/api/runtime/big-fish/gallery/{session_id}/remix", post(remix_big_fish_gallery_work).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}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/big-fish/works/{session_id}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions", post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}", get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/messages", post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/messages/stream", post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/actions", post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/sessions/{session_id}/compile", post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works", get(get_match3d_works).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works/{profile_id}", get(get_match3d_work_detail) .patch(put_match3d_work) .put(put_match3d_work) .delete(delete_match3d_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/creation/match3d/works/{profile_id}/publish", post(publish_match3d_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route("/api/runtime/match3d/gallery", get(list_match3d_gallery)) .route( "/api/runtime/match3d/works/{profile_id}/runs", post(start_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}", get(get_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/click", post(click_match3d_item).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/stop", post(stop_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/restart", post(restart_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/time-up", post(finish_match3d_time_up).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/gallery/{profile_id}/remix", post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .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}/next-level", post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/pause", post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/props", post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/leaderboard", post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/runtime/custom-world/profile", post(generate_custom_world_profile).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/redeem-codes/redeem", post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/profile/redeem-codes/redeem", post(redeem_profile_reward_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/sessions", post(begin_runtime_story_session).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_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["token"].as_str().is_some()); } #[tokio::test] async fn auth_login_options_returns_enabled_methods_in_stable_order() { let config = AppConfig { sms_auth_enabled: true, wechat_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["phone", "password", "wechat"]) ); } #[tokio::test] async fn auth_login_options_keeps_password_entry_when_external_methods_disabled() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .uri("/api/auth/login-options") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("body should be valid json"); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["password"]) ); } #[tokio::test] async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let body = response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( payload["cooldownSeconds"], Value::Number(serde_json::Number::from(60)) ); assert_eq!( payload["expiresInSeconds"], Value::Number(serde_json::Number::from(300)) ); assert_eq!( payload["providerRequestId"], Value::String("mock-request-id".to_string()) ); } #[tokio::test] async fn send_phone_code_rejects_same_scene_during_cooldown() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("first request should build"), ) .await .expect("first request should succeed"); assert_eq!(first_response.status(), StatusCode::OK); let cooldown_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("cooldown request should build"), ) .await .expect("cooldown request should succeed"); assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS); assert!( cooldown_response .headers() .get("retry-after") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.parse::().is_ok_and(|seconds| seconds > 0)) ); let body = cooldown_response .into_body() .collect() .await .expect("cooldown body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("cooldown body should be valid json"); assert_eq!( payload["error"]["code"], Value::String("TOO_MANY_REQUESTS".to_string()) ); assert_eq!( payload["error"]["message"], Value::String("验证码发送过于频繁,请稍后再试".to_string()) ); assert!( payload["error"]["details"]["retryAfterSeconds"] .as_u64() .is_some() ); } #[tokio::test] async fn phone_login_creates_user_and_sets_refresh_cookie() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13800138000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .header( "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", ) .body(Body::from( serde_json::json!({ "phone": "13800138000", "code": "123456" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); assert!( login_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("genarrative_refresh_session=")) ); let body = login_response .into_body() .collect() .await .expect("response body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert!(payload["token"].as_str().is_some()); assert_eq!( payload["user"]["loginMethod"], Value::String("phone".to_string()) ); assert_eq!( payload["user"]["bindingStatus"], Value::String("active".to_string()) ); assert_eq!( payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); assert_eq!(payload["created"], Value::Bool(true)); assert!(payload["referral"].is_null()); } #[tokio::test] async fn phone_login_reuses_existing_user_for_same_phone_number() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "code": "123456" }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login request should succeed"); assert_eq!(first_login_response.status(), StatusCode::OK); let first_body = first_login_response .into_body() .collect() .await .expect("first login body should collect") .to_bytes(); let first_payload: Value = serde_json::from_slice(&first_body).expect("first login payload should be json"); let send_code_again_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_again_response.status(), StatusCode::OK); let second_login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13900139000", "code": "123456" }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login request should succeed"); assert_eq!(second_login_response.status(), StatusCode::OK); let second_body = second_login_response .into_body() .collect() .await .expect("second login body should collect") .to_bytes(); let second_payload: Value = serde_json::from_slice(&second_body).expect("second login payload should be json"); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); assert_eq!(first_payload["created"], Value::Bool(true)); assert_eq!(second_payload["created"], Value::Bool(false)); assert!(second_payload["referral"].is_null()); } #[tokio::test] async fn phone_login_invite_code_failure_does_not_block_created_user() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13600136000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13600136000", "code": "123456", "inviteCode": "SPRING2026" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); let body = login_response .into_body() .collect() .await .expect("login body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("login payload should be json"); assert!(payload["token"].as_str().is_some()); assert_eq!(payload["created"], Value::Bool(true)); assert_eq!(payload["referral"]["ok"], Value::Bool(false)); assert_eq!( payload["referral"]["message"], Value::String("邀请码无效,已继续注册".to_string()) ); } #[tokio::test] async fn phone_login_existing_user_ignores_invite_code() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let first_send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(first_send_code_response.status(), StatusCode::OK); let first_login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "code": "123456" }) .to_string(), )) .expect("first login request should build"), ) .await .expect("first login request should succeed"); assert_eq!(first_login_response.status(), StatusCode::OK); let second_send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(second_send_code_response.status(), StatusCode::OK); let second_login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13500135000", "code": "123456", "inviteCode": "SPRING2026" }) .to_string(), )) .expect("second login request should build"), ) .await .expect("second login request should succeed"); assert_eq!(second_login_response.status(), StatusCode::OK); let body = second_login_response .into_body() .collect() .await .expect("second login body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("second login payload should be json"); assert_eq!(payload["created"], Value::Bool(false)); assert!(payload["referral"].is_null()); } #[tokio::test] async fn phone_login_exhausts_code_after_too_many_wrong_attempts() { let config = AppConfig { sms_auth_enabled: true, ..AppConfig::default() }; let app = build_router(AppState::new(config).expect("state should build")); let send_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "scene": "login" }) .to_string(), )) .expect("send code request should build"), ) .await .expect("send code request should succeed"); assert_eq!(send_code_response.status(), StatusCode::OK); for _ in 0..4 { let wrong_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "000000" }) .to_string(), )) .expect("wrong login request should build"), ) .await .expect("wrong login request should succeed"); assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST); } let exhausted_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "000000" }) .to_string(), )) .expect("exhausted login request should build"), ) .await .expect("exhausted login request should succeed"); assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS); let exhausted_body = exhausted_response .into_body() .collect() .await .expect("exhausted body should collect") .to_bytes(); let exhausted_payload: Value = serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json"); assert_eq!( exhausted_payload["error"]["message"], Value::String("验证码错误次数过多,请重新获取验证码".to_string()) ); let stale_right_code_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "123456" }) .to_string(), )) .expect("stale login request should build"), ) .await .expect("stale login request should succeed"); assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST); let resend_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/send-code") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "scene": "login" }) .to_string(), )) .expect("resend request should build"), ) .await .expect("resend request should succeed"); assert_eq!(resend_response.status(), StatusCode::OK); let login_response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/phone/login") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "phone": "13700137000", "code": "123456" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); assert_eq!(login_response.status(), StatusCode::OK); } #[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_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); } #[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); } }