mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
489 lines
16 KiB
Rust
489 lines
16 KiB
Rust
#[path = "config_auth_app_state_connectivity_e2e.rs"]
|
|
mod base_coverage;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
use openhuman_core::openhuman::app_state::{snapshot, update_local_state, StoredAppStatePatch};
|
|
use openhuman_core::openhuman::config::rpc as config_rpc;
|
|
use openhuman_core::openhuman::credentials::{
|
|
auth_get_session_token_json, clear_session, list_provider_credentials,
|
|
remove_provider_credentials, store_provider_credentials, store_session, AuthService,
|
|
};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
struct Round13EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl Round13EnvVarGuard {
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::set_var(key, value);
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::set_var(key, path.as_os_str());
|
|
Self { key, old }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::remove_var(key);
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for Round13EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Round13Harness {
|
|
_tmp: TempDir,
|
|
workspace_override: PathBuf,
|
|
_guards: Vec<Round13EnvVarGuard>,
|
|
}
|
|
|
|
impl Round13Harness {
|
|
async fn config(&self) -> openhuman_core::openhuman::config::Config {
|
|
config_rpc::load_config_with_timeout()
|
|
.await
|
|
.expect("isolated config should load")
|
|
}
|
|
|
|
fn app_state_file(&self) -> PathBuf {
|
|
self.workspace_override
|
|
.join("workspace/state/app-state.json")
|
|
}
|
|
}
|
|
|
|
fn round13_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
// Delegate to the lock owned by the included `base_coverage` (config_auth)
|
|
// module so round13 env mutations serialize against every other
|
|
// OPENHUMAN_WORKSPACE/BACKEND_URL-mutating test in this combined binary.
|
|
// Two separate mutexes let the two groups race and quarantine the wrong
|
|
// workspace (flaky `!state_file.exists()` / spurious JSON-RPC errors).
|
|
base_coverage::env_lock()
|
|
}
|
|
|
|
fn write_round13_min_config(openhuman_dir: &Path) {
|
|
std::fs::create_dir_all(openhuman_dir).expect("create openhuman config dir");
|
|
let cfg = r#"api_url = "http://127.0.0.1:9"
|
|
default_model = "round13-raw-coverage-model"
|
|
default_temperature = 0.2
|
|
onboarding_completed = false
|
|
chat_onboarding_completed = false
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
|
|
[local_ai]
|
|
enabled = false
|
|
runtime_enabled = false
|
|
opt_in_confirmed = false
|
|
|
|
[memory]
|
|
provider = "none"
|
|
embedding_provider = "none"
|
|
embedding_model = "none"
|
|
embedding_dimensions = 0
|
|
auto_save = false
|
|
|
|
[memory_tree]
|
|
embedding_strict = false
|
|
"#;
|
|
std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml");
|
|
let _: openhuman_core::openhuman::config::Config =
|
|
toml::from_str(cfg).expect("test config must match schema");
|
|
}
|
|
|
|
fn round13_setup() -> Round13Harness {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let workspace_override = tmp.path().join("openhuman");
|
|
write_round13_min_config(&workspace_override);
|
|
|
|
let guards = vec![
|
|
Round13EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace_override),
|
|
Round13EnvVarGuard::set_to_path("HOME", tmp.path()),
|
|
Round13EnvVarGuard::unset("BACKEND_URL"),
|
|
Round13EnvVarGuard::unset("VITE_BACKEND_URL"),
|
|
Round13EnvVarGuard::unset("OPENHUMAN_API_URL"),
|
|
Round13EnvVarGuard::unset("OPENHUMAN_CORE_RPC_URL"),
|
|
Round13EnvVarGuard::unset("OPENHUMAN_CORE_PORT"),
|
|
Round13EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
Round13EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
Round13EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
Round13EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
Round13EnvVarGuard::set("OPENHUMAN_BROWSER_ALLOW_ALL_RPC_ENABLE", ""),
|
|
];
|
|
|
|
Round13Harness {
|
|
_tmp: tmp,
|
|
workspace_override,
|
|
_guards: guards,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_provider_field_fallbacks_default_profile_and_non_active_listing() {
|
|
let _lock = round13_env_lock();
|
|
let harness = round13_setup();
|
|
let config = harness.config().await;
|
|
|
|
let empty = store_provider_credentials(&config, " ", None, None, None, None).await;
|
|
assert_eq!(empty.unwrap_err(), "provider is required");
|
|
|
|
let invalid_fields = store_provider_credentials(
|
|
&config,
|
|
"raw-provider",
|
|
None,
|
|
None,
|
|
Some(json!("bad")),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(invalid_fields
|
|
.unwrap_err()
|
|
.contains("fields must be a JSON object"));
|
|
|
|
let missing =
|
|
store_provider_credentials(&config, "raw-provider", None, None, Some(json!({})), None)
|
|
.await;
|
|
assert!(missing
|
|
.unwrap_err()
|
|
.contains("provide at least one credential"));
|
|
|
|
let stored_from_token_field = store_provider_credentials(
|
|
&config,
|
|
"raw-provider",
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"token": "field-token",
|
|
"region": "us-test-1"
|
|
})),
|
|
Some(true),
|
|
)
|
|
.await
|
|
.expect("store token field credential");
|
|
assert_eq!(stored_from_token_field.value.provider, "raw-provider");
|
|
assert_eq!(stored_from_token_field.value.profile_name, "default");
|
|
assert!(stored_from_token_field.value.has_token);
|
|
|
|
let auth = AuthService::from_config(&config);
|
|
assert_eq!(
|
|
auth.get_provider_bearer_token("raw-provider", None)
|
|
.expect("read active bearer")
|
|
.as_deref(),
|
|
Some("field-token")
|
|
);
|
|
|
|
let stored_from_api_key = store_provider_credentials(
|
|
&config,
|
|
"raw-provider",
|
|
Some("secondary"),
|
|
None,
|
|
Some(json!({
|
|
"api_key": "api-key-token",
|
|
"label": "kept as metadata"
|
|
})),
|
|
Some(false),
|
|
)
|
|
.await
|
|
.expect("store api_key field credential");
|
|
assert_eq!(stored_from_api_key.value.profile_name, "secondary");
|
|
|
|
let listed = list_provider_credentials(&config, Some("raw-provider".to_string()))
|
|
.await
|
|
.expect("list raw provider credentials")
|
|
.value;
|
|
let names = listed
|
|
.iter()
|
|
.map(|profile| profile.profile_name.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(names, vec!["default", "secondary"]);
|
|
|
|
let removed_default = remove_provider_credentials(&config, "raw-provider", None)
|
|
.await
|
|
.expect("remove default profile");
|
|
assert_eq!(removed_default.value["removed"], true);
|
|
assert_eq!(removed_default.value["profile"], "default");
|
|
|
|
let removed_missing = remove_provider_credentials(&config, "raw-provider", Some("missing"))
|
|
.await
|
|
.expect("remove missing profile is non-fatal");
|
|
assert_eq!(removed_missing.value["removed"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_local_session_string_payload_and_double_clear_are_offline() {
|
|
let _lock = round13_env_lock();
|
|
let harness = round13_setup();
|
|
let config = harness.config().await;
|
|
|
|
let empty_token = store_session(&config, " ", None, None).await;
|
|
assert_eq!(empty_token.unwrap_err(), "token is required");
|
|
|
|
let local_without_user = store_session(&config, "header.payload.local", None, None).await;
|
|
assert_eq!(
|
|
local_without_user.unwrap_err(),
|
|
"local session requires a user payload"
|
|
);
|
|
|
|
let stored = store_session(
|
|
&config,
|
|
"header.payload.local",
|
|
Some("ignored-user-hint".to_string()),
|
|
Some(json!("string user payload is preserved")),
|
|
)
|
|
.await
|
|
.expect("store local session with non-object user payload");
|
|
assert_eq!(stored.value.provider, "app-session");
|
|
assert!(stored
|
|
.logs
|
|
.iter()
|
|
.any(|log| log == "local session accepted without backend validation"));
|
|
|
|
let effective_config = config_rpc::load_config_with_timeout()
|
|
.await
|
|
.expect("reload active local user config");
|
|
let token = auth_get_session_token_json(&effective_config)
|
|
.await
|
|
.expect("session token")
|
|
.value;
|
|
assert_eq!(token["token"], "header.payload.local");
|
|
|
|
let first_clear = clear_session(&effective_config)
|
|
.await
|
|
.expect("clear stored session");
|
|
assert_eq!(first_clear.value["removed"], true);
|
|
|
|
let signed_out_config = config_rpc::load_config_with_timeout()
|
|
.await
|
|
.expect("reload signed-out config");
|
|
let second_clear = clear_session(&signed_out_config)
|
|
.await
|
|
.expect("clear missing session is idempotent");
|
|
assert_eq!(second_clear.value["removed"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_state() {
|
|
let _lock = round13_env_lock();
|
|
let harness = round13_setup();
|
|
|
|
let updated = update_local_state(StoredAppStatePatch {
|
|
keyring_consent: None,
|
|
encryption_key: Some(Some(" raw-key ".to_string())),
|
|
onboarding_tasks: Some(Some(Default::default())),
|
|
})
|
|
.await
|
|
.expect("update local app state")
|
|
.value;
|
|
assert_eq!(updated.encryption_key.as_deref(), Some("raw-key"));
|
|
assert!(updated.onboarding_tasks.is_some());
|
|
|
|
let cleared_key = update_local_state(StoredAppStatePatch {
|
|
keyring_consent: None,
|
|
encryption_key: Some(Some(" ".to_string())),
|
|
onboarding_tasks: None,
|
|
})
|
|
.await
|
|
.expect("blank key clears encryption key")
|
|
.value;
|
|
assert!(cleared_key.encryption_key.is_none());
|
|
assert!(cleared_key.onboarding_tasks.is_some());
|
|
|
|
let cleared_tasks = update_local_state(StoredAppStatePatch {
|
|
keyring_consent: None,
|
|
encryption_key: None,
|
|
onboarding_tasks: Some(None),
|
|
})
|
|
.await
|
|
.expect("null tasks clear onboarding tasks")
|
|
.value;
|
|
assert!(cleared_tasks.encryption_key.is_none());
|
|
assert!(cleared_tasks.onboarding_tasks.is_none());
|
|
|
|
let unchanged = update_local_state(StoredAppStatePatch {
|
|
keyring_consent: None,
|
|
encryption_key: None,
|
|
onboarding_tasks: None,
|
|
})
|
|
.await
|
|
.expect("empty patch preserves cleared state")
|
|
.value;
|
|
assert!(unchanged.encryption_key.is_none());
|
|
assert!(unchanged.onboarding_tasks.is_none());
|
|
|
|
let raw = std::fs::read_to_string(harness.app_state_file()).expect("app state persisted");
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(&raw).expect("valid app state json"),
|
|
json!({})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_app_state_snapshot_quarantines_null_and_malformed_local_state_files() {
|
|
let _lock = round13_env_lock();
|
|
let harness = round13_setup();
|
|
let state_file = harness.app_state_file();
|
|
let state_dir = state_file.parent().expect("state dir");
|
|
std::fs::create_dir_all(state_dir).expect("create state dir");
|
|
|
|
std::fs::write(&state_file, "null").expect("write semantically invalid app state");
|
|
let null_snapshot = snapshot().await.expect("snapshot with null state").value;
|
|
assert!(null_snapshot.local_state.encryption_key.is_none());
|
|
assert!(null_snapshot.local_state.onboarding_tasks.is_none());
|
|
assert!(
|
|
!state_file.exists(),
|
|
"invalid app-state.json should be quarantined or removed"
|
|
);
|
|
assert!(
|
|
std::fs::read_dir(state_dir)
|
|
.expect("state dir entries")
|
|
.filter_map(Result::ok)
|
|
.any(|entry| entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("app-state.json.corrupted.")),
|
|
"null app-state file should leave a quarantine artifact"
|
|
);
|
|
|
|
std::fs::write(&state_file, "{not-json").expect("write malformed app state");
|
|
let malformed_snapshot = snapshot()
|
|
.await
|
|
.expect("snapshot with malformed state")
|
|
.value;
|
|
assert!(malformed_snapshot.local_state.encryption_key.is_none());
|
|
assert!(malformed_snapshot.local_state.onboarding_tasks.is_none());
|
|
assert!(!state_file.exists());
|
|
}
|
|
|
|
async fn spawn_probe_listener(
|
|
host: &str,
|
|
status: &str,
|
|
body: &'static str,
|
|
) -> Option<(
|
|
u16,
|
|
tokio::task::JoinHandle<()>,
|
|
tokio::sync::oneshot::Sender<()>,
|
|
)> {
|
|
let listener = match tokio::net::TcpListener::bind((host, 0)).await {
|
|
Ok(listener) => listener,
|
|
Err(_) => return None,
|
|
};
|
|
let port = listener.local_addr().expect("probe listener addr").port();
|
|
let status = status.to_string();
|
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut shutdown_rx => break,
|
|
accepted = listener.accept() => {
|
|
let Ok((mut stream, _addr)) = accepted else {
|
|
break;
|
|
};
|
|
let mut req_buf = [0_u8; 1024];
|
|
let _ = stream.read(&mut req_buf).await;
|
|
let response = format!(
|
|
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
Some((port, task, shutdown_tx))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_connectivity_picker_identifies_openhuman_probe_listener() {
|
|
let Some((preferred, task, shutdown_tx)) =
|
|
spawn_probe_listener("127.0.0.1", "200 OK", r#"{"name":"openhuman","ok":true}"#).await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let result = openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host(
|
|
"127.0.0.1",
|
|
preferred,
|
|
)
|
|
.await;
|
|
let err = result.expect_err("openhuman probe listener should request takeover");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
openhuman_core::openhuman::connectivity::rpc::PickListenPortError::WouldTakeOver {
|
|
preferred: p,
|
|
ref fingerprint
|
|
} if p == preferred && fingerprint == "openhuman-core"
|
|
),
|
|
"unexpected picker error: {err:?}"
|
|
);
|
|
|
|
let _ = shutdown_tx.send(());
|
|
let _ = task.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_connectivity_picker_falls_back_for_non_success_probe_status() {
|
|
let Some((preferred, task, shutdown_tx)) = spawn_probe_listener(
|
|
"127.0.0.1",
|
|
"503 Service Unavailable",
|
|
r#"{"name":"openhuman"}"#,
|
|
)
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let picked = openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host(
|
|
"127.0.0.1",
|
|
preferred,
|
|
)
|
|
.await
|
|
.expect("non-openhuman status should fall back");
|
|
assert_ne!(picked.port, preferred);
|
|
assert_eq!(picked.fallback_from, Some(preferred));
|
|
drop(picked.listener);
|
|
|
|
let _ = shutdown_tx.send(());
|
|
let _ = task.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_round13_connectivity_picker_falls_back_for_non_identifying_probe_body() {
|
|
let Some((preferred, task, shutdown_tx)) =
|
|
spawn_probe_listener("127.0.0.1", "200 OK", r#"{"name":"someone-else"}"#).await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let picked = openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host(
|
|
"127.0.0.1",
|
|
preferred,
|
|
)
|
|
.await
|
|
.expect("non-identifying body should fall back");
|
|
assert_ne!(picked.port, preferred);
|
|
assert_eq!(picked.fallback_from, Some(preferred));
|
|
drop(picked.listener);
|
|
|
|
let _ = shutdown_tx.send(());
|
|
let _ = task.await;
|
|
}
|