2449e77461
## 变更 - 新增按服务 origin、平台 userId / Developer Key 摘要和本地 projectId 分区的 External Editor 项目绑定 - 新增按当前 principal、远端项目、本地 assetId、源 SHA-256、媒体类型和 canonical kind 分区的资源绑定 - manifest 中的 canvasProjectId / resourceId / assetObjectId 仅保留来源信息,不再作为当前账号的可编辑授权 - 切换账号后从本地正式资源重新上传、confirm、登记;图片、视频、角色动画、素材画布参考、art-spec 派生和 Direct 恢复统一使用当前账号绑定 - prepared / accepted / running 账本继续冻结原 principal;账号变化或远端结果不确定时停止补偿并保留现场等待对账 - 同步技术方案、decision log 和 pitfalls ## Review 结论 - 两路独立代码 review 均未发现剩余 P0-P2 - Review 发现并关闭了 max-pass 测试误放宽问题,恢复为绑定最大轮次的强断言 - 首轮 CI 暴露两处本 PR import 排序错误,已在独立提交09486f142中修复并复核 ## 验证 - Rust 完整测试:2301 passed,0 failed,16 ignored - Rust 集成与构建测试:5 + 2 + 14 passed - repository-ci 本地同构门禁通过:lint、typecheck、139 表 SpacetimeDB schema guard、403 个 appSurface 测试、web/admin-web build - External Editor procedure 真实 smoke 通过:精确重放、冲突、删除 fail-close、并发和孤儿检查 - Encoding check:5594 files - git diff --check 通过 - Gitea Project CI run 1253:Repository checks、Frontend、Backend、Native shell tests 全部通过 - 当前 heada0b8415be已合并 origin/master 44ee28c43,PR 无冲突 ## 后续依赖 PR #176 暴露了这一公共账号身份缺陷。该 PR 合并后,#176 需要 rebase,并删除或接入其局部 canonical cache,不能保留第二套账号绑定系统。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/182 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
617 lines
20 KiB
Rust
617 lines
20 KiB
Rust
use sha2::{Digest, Sha256};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) struct PlatformSessionSnapshot {
|
|
pub(crate) user_id: String,
|
|
pub(crate) access_token: String,
|
|
pub(crate) api_base_url: String,
|
|
pub(crate) generation: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum EditorApiMode {
|
|
PlatformAccount,
|
|
ExternalDeveloper,
|
|
}
|
|
|
|
pub(crate) fn editor_api_mode_for_build(
|
|
debug_assertions: bool,
|
|
game_chat_release_feature: bool,
|
|
) -> EditorApiMode {
|
|
if !debug_assertions && game_chat_release_feature {
|
|
EditorApiMode::ExternalDeveloper
|
|
} else {
|
|
EditorApiMode::PlatformAccount
|
|
}
|
|
}
|
|
|
|
pub(crate) fn editor_api_mode() -> EditorApiMode {
|
|
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PlatformSessionState {
|
|
generation: u64,
|
|
snapshot: Option<PlatformSessionSnapshot>,
|
|
}
|
|
|
|
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
|
|
|
|
fn platform_session() -> &'static Mutex<PlatformSessionState> {
|
|
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
|
|
}
|
|
|
|
fn install_platform_session_in(
|
|
current: &mut PlatformSessionState,
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) {
|
|
if generation < current.generation {
|
|
return;
|
|
}
|
|
if generation == current.generation {
|
|
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
|
snapshot.user_id == user_id
|
|
&& snapshot.access_token == access_token
|
|
&& snapshot.api_base_url == api_base_url
|
|
}) {
|
|
return;
|
|
}
|
|
// Equal-generation retries may only repeat the exact committed snapshot. In
|
|
// particular, a late install cannot revive a generation that was cleared.
|
|
return;
|
|
}
|
|
current.generation = generation;
|
|
current.snapshot = Some(PlatformSessionSnapshot {
|
|
user_id: user_id.to_string(),
|
|
access_token: access_token.to_string(),
|
|
api_base_url: api_base_url.to_string(),
|
|
generation,
|
|
});
|
|
}
|
|
|
|
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
|
|
if generation < current.generation {
|
|
return;
|
|
}
|
|
current.generation = generation;
|
|
current.snapshot = None;
|
|
}
|
|
|
|
pub(crate) fn install_platform_session(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
let snapshot =
|
|
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
install_platform_session_in(
|
|
&mut current,
|
|
&snapshot.user_id,
|
|
&snapshot.access_token,
|
|
&snapshot.api_base_url,
|
|
snapshot.generation,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn validated_platform_session_snapshot(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<PlatformSessionSnapshot, String> {
|
|
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
|
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
|
|
}
|
|
let user_id = user_id.trim();
|
|
let access_token = access_token.trim();
|
|
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
|
|
if user_id.is_empty() || user_id.len() > 256 {
|
|
return Err("陶泥儿登录用户身份无效".to_string());
|
|
}
|
|
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
|
return Err("陶泥儿登录凭据无效".to_string());
|
|
}
|
|
Ok(PlatformSessionSnapshot {
|
|
user_id: user_id.to_string(),
|
|
access_token: access_token.to_string(),
|
|
api_base_url,
|
|
generation,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn validate_platform_session_input(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ())
|
|
}
|
|
|
|
pub(crate) fn replace_platform_session_for_gui_owner(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
let snapshot =
|
|
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
current.generation = snapshot.generation;
|
|
current.snapshot = Some(snapshot);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn install_platform_session_checked(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
let snapshot =
|
|
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
install_platform_session_in(
|
|
&mut current,
|
|
&snapshot.user_id,
|
|
&snapshot.access_token,
|
|
&snapshot.api_base_url,
|
|
snapshot.generation,
|
|
);
|
|
if current.snapshot.as_ref() == Some(&snapshot) {
|
|
Ok(())
|
|
} else {
|
|
Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string())
|
|
}
|
|
}
|
|
|
|
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
|
let value = value.trim().trim_end_matches('/');
|
|
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
|
if !matches!(parsed.scheme(), "http" | "https")
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
|| parsed.path() != "/"
|
|
{
|
|
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
|
|
}
|
|
let host = parsed
|
|
.host_str()
|
|
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
|
let loopback = host == "localhost"
|
|
|| host == "127.0.0.1"
|
|
|| host
|
|
.parse::<std::net::IpAddr>()
|
|
.is_ok_and(|ip| ip.is_loopback());
|
|
if parsed.scheme() == "http" && !loopback {
|
|
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
pub(crate) fn clear_platform_session(generation: u64) {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
clear_platform_session_in(&mut current, generation);
|
|
}
|
|
|
|
pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
current.generation = generation;
|
|
current.snapshot = None;
|
|
}
|
|
|
|
pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
clear_platform_session_in(&mut current, generation);
|
|
if current.generation == generation && current.snapshot.is_none() {
|
|
Ok(())
|
|
} else {
|
|
Err("authentication-required: 平台登出 generation 已过期".to_string())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
|
platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.snapshot
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) fn current_platform_session_generation() -> u64 {
|
|
platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.generation
|
|
}
|
|
|
|
pub(crate) fn validate_platform_session_snapshot(
|
|
expected: &PlatformSessionSnapshot,
|
|
) -> Result<(), String> {
|
|
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
|
|
Ok(())
|
|
} else {
|
|
Err(
|
|
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
|
.to_string(),
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_validated_platform_session_fingerprint<T>(
|
|
expected_user_id: &str,
|
|
expected_api_base_url: &str,
|
|
expected_generation: u64,
|
|
expected_access_token_sha256: &str,
|
|
action: impl FnOnce() -> Result<T, String>,
|
|
) -> Result<T, String> {
|
|
let lease = acquire_validated_platform_session_fingerprint(
|
|
expected_user_id,
|
|
expected_api_base_url,
|
|
expected_generation,
|
|
expected_access_token_sha256,
|
|
)?;
|
|
let result = action();
|
|
drop(lease);
|
|
result
|
|
}
|
|
|
|
pub(crate) struct ValidatedPlatformSessionLease {
|
|
_guard: std::sync::MutexGuard<'static, PlatformSessionState>,
|
|
}
|
|
|
|
pub(crate) fn acquire_validated_platform_session_fingerprint(
|
|
expected_user_id: &str,
|
|
expected_api_base_url: &str,
|
|
expected_generation: u64,
|
|
expected_access_token_sha256: &str,
|
|
) -> Result<ValidatedPlatformSessionLease, String> {
|
|
let current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let matches = current.snapshot.as_ref().is_some_and(|snapshot| {
|
|
snapshot.user_id == expected_user_id
|
|
&& snapshot.api_base_url == expected_api_base_url
|
|
&& snapshot.generation == expected_generation
|
|
&& format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes()))
|
|
== expected_access_token_sha256
|
|
});
|
|
if !matches {
|
|
return Err(
|
|
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(ValidatedPlatformSessionLease { _guard: current })
|
|
}
|
|
|
|
fn platform_session_snapshot_matches(
|
|
current: Option<&PlatformSessionSnapshot>,
|
|
expected: &PlatformSessionSnapshot,
|
|
) -> bool {
|
|
current == Some(expected)
|
|
}
|
|
|
|
pub(crate) fn platform_session_is_available() -> bool {
|
|
current_platform_session().is_some()
|
|
}
|
|
|
|
pub(crate) fn platform_session_service_identity() -> Option<String> {
|
|
current_platform_session()
|
|
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static PLATFORM_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct TestPlatformSessionGuard {
|
|
_isolation: std::sync::MutexGuard<'static, ()>,
|
|
previous: Option<PlatformSessionState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for TestPlatformSessionGuard {
|
|
fn drop(&mut self) {
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
*current = self
|
|
.previous
|
|
.take()
|
|
.expect("platform session test guard must restore its prior state");
|
|
}
|
|
}
|
|
|
|
/// Installs a scoped platform-account session for tests that must exercise the
|
|
/// production-default client authentication path. The previous process state
|
|
/// is restored on drop so the fixture cannot leak credentials or account
|
|
/// identity to another test.
|
|
#[cfg(test)]
|
|
pub(crate) fn install_test_platform_session(
|
|
user_id: &str,
|
|
access_token: &str,
|
|
api_base_url: &str,
|
|
) -> TestPlatformSessionGuard {
|
|
let isolation = PLATFORM_SESSION_TEST_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let previous = std::mem::take(&mut *current);
|
|
*current = PlatformSessionState {
|
|
generation: 1,
|
|
snapshot: Some(PlatformSessionSnapshot {
|
|
user_id: user_id.to_string(),
|
|
access_token: access_token.to_string(),
|
|
api_base_url: api_base_url.to_string(),
|
|
generation: 1,
|
|
}),
|
|
};
|
|
drop(current);
|
|
TestPlatformSessionGuard {
|
|
_isolation: isolation,
|
|
previous: Some(previous),
|
|
}
|
|
}
|
|
|
|
/// Holds the shared test-session lock while presenting an explicit logged-out
|
|
/// state. Tests that assert missing-login behavior must use this guard so they
|
|
/// cannot observe a platform session installed by another parallel test.
|
|
#[cfg(test)]
|
|
pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard {
|
|
let isolation = PLATFORM_SESSION_TEST_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let mut current = platform_session()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let previous = std::mem::take(&mut *current);
|
|
*current = PlatformSessionState::default();
|
|
drop(current);
|
|
TestPlatformSessionGuard {
|
|
_isolation: isolation,
|
|
previous: Some(previous),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cleared_generation_rejects_late_install_and_older_clear() {
|
|
let mut state = PlatformSessionState::default();
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
1,
|
|
);
|
|
clear_platform_session_in(&mut state, 2);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"late-token-a",
|
|
"https://dev.genarrative.world",
|
|
1,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"same-generation-token",
|
|
"https://dev.genarrative.world",
|
|
2,
|
|
);
|
|
assert!(state.snapshot.is_none());
|
|
assert_eq!(state.generation, 2);
|
|
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-b",
|
|
"token-b",
|
|
"https://dev.genarrative.world",
|
|
3,
|
|
);
|
|
clear_platform_session_in(&mut state, 2);
|
|
assert_eq!(
|
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
|
Some("user-b")
|
|
);
|
|
assert_eq!(state.generation, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
|
|
let mut state = PlatformSessionState::default();
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-a",
|
|
"token-a",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
install_platform_session_in(
|
|
&mut state,
|
|
"user-b",
|
|
"token-b",
|
|
"https://dev.genarrative.world",
|
|
4,
|
|
);
|
|
assert_eq!(
|
|
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
|
Some("user-a")
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.snapshot
|
|
.as_ref()
|
|
.map(|value| value.access_token.as_str()),
|
|
Some("token-a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_generation_preserves_the_floor_after_session_clear() {
|
|
let _session = clear_test_platform_session();
|
|
install_platform_session(
|
|
"generation-floor-user",
|
|
"generation-floor-token",
|
|
"https://dev.genarrative.world",
|
|
41,
|
|
)
|
|
.expect("install session generation floor");
|
|
clear_platform_session(42);
|
|
|
|
assert_eq!(current_platform_session_generation(), 42);
|
|
assert!(current_platform_session().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
|
assert_eq!(
|
|
editor_api_mode_for_build(true, false),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(true, true),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(false, false),
|
|
EditorApiMode::PlatformAccount
|
|
);
|
|
assert_eq!(
|
|
editor_api_mode_for_build(false, true),
|
|
EditorApiMode::ExternalDeveloper
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
|
|
assert_eq!(
|
|
normalize_platform_api_base_url("https://staging.example.com/"),
|
|
Ok("https://staging.example.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
|
|
Ok("http://127.0.0.1:8080".to_string())
|
|
);
|
|
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
|
|
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
|
|
let expected = PlatformSessionSnapshot {
|
|
user_id: "user-a".to_string(),
|
|
access_token: "token-a".to_string(),
|
|
api_base_url: "https://dev.genarrative.world".to_string(),
|
|
generation: 4,
|
|
};
|
|
assert!(platform_session_snapshot_matches(
|
|
Some(&expected),
|
|
&expected
|
|
));
|
|
|
|
for current in [
|
|
None,
|
|
Some(PlatformSessionSnapshot {
|
|
user_id: "user-b".to_string(),
|
|
..expected.clone()
|
|
}),
|
|
Some(PlatformSessionSnapshot {
|
|
access_token: "token-b".to_string(),
|
|
generation: 5,
|
|
..expected.clone()
|
|
}),
|
|
] {
|
|
assert!(!platform_session_snapshot_matches(
|
|
current.as_ref(),
|
|
&expected
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validated_session_lease_linearizes_local_commit_with_account_switch() {
|
|
let _session = install_test_platform_session(
|
|
"lease-user-a",
|
|
"lease-token-a",
|
|
"https://dev.genarrative.world",
|
|
);
|
|
let expected = current_platform_session().expect("current lease session");
|
|
let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes()));
|
|
let lease = acquire_validated_platform_session_fingerprint(
|
|
&expected.user_id,
|
|
&expected.api_base_url,
|
|
expected.generation,
|
|
&token_sha256,
|
|
)
|
|
.expect("acquire validated session lease");
|
|
let (started_sender, started_receiver) = std::sync::mpsc::channel();
|
|
let (finished_sender, finished_receiver) = std::sync::mpsc::channel();
|
|
let switcher = std::thread::spawn(move || {
|
|
started_sender.send(()).expect("signal account switch");
|
|
install_platform_session(
|
|
"lease-user-b",
|
|
"lease-token-b",
|
|
"https://dev.genarrative.world",
|
|
2,
|
|
)
|
|
.expect("switch account after lease release");
|
|
finished_sender.send(()).expect("signal switched account");
|
|
});
|
|
started_receiver
|
|
.recv_timeout(std::time::Duration::from_secs(1))
|
|
.expect("switcher started");
|
|
assert!(
|
|
finished_receiver
|
|
.recv_timeout(std::time::Duration::from_millis(100))
|
|
.is_err(),
|
|
"account switch must wait until the synchronous local commit lease is released"
|
|
);
|
|
drop(lease);
|
|
finished_receiver
|
|
.recv_timeout(std::time::Duration::from_secs(1))
|
|
.expect("account switch completed after lease release");
|
|
switcher.join().expect("join account switcher");
|
|
assert_eq!(
|
|
current_platform_session().map(|session| session.user_id),
|
|
Some("lease-user-b".to_string())
|
|
);
|
|
}
|
|
}
|