use axum::{ Router, body::Body, extract::Extension, http::Request, middleware, routing::{get, post}, }; use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::{Level, info_span}; use crate::{ assets::{create_direct_upload_ticket, get_asset_read_url}, auth::{ attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, require_bearer_auth, }, auth_me::auth_me, auth_sessions::auth_sessions, error_middleware::normalize_error_response, health::health_check, logout::logout, logout_all::logout_all, password_entry::password_entry, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::AppState, }; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { Router::new() .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/me", get(auth_me).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/auth/sessions", get(auth_sessions) .route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) .route( "/api/auth/refresh", post(refresh_session).route_layer(middleware::from_fn_with_state( state.clone(), attach_refresh_session_token, )), ) .route( "/api/auth/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/assets/direct-upload-tickets", post(create_direct_upload_ticket), ) .route("/api/assets/read-url", get(get_asset_read_url)) .route("/api/auth/entry", post(password_entry)) // 错误归一化层放在 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(DefaultOnResponse::new().level(Level::INFO)) .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) // request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。 .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[cfg(test)] mod tests { use axum::{ body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; use time::OffsetDateTime; use tower::ServiceExt; use crate::{config::AppConfig, state::AppState}; use super::build_router; #[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-node-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-node-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"); state .password_entry_service() .execute(module_auth::PasswordEntryInput { username: "guest_auth_debug".to_string(), password: "secret123".to_string(), }) .await .expect("seed login should succeed"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: "sess_auth_debug".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("测试用户".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("/_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("user_00000001".to_string()) ); assert_eq!( payload["claims"]["sid"], Value::String("sess_auth_debug".to_string()) ); assert_eq!( payload["claims"]["ver"], Value::Number(serde_json::Number::from(1)) ); } #[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_creates_user_and_sets_refresh_cookie() { 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!({ "username": "guest_001", "password": "secret123" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); 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"]["username"], Value::String("guest_001".to_string()) ); assert!(payload["token"].as_str().is_some()); } #[tokio::test] async fn auth_sessions_returns_multi_device_session_fields() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); 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!({ "username": "guest_sessions_api", "password": "secret123" }) .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!({ "username": "guest_sessions_api", "password": "secret123" }) .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_credentials() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let first_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_001", "password": "secret123" }) .to_string(), )) .expect("request should build"), ) .await .expect("first request should succeed"); 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 = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_001", "password": "secret123" }) .to_string(), )) .expect("request should build"), ) .await .expect("second request should succeed"); 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"], second_payload["user"]["id"]); } #[tokio::test] async fn password_entry_rejects_wrong_password() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); app.clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_001", "password": "secret123" }) .to_string(), )) .expect("request should build"), ) .await .expect("seed request should succeed"); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_001", "password": "secret999" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn password_entry_rejects_invalid_username() { 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!({ "username": "无效用户", "password": "secret123" }) .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"); state .password_entry_service() .execute(module_auth::PasswordEntryInput { username: "guest_001".to_string(), password: "secret123".to_string(), }) .await .expect("seed login should succeed"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: "sess_me_query".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some("guest_001".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::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("user_00000001".to_string()) ); assert_eq!( payload["availableLoginMethods"], serde_json::json!(["phone", "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 app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_refresh", "password": "secret123" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); 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 app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_logout_api", "password": "secret123" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); 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 app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_logout_no_cookie", "password": "secret123" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); 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 app = build_router(AppState::new(AppConfig::default()).expect("state should build")); 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!({ "username": "guest_logout_all_api", "password": "secret123" }) .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!({ "username": "guest_logout_all_api", "password": "secret123" }) .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 app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let login_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/auth/entry") .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "guest_logout_all_nc", "password": "secret123" }) .to_string(), )) .expect("login request should build"), ) .await .expect("login request should succeed"); 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")) ); } }