Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
# Conflicts: # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/spacetime-client/src/lib.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -33,7 +33,7 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::auth_sessions,
|
||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
|
||||
@@ -81,6 +81,8 @@ use crate::{
|
||||
generate_custom_world_opening_cg, generate_custom_world_scene_image,
|
||||
generate_custom_world_scene_npc, upload_custom_world_cover_image,
|
||||
},
|
||||
edutainment_baby_drawing::create_baby_love_drawing_magic,
|
||||
edutainment_baby_object::generate_baby_object_match_assets,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
hyper3d_generation::{
|
||||
@@ -94,8 +96,10 @@ use crate::{
|
||||
match3d::{
|
||||
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
|
||||
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
|
||||
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
|
||||
generate_match3d_background_image_for_work, generate_match3d_cover_image,
|
||||
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
|
||||
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
|
||||
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
|
||||
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
|
||||
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
},
|
||||
@@ -177,12 +181,16 @@ use crate::{
|
||||
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
|
||||
stream_volcengine_tts_sse,
|
||||
},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
wechat_auth::{
|
||||
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
||||
},
|
||||
wechat_pay::handle_wechat_pay_notify,
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
|
||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||
const BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -330,6 +338,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/sessions/{session_id}/revoke",
|
||||
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/me",
|
||||
axum::routing::patch(update_profile_identity).route_layer(
|
||||
@@ -347,6 +362,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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/miniprogram-login",
|
||||
post(login_wechat_mini_program),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/wechat/bind-phone",
|
||||
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
|
||||
@@ -635,6 +654,24 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/creation-entry/config",
|
||||
get(get_creation_entry_config_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/edutainment/baby-object-match/assets",
|
||||
post(generate_baby_object_match_assets).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/edutainment/baby-love-drawing/magic",
|
||||
post(create_baby_love_drawing_magic)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
@@ -955,6 +992,32 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/cover-image",
|
||||
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/background-image",
|
||||
post(generate_match3d_background_image_for_work).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/item-assets",
|
||||
post(generate_match3d_item_assets_for_work).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/generated-models",
|
||||
post(persist_match3d_generated_model).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(
|
||||
@@ -1413,6 +1476,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/wechat/notify",
|
||||
post(handle_wechat_pay_notify),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/feedback",
|
||||
post(submit_profile_feedback)
|
||||
@@ -1931,10 +1998,12 @@ mod tests {
|
||||
user: &module_auth::AuthUser,
|
||||
session_id: &str,
|
||||
) -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let active_session_id = state.seed_test_refresh_session_for_user(user, session_id);
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
session_id: active_session_id,
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
@@ -1943,13 +2012,22 @@ mod tests {
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
now,
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn read_access_token(response_body: &[u8]) -> String {
|
||||
let payload: Value =
|
||||
serde_json::from_slice(response_body).expect("login payload should be json");
|
||||
payload["token"]
|
||||
.as_str()
|
||||
.expect("access token should exist")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
@@ -1973,6 +2051,37 @@ mod tests {
|
||||
.expect("password login request should succeed")
|
||||
}
|
||||
|
||||
async fn password_login_request_with_client(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
password: &str,
|
||||
client_instance_id: &str,
|
||||
forwarded_for: &str,
|
||||
) -> axum::response::Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", client_instance_id)
|
||||
.header("x-forwarded-for", forwarded_for)
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": phone_number,
|
||||
"password": password
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("password login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("password login request should succeed")
|
||||
}
|
||||
|
||||
fn build_internal_creative_agent_app() -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
||||
@@ -2546,10 +2655,11 @@ mod tests {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config.clone()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
|
||||
let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: seed_user.id.clone(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
session_id: session_id.clone(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: seed_user.token_version,
|
||||
@@ -2587,10 +2697,7 @@ mod tests {
|
||||
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"]["sid"], Value::String(session_id));
|
||||
assert_eq!(
|
||||
payload["claims"]["ver"],
|
||||
Value::Number(serde_json::Number::from(seed_user.token_version))
|
||||
@@ -3744,6 +3851,210 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() {
|
||||
let config = AppConfig {
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/wechat/miniprogram-login")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-client-type", "mini_program")
|
||||
.header("x-client-runtime", "wechat_mini_program")
|
||||
.header("x-client-platform", "ios")
|
||||
.header("x-client-instance-id", "mini-instance-001")
|
||||
.header("x-mini-program-app-id", "wx-mini-test")
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"code": "wx-mini-code-001"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("mini program login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("mini program login request should succeed");
|
||||
|
||||
assert_eq!(login_response.status(), StatusCode::OK);
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("mini program login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("mini program login payload should be json");
|
||||
let token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("system token should exist")
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
login_payload["user"]["loginMethod"],
|
||||
Value::String("wechat".to_string())
|
||||
);
|
||||
assert!(refresh_cookie.contains("genarrative_refresh_session="));
|
||||
|
||||
let sessions_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
|
||||
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||
let sessions_body = sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["clientType"],
|
||||
Value::String("mini_program".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["clientRuntime"],
|
||||
Value::String("wechat_mini_program".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions_payload["sessions"][0]["miniProgramAppId"],
|
||||
Value::String("wx-mini-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_miniprogram_bind_phone_code_activates_pending_user() {
|
||||
let config = AppConfig {
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/wechat/miniprogram-login")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-client-type", "mini_program")
|
||||
.header("x-client-runtime", "wechat_mini_program")
|
||||
.header("x-client-platform", "ios")
|
||||
.header("x-client-instance-id", "mini-bind-instance-001")
|
||||
.header("x-mini-program-app-id", "wx-mini-test")
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"code": "wx-mini-code-bind-001"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("mini program login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("mini program login request should succeed");
|
||||
|
||||
assert_eq!(login_response.status(), StatusCode::OK);
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("mini program login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("mini program login payload should be json");
|
||||
let token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("system token should exist")
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
|
||||
let bind_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/wechat/bind-phone")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-client-type", "mini_program")
|
||||
.header("x-client-runtime", "wechat_mini_program")
|
||||
.header("x-client-platform", "ios")
|
||||
.header("x-client-instance-id", "mini-bind-instance-001")
|
||||
.header("x-mini-program-app-id", "wx-mini-test")
|
||||
.header("x-mini-program-env", "develop")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"wechatPhoneCode": "13800138000"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("bind request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("bind request should succeed");
|
||||
|
||||
assert_eq!(bind_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
bind_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("genarrative_refresh_session="))
|
||||
);
|
||||
let bind_body = bind_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("bind body should collect")
|
||||
.to_bytes();
|
||||
let bind_payload: Value =
|
||||
serde_json::from_slice(&bind_body).expect("bind payload should be json");
|
||||
|
||||
assert_eq!(
|
||||
bind_payload["user"]["bindingStatus"],
|
||||
Value::String("active".to_string())
|
||||
);
|
||||
assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
bind_payload["user"]["phoneNumberMasked"],
|
||||
Value::String("138****8000".to_string())
|
||||
);
|
||||
assert!(
|
||||
bind_payload["token"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wechat_bind_phone_merges_into_existing_phone_user() {
|
||||
let config = AppConfig {
|
||||
@@ -4044,12 +4355,17 @@ mod tests {
|
||||
session["clientType"] == Value::String("web_browser".to_string())
|
||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||
&& session["sessionCount"] == Value::Number(1.into())
|
||||
&& session["sessionIds"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.len() == 1)
|
||||
&& 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["sessionCount"] == Value::Number(1.into())
|
||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||
@@ -4057,6 +4373,108 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
let login_body = serde_json::json!({
|
||||
"phone": "13800138028",
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", "same-device")
|
||||
.header("x-forwarded-for", "203.0.113.10")
|
||||
.body(Body::from(login_body.clone()))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let access_token = read_access_token(&first_body);
|
||||
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", "same-device")
|
||||
.header("x-forwarded-for", "203.0.113.10")
|
||||
.body(Body::from(login_body))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
let sessions_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
|
||||
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||
let sessions_body = sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
let sessions = sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array");
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into()));
|
||||
assert_eq!(sessions[0]["isCurrent"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
sessions[0]["ipMasked"],
|
||||
Value::String("203.0.*.*".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sessions[0]["sessionIds"]
|
||||
.as_array()
|
||||
.expect("session ids should exist")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_phone() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4099,6 +4517,156 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_reset_allows_login_with_new_password_only() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let send_code_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/send-code")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13800138026",
|
||||
"scene": "reset_password"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("reset code request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("reset code request should succeed");
|
||||
assert_eq!(send_code_response.status(), StatusCode::OK);
|
||||
|
||||
let reset_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/password/reset")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13800138026",
|
||||
"code": "123456",
|
||||
"newPassword": "secret456"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("reset password request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("reset password request should succeed");
|
||||
assert_eq!(reset_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
reset_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("genarrative_refresh_session="))
|
||||
);
|
||||
|
||||
let old_password_response =
|
||||
password_login_request(app.clone(), "13800138026", TEST_PASSWORD).await;
|
||||
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_password_response = password_login_request(app, "13800138026", "secret456").await;
|
||||
assert_eq!(new_password_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_change_allows_login_with_new_password_only() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let token = read_access_token(&login_body);
|
||||
|
||||
let change_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/password/change")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"currentPassword": TEST_PASSWORD,
|
||||
"newPassword": "secret456"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("change password request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("change password request should succeed");
|
||||
assert_eq!(change_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
change_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let old_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("me request should succeed");
|
||||
assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let old_refresh_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("refresh request should succeed");
|
||||
assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let old_password_response =
|
||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_password_response = password_login_request(app, "13800138027", "secret456").await;
|
||||
assert_eq!(new_password_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_email_or_username_identifier() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -4133,23 +4701,16 @@ mod tests {
|
||||
};
|
||||
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 login_response =
|
||||
password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await;
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let token = read_access_token(&login_body);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -4310,6 +4871,141 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let first_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138030",
|
||||
TEST_PASSWORD,
|
||||
"revoke-current-device",
|
||||
"203.0.113.30",
|
||||
)
|
||||
.await;
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_access_token = read_access_token(&first_body);
|
||||
|
||||
let second_login_response = password_login_request_with_client(
|
||||
app.clone(),
|
||||
"13800138030",
|
||||
TEST_PASSWORD,
|
||||
"revoke-remote-device",
|
||||
"203.0.113.31",
|
||||
)
|
||||
.await;
|
||||
let second_cookie = second_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("second cookie should exist")
|
||||
.to_string();
|
||||
let second_body = second_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second login body should collect")
|
||||
.to_bytes();
|
||||
let second_access_token = read_access_token(&second_body);
|
||||
|
||||
let remote_sessions_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_cookie.clone())
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
assert_eq!(remote_sessions_response.status(), StatusCode::OK);
|
||||
let remote_sessions_body = remote_sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let remote_sessions_payload: Value =
|
||||
serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json");
|
||||
let remote_session_id = remote_sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array")
|
||||
.iter()
|
||||
.find(|session| session["isCurrent"] == Value::Bool(false))
|
||||
.and_then(|session| session["sessionId"].as_str())
|
||||
.expect("remote session id should exist")
|
||||
.to_string();
|
||||
|
||||
let revoke_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/auth/sessions/{remote_session_id}/revoke"))
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("revoke request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("revoke request should succeed");
|
||||
assert_eq!(revoke_response.status(), StatusCode::OK);
|
||||
|
||||
let current_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("current me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("current me request should succeed");
|
||||
assert_eq!(current_me_response.status(), StatusCode::OK);
|
||||
|
||||
let remote_me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {second_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("remote me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("remote me request should succeed");
|
||||
assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let remote_refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", second_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("remote refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("remote refresh request should succeed");
|
||||
assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4392,6 +5088,12 @@ mod tests {
|
||||
|
||||
let login_response =
|
||||
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
|
||||
let refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
@@ -4406,6 +5108,7 @@ mod tests {
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
@@ -4425,6 +5128,19 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("refresh request should succeed");
|
||||
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user