1ba34b8e25
新增 agc_write_file 直写工具并放宽自主构建执行路径 移除固定任务依赖与确认门槛对并行创作的阻塞 同步 DirectProject、预览、运行状态和 E2E 测试调整
828 lines
28 KiB
Rust
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(¤t)
|
|
.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())
|
|
);
|
|
}
|
|
}
|