Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs
T
Git Hooks Test 1ba34b8e25 放开 AGC 自主创作流程并补齐全流程执行能力
新增 agc_write_file 直写工具并放宽自主构建执行路径

移除固定任务依赖与确认门槛对并行创作的阻塞

同步 DirectProject、预览、运行状态和 E2E 测试调整
2026-08-28 20:55:23 +08:00

828 lines
28 KiB
Rust

use sha2::{Digest, Sha256};
use serde::Deserialize;
use std::fs::{self, OpenOptions};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes
/// a path, rather than credentials on argv, so a child Runner can inherit the
/// test identity without putting the bearer token in process listings.
pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str =
"GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE";
const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str =
"genarrative-agc-platform-session-fixture.v1";
const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024;
#[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,
external_developer_release_feature: bool,
) -> EditorApiMode {
if !debug_assertions && external_developer_release_feature {
EditorApiMode::ExternalDeveloper
} else {
EditorApiMode::PlatformAccount
}
}
pub(crate) fn editor_api_mode() -> EditorApiMode {
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(debug_assertions))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PlatformSessionFixture {
schema_version: String,
user_id: String,
access_token: String,
api_base_url: String,
generation: u64,
}
fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return true;
}
}
false
}
fn path_component_is_inside(root: &Path, candidate: &Path) -> bool {
#[cfg(windows)]
{
// Windows paths are case-insensitive. Comparing normalized UTF-16
// strings would introduce an unnecessary lossy conversion; component
// comparison with ASCII-folding covers the drive/normal path forms
// used by the fixture while retaining separators as boundaries.
let root = root.components().collect::<Vec<_>>();
let candidate = candidate.components().collect::<Vec<_>>();
return candidate.len() > root.len()
&& root.iter().zip(candidate.iter()).all(|(left, right)| {
left.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&right.as_os_str().to_string_lossy())
});
}
#[cfg(not(windows))]
{
candidate.starts_with(root) && candidate != root
}
}
fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result<PathBuf, String> {
if !config_dir.is_absolute() {
return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string());
}
if !fixture_path.is_absolute() {
return Err("平台登录态 fixture 路径必须是绝对路径".to_string());
}
// Check the directory entry before canonicalization so a symlink/junction
// cannot be silently followed into an unrelated credential location.
let config_entry = fs::symlink_metadata(config_dir)
.map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?;
if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() {
return Err("平台登录态 fixture AppData 必须是普通目录".to_string());
}
let canonical_config = fs::canonicalize(config_dir)
.map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?;
let fixture_entry = fs::symlink_metadata(fixture_path)
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() {
return Err("平台登录态 fixture 必须是普通文件".to_string());
}
if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
}
let canonical_fixture = fs::canonicalize(fixture_path)
.map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?;
if !path_component_is_inside(&canonical_config, &canonical_fixture) {
return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string());
}
// Walk the path below AppData and reject links/reparse points in every
// ancestor as well as at the leaf. This keeps the check useful on
// platforms where canonicalize otherwise follows a junction.
let relative = canonical_fixture
.strip_prefix(&canonical_config)
.map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?;
let mut current = canonical_config;
for component in relative.components() {
current.push(component.as_os_str());
let metadata = fs::symlink_metadata(&current)
.map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?;
if metadata_is_link_or_reparse(&metadata) {
return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string());
}
if current != canonical_fixture && !metadata.is_dir() {
return Err("平台登录态 fixture 父路径必须是普通目录".to_string());
}
}
Ok(canonical_fixture)
}
fn read_fixture_file(path: &Path) -> Result<Vec<u8>, String> {
let before = fs::symlink_metadata(path)
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
if metadata_is_link_or_reparse(&before) || !before.is_file() {
return Err("平台登录态 fixture 必须是普通文件".to_string());
}
if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options
.open(path)
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
let opened = file
.metadata()
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() {
return Err("平台登录态 fixture 文件身份校验失败".to_string());
}
let mut bytes = Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize);
file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|_| "读取平台登录态 fixture 失败".to_string())?;
if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
}
Ok(bytes)
}
fn parse_platform_session_fixture(bytes: &[u8]) -> Result<PlatformSessionFixture, String> {
let fixture = serde_json::from_slice::<PlatformSessionFixture>(bytes)
.map_err(|_| "平台登录态 fixture 格式无效".to_string())?;
if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION {
return Err("平台登录态 fixture 版本不受支持".to_string());
}
if fixture.generation == 0 {
return Err("平台登录态 fixture generation 无效".to_string());
}
if fixture.user_id.chars().any(|character| character.is_control()) {
return Err("平台登录态 fixture 用户身份无效".to_string());
}
if fixture.access_token.chars().any(|character| character.is_control()) {
return Err("平台登录态 fixture 凭据无效".to_string());
}
Ok(fixture)
}
/// Loads a deliberately narrow, file-backed account fixture for Debug E2E
/// processes. Release binaries fail closed if the hook is present. The
/// fixture path must be a regular file below the process' explicit
/// `--config-dir`; credentials are never accepted on argv or emitted in an
/// error string.
pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> {
load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions))
}
pub(crate) fn load_platform_session_fixture_from_env_for_build(
config_dir: &Path,
debug_build: bool,
) -> Result<(), String> {
let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else {
return Ok(());
};
if !debug_build {
return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string());
}
let raw_path = raw_path
.to_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?;
let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?;
let bytes = read_fixture_file(&fixture_path)?;
let fixture = parse_platform_session_fixture(&bytes)?;
let snapshot = validated_platform_session_snapshot(
&fixture.user_id,
&fixture.access_token,
&fixture.api_base_url,
fixture.generation,
)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// A fresh CLI/Runner normally starts at generation zero. Replacing the
// state here also makes a Debug GUI fixture deterministic without relaxing
// the normal account-switch generation rules.
current.generation = snapshot.generation;
current.snapshot = Some(snapshot);
Ok(())
}
#[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("独立外部开发发行版不接受陶泥儿网站登录态".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())
);
}
}