修复多端登录互相顶号

单设备退出只撤销当前 refresh session,不再提升账号级 token_version

认证中间件和 refresh 接口在本进程未命中会话时按需刷新 SpacetimeDB 认证工作集

补充多端登录与跨进程会话补水回归测试

同步项目文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-07 20:54:35 +08:00
parent a5143fa0cb
commit cc84656a1f
9 changed files with 463 additions and 55 deletions

View File

@@ -3844,6 +3844,111 @@ mod tests {
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_current_device_keeps_other_device_session_alive() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138031", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = password_login_request_with_client(
app.clone(),
"13800138031",
TEST_PASSWORD,
"logout-current-device",
"203.0.113.41",
)
.await;
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_access_token = read_access_token(&first_login_body);
let second_login_response = password_login_request_with_client(
app.clone(),
"13800138031",
TEST_PASSWORD,
"logout-other-device",
"203.0.113.42",
)
.await;
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 second_login_body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let second_access_token = read_access_token(&second_login_body);
let logout_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/logout")
.header("authorization", format!("Bearer {first_access_token}"))
.header("cookie", first_refresh_cookie)
.body(Body::empty())
.expect("logout request should build"),
)
.await
.expect("logout request should succeed");
assert_eq!(logout_response.status(), StatusCode::OK);
let first_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {first_access_token}"))
.body(Body::empty())
.expect("first me request should build"),
)
.await
.expect("first me request should succeed");
assert_eq!(first_me_response.status(), StatusCode::UNAUTHORIZED);
let second_me_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {second_access_token}"))
.body(Body::empty())
.expect("second me request should build"),
)
.await
.expect("second me request should succeed");
assert_eq!(second_me_response.status(), StatusCode::OK);
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::OK);
}
#[tokio::test]
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let state = AppState::new(AppConfig::default()).expect("state should build");