1242 lines
45 KiB
Rust
1242 lines
45 KiB
Rust
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<Body>| {
|
||
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"))
|
||
);
|
||
}
|
||
}
|