mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
6027 lines
204 KiB
Rust
6027 lines
204 KiB
Rust
//! Focused JSON-RPC E2E coverage for config, auth/credentials, app_state,
|
|
//! and connectivity controller surfaces.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{
|
|
atomic::{AtomicUsize, Ordering},
|
|
Arc, Mutex, OnceLock,
|
|
};
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::{Path as AxumPath, State};
|
|
use axum::http::{header::AUTHORIZATION, HeaderMap};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{delete, get, post};
|
|
use axum::{Json, Router};
|
|
use reqwest::StatusCode;
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
use openhuman_core::api::config::{
|
|
api_base_from_env, api_url, app_env_from_env, default_api_base_url_for_env, effective_api_url,
|
|
effective_backend_api_url, effective_inference_url, looks_like_local_ai_endpoint,
|
|
normalize_api_base_url, APP_ENV_VAR, DEFAULT_API_BASE_URL, DEFAULT_STAGING_API_BASE_URL,
|
|
OPENHUMAN_INFERENCE_PATH, VITE_APP_ENV_VAR,
|
|
};
|
|
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
|
use openhuman_core::core::event_bus::{DomainEvent, EventHandler};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
use openhuman_core::openhuman::app_state::app_state_schemas;
|
|
use openhuman_core::openhuman::config::schema::{
|
|
generate_provider_id, generate_voice_provider_id, is_slug_reserved, is_voice_slug_reserved,
|
|
migrate_legacy_fields, AuditConfig, AuthStyle, CapabilityProviderConfig,
|
|
CapabilityProviderTrustState, CloudProviderCreds, CloudProviderType, DashboardConfig,
|
|
DingTalkConfig, DiscordConfig, EventStreamConfig, IrcConfig, LarkConfig, MatrixConfig,
|
|
MemoryConfig, MemoryContextWindow, ModelHealthConfig, OrchestratorModelConfig, ProxyConfig,
|
|
ProxyScope, QQConfig, ResourceLimitsConfig, SandboxConfig, SecurityConfig, SlackConfig,
|
|
SttApiStyle, TelegramConfig, TtsApiStyle, VoiceCapability, VoiceProviderCreds, WebhookConfig,
|
|
WhatsAppConfig,
|
|
};
|
|
use openhuman_core::openhuman::config::settings_cli::{
|
|
settings_section_json, ConfigSnapshotFields,
|
|
};
|
|
use openhuman_core::openhuman::config::{
|
|
clear_active_user, default_projects_dir, output_language_directive, pre_login_user_dir,
|
|
read_active_user_id, user_openhuman_dir, write_active_user_id, AgentConfig, ChannelsConfig,
|
|
Config, DaemonConfig, DelegateAgentConfig, DictationActivationMode, LlmBackend,
|
|
ReflectionSource, TeamModelConfig, UpdateRestartStrategy,
|
|
};
|
|
use openhuman_core::openhuman::connectivity::{
|
|
all_connectivity_controller_schemas, all_connectivity_registered_controllers,
|
|
connectivity_controller_schema,
|
|
};
|
|
use openhuman_core::openhuman::credentials::bus::SessionExpiredSubscriber;
|
|
use openhuman_core::openhuman::credentials::cli::{
|
|
cli_auth_list, cli_auth_login, cli_auth_logout, cli_auth_status, parse_field_equals_entries,
|
|
};
|
|
use openhuman_core::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet};
|
|
use openhuman_core::openhuman::credentials::session_support::{
|
|
build_session_state, get_session_token, is_local_session_token, load_app_session_profile,
|
|
parse_fields_value, profile_name_or_default, session_state_from_profile,
|
|
session_token_from_profile, summarize_auth_profile,
|
|
};
|
|
use openhuman_core::openhuman::credentials::{
|
|
clear_composio_api_key, decrypt_secret, encrypt_secret, get_composio_api_key,
|
|
list_provider_credentials_by_prefix, normalize_provider, rpc_store_composio_api_key,
|
|
store_composio_api_key, AuthService, APP_SESSION_PROVIDER, COMPOSIO_DIRECT_PROVIDER,
|
|
};
|
|
|
|
const TEST_RPC_TOKEN: &str = "worker-a-domain-e2e-token";
|
|
|
|
static AUTH_INIT: OnceLock<()> = OnceLock::new();
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
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 EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
|
|
// `pub` so binaries that `#[path]`-include this file as a module (e.g.
|
|
// `config_credentials_raw_coverage_e2e.rs` as `base_coverage`) can route their
|
|
// own env-mutating tests through the SAME lock, serializing all
|
|
// OPENHUMAN_WORKSPACE/BACKEND_URL mutations in the combined binary.
|
|
pub fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
fn ensure_rpc_auth() {
|
|
AUTH_INIT.get_or_init(|| {
|
|
std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN);
|
|
let token_dir = std::env::temp_dir().join("openhuman-worker-a-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc auth token");
|
|
});
|
|
}
|
|
|
|
async fn serve_rpc() -> (
|
|
SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
ensure_rpc_auth();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind rpc listener");
|
|
let addr = listener.local_addr().expect("rpc listener addr");
|
|
let router = build_core_http_router(false);
|
|
let join = tokio::spawn(async move { axum::serve(listener, router).await });
|
|
(addr, join)
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MockBackendState {
|
|
auth_me_hits: Arc<AtomicUsize>,
|
|
}
|
|
|
|
async fn serve_mock_backend() -> (
|
|
String,
|
|
MockBackendState,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
let state = MockBackendState::default();
|
|
let app = Router::new()
|
|
.route("/auth/me", get(mock_auth_me))
|
|
.route("/api/auth/me", get(mock_auth_me))
|
|
.route("/auth/login-token/consume", post(mock_consume_login_token))
|
|
.route(
|
|
"/api/auth/login-token/consume",
|
|
post(mock_consume_login_token),
|
|
)
|
|
.route(
|
|
"/auth/channels/{channel}/link-token",
|
|
post(mock_channel_link_token),
|
|
)
|
|
.route(
|
|
"/api/auth/channels/{channel}/link-token",
|
|
post(mock_channel_link_token),
|
|
)
|
|
.route("/auth/integrations", get(mock_integrations))
|
|
.route("/api/auth/integrations", get(mock_integrations))
|
|
.route("/auth/github/connect", get(mock_oauth_connect))
|
|
.route("/api/auth/github/connect", get(mock_oauth_connect))
|
|
.route(
|
|
"/auth/integrations/{integration_id}/tokens",
|
|
post(mock_integration_tokens),
|
|
)
|
|
.route(
|
|
"/api/auth/integrations/{integration_id}/tokens",
|
|
post(mock_integration_tokens),
|
|
)
|
|
.route(
|
|
"/auth/integrations/{integration_id}/client-key",
|
|
post(mock_client_key),
|
|
)
|
|
.route(
|
|
"/api/auth/integrations/{integration_id}/client-key",
|
|
post(mock_client_key),
|
|
)
|
|
.route(
|
|
"/auth/integrations/{integration_id}",
|
|
delete(mock_revoke_integration),
|
|
)
|
|
.route(
|
|
"/api/auth/integrations/{integration_id}",
|
|
delete(mock_revoke_integration),
|
|
)
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind mock backend");
|
|
let addr = listener.local_addr().expect("mock backend addr");
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), state, join)
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct SequenceAuthBackendState {
|
|
auth_me_hits: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct NullAuthBackendState {
|
|
auth_me_hits: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StaticAuthBackendState {
|
|
auth_me_hits: Arc<AtomicUsize>,
|
|
user: Arc<Value>,
|
|
}
|
|
|
|
async fn serve_sequence_auth_backend() -> (
|
|
String,
|
|
SequenceAuthBackendState,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
let state = SequenceAuthBackendState::default();
|
|
let app = Router::new()
|
|
.route("/auth/me", get(sequence_auth_me))
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind sequence auth backend");
|
|
let addr = listener.local_addr().expect("sequence auth backend addr");
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), state, join)
|
|
}
|
|
|
|
async fn serve_null_auth_backend() -> (
|
|
String,
|
|
NullAuthBackendState,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
let state = NullAuthBackendState::default();
|
|
let app = Router::new()
|
|
.route("/auth/me", get(null_auth_me))
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind null auth backend");
|
|
let addr = listener.local_addr().expect("null auth backend addr");
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), state, join)
|
|
}
|
|
|
|
async fn serve_static_auth_backend(
|
|
user: Value,
|
|
) -> (
|
|
String,
|
|
StaticAuthBackendState,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
let state = StaticAuthBackendState {
|
|
auth_me_hits: Arc::new(AtomicUsize::new(0)),
|
|
user: Arc::new(user),
|
|
};
|
|
let app = Router::new()
|
|
.route("/auth/me", get(static_auth_me))
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind static auth backend");
|
|
let addr = listener.local_addr().expect("static auth backend addr");
|
|
let join = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(format!("http://{addr}"), state, join)
|
|
}
|
|
|
|
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
|
headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
}
|
|
|
|
async fn mock_auth_me(State(state): State<MockBackendState>, headers: HeaderMap) -> Json<Value> {
|
|
state.auth_me_hits.fetch_add(1, Ordering::SeqCst);
|
|
let auth = bearer(&headers).unwrap_or_default();
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"id": "remote-user-1",
|
|
"_id": "remote-user-1",
|
|
"name": "Remote Worker",
|
|
"email": "remote-worker@example.test",
|
|
"authHeader": auth
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn sequence_auth_me(
|
|
State(state): State<SequenceAuthBackendState>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let hit = state.auth_me_hits.fetch_add(1, Ordering::SeqCst) + 1;
|
|
match hit {
|
|
1 => {
|
|
let auth = bearer(&headers).unwrap_or_default();
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"id": "sequence-user",
|
|
"name": "Sequence Worker",
|
|
"email": "sequence-worker@example.test",
|
|
"authHeader": auth
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
2 => Json(json!({
|
|
"success": true,
|
|
"data": {}
|
|
}))
|
|
.into_response(),
|
|
_ => (
|
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({
|
|
"success": false,
|
|
"error": "forced auth/me failure"
|
|
})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn null_auth_me(State(state): State<NullAuthBackendState>, headers: HeaderMap) -> Response {
|
|
let hit = state.auth_me_hits.fetch_add(1, Ordering::SeqCst) + 1;
|
|
match hit {
|
|
1 => {
|
|
let auth = bearer(&headers).unwrap_or_default();
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"id": "null-sequence-user",
|
|
"name": "Null Sequence Worker",
|
|
"email": "null-sequence@example.test",
|
|
"authHeader": auth
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
_ => Json(json!({
|
|
"success": true,
|
|
"data": null
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn static_auth_me(
|
|
State(state): State<StaticAuthBackendState>,
|
|
_headers: HeaderMap,
|
|
) -> Json<Value> {
|
|
state.auth_me_hits.fetch_add(1, Ordering::SeqCst);
|
|
Json(json!({
|
|
"success": true,
|
|
"data": (*state.user).clone()
|
|
}))
|
|
}
|
|
|
|
async fn mock_consume_login_token(Json(body): Json<Value>) -> Json<Value> {
|
|
// Token now arrives in the JSON body (`{ token }`), not the URL path, and the
|
|
// response field is `jwt` (matches backend `routes/auth.ts`).
|
|
let token = body
|
|
.get("token")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default();
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"jwt": format!("jwt-from-{token}")
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn mock_channel_link_token(AxumPath(channel): AxumPath<String>) -> Json<Value> {
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"channel": channel,
|
|
"linkToken": "link-token-123",
|
|
"expiresIn": 300
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn mock_integrations() -> Json<Value> {
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"integrations": [{
|
|
"id": "0123456789abcdef01234567",
|
|
"provider": "github",
|
|
"createdAt": "2026-01-01T00:00:00Z"
|
|
}]
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn mock_oauth_connect() -> Json<Value> {
|
|
Json(json!({
|
|
"success": true,
|
|
"oauthUrl": "https://github.example.test/oauth?state=worker-a-state",
|
|
"state": "worker-a-state"
|
|
}))
|
|
}
|
|
|
|
async fn mock_integration_tokens() -> Json<Value> {
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"encrypted": encrypt_handoff_blob(
|
|
"0123456789abcdef0123456789abcdef",
|
|
&json!({
|
|
"accessToken": "gh-access-token",
|
|
"refreshToken": "gh-refresh-token",
|
|
"expiresAt": "2026-01-01T00:00:00Z"
|
|
}).to_string(),
|
|
)
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn mock_client_key(AxumPath(integration_id): AxumPath<String>) -> Json<Value> {
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"integrationId": integration_id,
|
|
"clientKey": "client-key-share"
|
|
}
|
|
}))
|
|
}
|
|
|
|
fn encrypt_handoff_blob(key: &str, plaintext: &str) -> String {
|
|
use aes_gcm::aead::generic_array::typenum::U16;
|
|
use aes_gcm::aead::{Aead, KeyInit};
|
|
use aes_gcm::aes::Aes256;
|
|
use aes_gcm::AesGcm;
|
|
use base64::Engine;
|
|
|
|
type Aes256Gcm16 = AesGcm<Aes256, U16>;
|
|
|
|
let cipher = Aes256Gcm16::new_from_slice(key.as_bytes()).expect("valid handoff key");
|
|
let iv = [7_u8; 16];
|
|
let nonce = aes_gcm::aead::generic_array::GenericArray::from_slice(&iv);
|
|
let encrypted = cipher
|
|
.encrypt(nonce, plaintext.as_bytes())
|
|
.expect("encrypt handoff payload");
|
|
let (ciphertext, tag) = encrypted.split_at(encrypted.len() - 16);
|
|
let mut combined = Vec::with_capacity(16 + 16 + ciphertext.len());
|
|
combined.extend_from_slice(&iv);
|
|
combined.extend_from_slice(tag);
|
|
combined.extend_from_slice(ciphertext);
|
|
base64::engine::general_purpose::STANDARD.encode(combined)
|
|
}
|
|
|
|
async fn mock_revoke_integration(AxumPath(_integration_id): AxumPath<String>) -> Json<Value> {
|
|
Json(json!({ "success": true, "data": { "revoked": true } }))
|
|
}
|
|
|
|
fn write_min_config(openhuman_dir: &Path) {
|
|
std::fs::create_dir_all(openhuman_dir).expect("create .openhuman");
|
|
let cfg = r#"api_url = "http://127.0.0.1:9"
|
|
default_model = "worker-a-model"
|
|
default_temperature = 0.2
|
|
|
|
[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");
|
|
}
|
|
|
|
struct TestHarness {
|
|
_tmp: TempDir,
|
|
home: std::path::PathBuf,
|
|
_guards: Vec<EnvVarGuard>,
|
|
rpc_base: String,
|
|
join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
}
|
|
|
|
async fn setup() -> TestHarness {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().to_path_buf();
|
|
let openhuman_home = home.join(".openhuman");
|
|
write_min_config(&openhuman_home);
|
|
|
|
let guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", &home),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::unset("BACKEND_URL"),
|
|
EnvVarGuard::unset("VITE_BACKEND_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_API_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_CORE_RPC_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_CORE_PORT"),
|
|
EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
EnvVarGuard::set("OPENHUMAN_BROWSER_ALLOW_ALL_RPC_ENABLE", ""),
|
|
];
|
|
|
|
let (addr, join) = serve_rpc().await;
|
|
TestHarness {
|
|
_tmp: tmp,
|
|
home,
|
|
_guards: guards,
|
|
rpc_base: format!("http://{addr}"),
|
|
join,
|
|
}
|
|
}
|
|
|
|
async fn schema(rpc_base: &str) -> Value {
|
|
let url = format!("{}/schema", rpc_base.trim_end_matches('/'));
|
|
reqwest::get(&url)
|
|
.await
|
|
.unwrap_or_else(|err| panic!("GET {url}: {err}"))
|
|
.json::<Value>()
|
|
.await
|
|
.expect("schema json")
|
|
}
|
|
|
|
async fn rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("client");
|
|
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
|
|
let response = client
|
|
.post(&url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.json(&json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|err| panic!("POST {url} {method}: {err}"));
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"HTTP transport should accept {method}"
|
|
);
|
|
response
|
|
.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|err| panic!("json for {method}: {err}"))
|
|
}
|
|
|
|
fn ok<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
if let Some(error) = value.get("error") {
|
|
panic!("{context}: unexpected JSON-RPC error: {error}");
|
|
}
|
|
value
|
|
.get("result")
|
|
.unwrap_or_else(|| panic!("{context}: missing result: {value}"))
|
|
}
|
|
|
|
fn err<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
value
|
|
.get("error")
|
|
.unwrap_or_else(|| panic!("{context}: expected JSON-RPC error, got: {value}"))
|
|
}
|
|
|
|
fn payload<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
let result = ok(value, context);
|
|
result.get("result").unwrap_or(result)
|
|
}
|
|
|
|
fn assert_error_contains(value: &Value, context: &str, needle: &str) {
|
|
let message = err(value, context)
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
assert!(
|
|
message.contains(needle),
|
|
"{context}: expected error containing {needle:?}, got {message:?}"
|
|
);
|
|
}
|
|
|
|
fn schema_method_names(value: &Value, namespace: &str) -> Vec<String> {
|
|
let mut methods = value
|
|
.get("methods")
|
|
.and_then(Value::as_array)
|
|
.expect("schema methods array")
|
|
.iter()
|
|
.filter(|method| method.get("namespace").and_then(Value::as_str) == Some(namespace))
|
|
.map(|method| {
|
|
method
|
|
.get("method")
|
|
.and_then(Value::as_str)
|
|
.expect("method name")
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
methods.sort();
|
|
methods
|
|
}
|
|
|
|
#[test]
|
|
fn config_schema_helpers_cover_provider_voice_agent_and_channel_defaults() {
|
|
let mut provider = CloudProviderCreds {
|
|
id: "provider-legacy".to_string(),
|
|
legacy_type: Some("anthropic".to_string()),
|
|
..CloudProviderCreds::default()
|
|
};
|
|
migrate_legacy_fields(&mut provider);
|
|
assert_eq!(provider.slug, "anthropic");
|
|
assert_eq!(provider.label, "Anthropic");
|
|
assert_eq!(provider.endpoint, "https://api.anthropic.com/v1");
|
|
assert_eq!(provider.auth_style, AuthStyle::Anthropic);
|
|
let mut openhuman_legacy = CloudProviderCreds {
|
|
id: "provider-openhuman".to_string(),
|
|
legacy_type: Some("openhuman".to_string()),
|
|
..CloudProviderCreds::default()
|
|
};
|
|
migrate_legacy_fields(&mut openhuman_legacy);
|
|
assert_eq!(openhuman_legacy.slug, "openhuman");
|
|
assert_eq!(openhuman_legacy.label, "OpenHuman");
|
|
assert_eq!(openhuman_legacy.endpoint, "https://api.openhuman.ai/v1");
|
|
assert_eq!(openhuman_legacy.auth_style, AuthStyle::OpenhumanJwt);
|
|
let mut custom_legacy = CloudProviderCreds {
|
|
id: "provider-custom".to_string(),
|
|
legacy_type: Some("unknown-provider".to_string()),
|
|
..CloudProviderCreds::default()
|
|
};
|
|
migrate_legacy_fields(&mut custom_legacy);
|
|
assert_eq!(custom_legacy.label, "Custom");
|
|
assert!(custom_legacy.endpoint.is_empty());
|
|
let mut sumopod_legacy = CloudProviderCreds {
|
|
id: "provider-sumopod".to_string(),
|
|
legacy_type: Some("sumopod".to_string()),
|
|
..CloudProviderCreds::default()
|
|
};
|
|
migrate_legacy_fields(&mut sumopod_legacy);
|
|
assert_eq!(sumopod_legacy.slug, "sumopod");
|
|
assert_eq!(sumopod_legacy.label, "SumoPod");
|
|
assert_eq!(sumopod_legacy.endpoint, "https://ai.sumopod.com/v1");
|
|
assert_eq!(sumopod_legacy.auth_style, AuthStyle::Bearer);
|
|
let mut minimax_legacy = CloudProviderCreds {
|
|
id: "provider-minimax".to_string(),
|
|
legacy_type: Some("minimax".to_string()),
|
|
..CloudProviderCreds::default()
|
|
};
|
|
migrate_legacy_fields(&mut minimax_legacy);
|
|
assert_eq!(minimax_legacy.slug, "minimax");
|
|
assert_eq!(minimax_legacy.label, "MiniMax");
|
|
// MiniMax uses its OpenAI-compatible /v1 surface + Bearer (TAURI-RUST-8X3);
|
|
// the legacy `type=minimax` migration fills from the corrected catalog.
|
|
assert_eq!(minimax_legacy.endpoint, "https://api.minimax.io/v1");
|
|
assert_eq!(minimax_legacy.auth_style, AuthStyle::Bearer);
|
|
assert_eq!(AuthStyle::OpenhumanJwt.as_str(), "openhuman_jwt");
|
|
assert_eq!(AuthStyle::Anthropic.as_str(), "anthropic");
|
|
assert_eq!(AuthStyle::None.as_str(), "none");
|
|
assert_eq!(
|
|
CloudProviderType::Openrouter.default_endpoint(),
|
|
"https://openrouter.ai/api/v1"
|
|
);
|
|
assert_eq!(
|
|
CloudProviderType::Openhuman.default_endpoint(),
|
|
"https://api.openhuman.ai/v1"
|
|
);
|
|
assert_eq!(
|
|
CloudProviderType::Openai.default_endpoint(),
|
|
"https://api.openai.com/v1"
|
|
);
|
|
assert_eq!(
|
|
CloudProviderType::Anthropic.default_endpoint(),
|
|
"https://api.anthropic.com/v1"
|
|
);
|
|
assert_eq!(
|
|
CloudProviderType::Orcarouter.default_endpoint(),
|
|
"https://api.orcarouter.ai/v1"
|
|
);
|
|
assert_eq!(CloudProviderType::Custom.default_endpoint(), "");
|
|
assert_eq!(CloudProviderType::Openhuman.label(), "OpenHuman");
|
|
assert_eq!(CloudProviderType::Openai.label(), "OpenAI");
|
|
assert_eq!(CloudProviderType::Anthropic.label(), "Anthropic");
|
|
assert_eq!(CloudProviderType::Orcarouter.label(), "OrcaRouter");
|
|
assert_eq!(CloudProviderType::Openhuman.as_str(), "openhuman");
|
|
assert_eq!(CloudProviderType::Openai.as_str(), "openai");
|
|
assert_eq!(CloudProviderType::Anthropic.as_str(), "anthropic");
|
|
assert_eq!(CloudProviderType::Openrouter.as_str(), "openrouter");
|
|
assert_eq!(CloudProviderType::Orcarouter.as_str(), "orcarouter");
|
|
assert_eq!(CloudProviderType::Custom.as_str(), "custom");
|
|
assert_eq!(
|
|
CloudProviderType::Openhuman.auth_style(),
|
|
AuthStyle::OpenhumanJwt
|
|
);
|
|
assert_eq!(
|
|
CloudProviderType::Anthropic.auth_style(),
|
|
AuthStyle::Anthropic
|
|
);
|
|
assert_eq!(CloudProviderType::Custom.auth_style(), AuthStyle::Bearer);
|
|
assert!(is_slug_reserved(" cloud "));
|
|
assert!(!is_slug_reserved("ollama"));
|
|
|
|
let provider_id = generate_provider_id("my provider!");
|
|
assert!(provider_id.starts_with("p_my_provider__"));
|
|
assert_eq!(provider_id.rsplit('_').next().unwrap().len(), 5);
|
|
|
|
assert!(VoiceCapability::Stt.supports_stt());
|
|
assert!(!VoiceCapability::Stt.supports_tts());
|
|
assert_eq!(VoiceCapability::Tts.as_str(), "tts");
|
|
assert_eq!(VoiceCapability::Stt.as_str(), "stt");
|
|
assert_eq!(VoiceCapability::Both.as_str(), "both");
|
|
assert!(VoiceCapability::Both.supports_stt());
|
|
assert!(VoiceCapability::Both.supports_tts());
|
|
assert_eq!(VoiceProviderCreds::default().auth_style, AuthStyle::Bearer);
|
|
let voice_defaults = VoiceProviderCreds::default();
|
|
assert_eq!(voice_defaults.stt_api_style, SttApiStyle::OpenaiAudio);
|
|
assert_eq!(voice_defaults.tts_api_style, TtsApiStyle::OpenaiAudio);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::config::schema::voice_providers::builtin_voice_provider(
|
|
"deepgram"
|
|
)
|
|
.expect("deepgram builtin")
|
|
.default_stt_model,
|
|
Some("nova-2")
|
|
);
|
|
assert!(is_voice_slug_reserved(" whisper "));
|
|
assert!(!is_voice_slug_reserved("openai"));
|
|
let voice_id = generate_voice_provider_id("voice provider!");
|
|
assert!(voice_id.starts_with("vp_voice_provider__"));
|
|
assert_eq!(voice_id.rsplit('_').next().unwrap().len(), 5);
|
|
|
|
let team = TeamModelConfig {
|
|
lead_model: Some(" lead-model ".to_string()),
|
|
agent_model: None,
|
|
};
|
|
assert_eq!(team.model_for_role(true), Some("lead-model"));
|
|
assert_eq!(team.model_for_role(false), Some("lead-model"));
|
|
assert_eq!(
|
|
MemoryContextWindow::from_str_opt("MAXIMUM"),
|
|
Some(MemoryContextWindow::Maximum)
|
|
);
|
|
assert_eq!(MemoryContextWindow::Extended.as_str(), "extended");
|
|
assert_eq!(MemoryContextWindow::Minimal.as_str(), "minimal");
|
|
assert_eq!(MemoryContextWindow::Balanced.as_str(), "balanced");
|
|
assert_eq!(MemoryContextWindow::Maximum.as_str(), "maximum");
|
|
assert_eq!(
|
|
MemoryContextWindow::Balanced.limits().total_tree_max_chars,
|
|
32_000
|
|
);
|
|
assert_eq!(
|
|
MemoryContextWindow::Extended
|
|
.limits()
|
|
.per_namespace_max_chars,
|
|
16_000
|
|
);
|
|
assert_eq!(MemoryContextWindow::from_str_opt("unsupported"), None);
|
|
let delegate: DelegateAgentConfig =
|
|
serde_json::from_value(json!({ "model": "delegate-model" })).expect("delegate defaults");
|
|
assert_eq!(delegate.max_depth, 3);
|
|
assert!(delegate.system_prompt.is_none());
|
|
let mut agent = AgentConfig {
|
|
max_memory_context_chars: 20_000,
|
|
..AgentConfig::default()
|
|
};
|
|
assert_eq!(
|
|
agent.resolved_memory_limits().max_memory_context_chars,
|
|
MemoryContextWindow::Maximum
|
|
.limits()
|
|
.max_memory_context_chars
|
|
);
|
|
agent.memory_window = Some(MemoryContextWindow::Minimal);
|
|
assert_eq!(
|
|
agent.resolved_memory_limits(),
|
|
MemoryContextWindow::Minimal.limits()
|
|
);
|
|
|
|
let default_channels = ChannelsConfig::default();
|
|
assert!(!default_channels.has_listening_integrations());
|
|
let mut listening_channels = default_channels.clone();
|
|
listening_channels.whatsapp = Some(WhatsAppConfig {
|
|
access_token: Some("token".to_string()),
|
|
phone_number_id: Some("phone".to_string()),
|
|
verify_token: Some("verify".to_string()),
|
|
app_secret: None,
|
|
session_path: None,
|
|
pair_phone: None,
|
|
pair_code: None,
|
|
allowed_numbers: vec![],
|
|
});
|
|
assert!(listening_channels.has_listening_integrations());
|
|
let whatsapp = listening_channels
|
|
.whatsapp
|
|
.as_ref()
|
|
.expect("whatsapp config");
|
|
assert_eq!(whatsapp.backend_type(), "cloud");
|
|
assert!(whatsapp.is_cloud_config());
|
|
assert!(!whatsapp.is_web_config());
|
|
let whatsapp_web = WhatsAppConfig {
|
|
access_token: None,
|
|
phone_number_id: None,
|
|
verify_token: None,
|
|
app_secret: None,
|
|
session_path: Some("/tmp/openhuman-whatsapp-session".to_string()),
|
|
pair_phone: None,
|
|
pair_code: None,
|
|
allowed_numbers: vec![],
|
|
};
|
|
assert_eq!(whatsapp_web.backend_type(), "web");
|
|
assert!(!whatsapp_web.is_cloud_config());
|
|
assert!(whatsapp_web.is_web_config());
|
|
|
|
let minimal_config: Config = toml::from_str(
|
|
r#"
|
|
api_url = "https://api.example.test"
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#,
|
|
)
|
|
.expect("minimal config should deserialize with defaults");
|
|
assert_eq!(minimal_config.default_temperature, 0.7);
|
|
assert!(minimal_config
|
|
.temperature_unsupported_models
|
|
.iter()
|
|
.any(|pattern| pattern == "gpt-5*"));
|
|
|
|
assert_eq!(
|
|
output_language_directive(Some("zh_CN")).as_deref(),
|
|
Some(
|
|
"Output language: write all natural-language output in Simplified Chinese. Keep JSON keys, enum values, proper nouns, code, commands, and quoted source text unchanged."
|
|
)
|
|
);
|
|
assert_eq!(
|
|
output_language_directive(Some(" Klingon\u{0000} ")).as_deref(),
|
|
Some(
|
|
"Output language: write all natural-language output in Klingon. Keep JSON keys, enum values, proper nouns, code, commands, and quoted source text unchanged."
|
|
)
|
|
);
|
|
assert_eq!(output_language_directive(Some("\u{0000}\u{0001}")), None);
|
|
assert_eq!(output_language_directive(Some(" ")), None);
|
|
assert_eq!(output_language_directive(None), None);
|
|
|
|
let mut config = Config::default();
|
|
config.workspace_dir = PathBuf::from("/tmp/openhuman-worker-a-workspace");
|
|
assert_eq!(
|
|
config.memory_tree_content_root(),
|
|
PathBuf::from("/tmp/openhuman-worker-a-workspace/memory_tree/content")
|
|
);
|
|
config.memory_tree.content_dir = Some(PathBuf::from("/tmp/custom-memory-tree"));
|
|
assert_eq!(
|
|
config.memory_tree_content_root(),
|
|
PathBuf::from("/tmp/custom-memory-tree")
|
|
);
|
|
|
|
config.chat_provider = Some(" ollama:chat-local ".into());
|
|
config.reasoning_provider = Some("cloud".into());
|
|
config.agentic_provider = Some("ollama:agent-local".into());
|
|
config.coding_provider = Some("ollama:code-local".into());
|
|
config.memory_provider = Some("ollama:memory-local".into());
|
|
config.embeddings_provider = Some("ollama:embed-local".into());
|
|
config.heartbeat_provider = Some("ollama:heartbeat-local".into());
|
|
config.learning_provider = Some("ollama:learning-local".into());
|
|
config.subconscious_provider = Some("ollama:subconscious-local".into());
|
|
assert_eq!(
|
|
config.workload_local_model("chat").as_deref(),
|
|
Some("chat-local")
|
|
);
|
|
config.chat_provider = Some("ollama: ".into());
|
|
assert_eq!(config.workload_local_model("chat"), None);
|
|
config.chat_provider = Some("ollama:chat-local".into());
|
|
assert_eq!(config.workload_local_model("reasoning"), None);
|
|
assert!(config.workload_uses_local("agentic"));
|
|
assert!(config.workload_uses_local("coding"));
|
|
assert!(config.workload_uses_local("memory"));
|
|
assert!(config.workload_uses_local("embeddings"));
|
|
assert!(config.workload_uses_local("heartbeat"));
|
|
assert!(config.workload_uses_local("learning"));
|
|
assert!(config.workload_uses_local("subconscious"));
|
|
assert!(!config.workload_uses_local("unknown"));
|
|
config.output_language = Some("fr".into());
|
|
assert!(config
|
|
.output_language_directive()
|
|
.expect("language directive")
|
|
.contains("French"));
|
|
|
|
config.orchestrator = OrchestratorModelConfig {
|
|
model: Some(" orchestrator-model ".into()),
|
|
};
|
|
config.teams.insert(
|
|
"research".into(),
|
|
TeamModelConfig {
|
|
lead_model: Some(" research-lead ".into()),
|
|
agent_model: Some("research-agent".into()),
|
|
},
|
|
);
|
|
config.teams.insert(
|
|
"tools".into(),
|
|
TeamModelConfig {
|
|
lead_model: None,
|
|
agent_model: Some("tools-agent".into()),
|
|
},
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("orchestrator", false),
|
|
Some("orchestrator-model")
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("research", true),
|
|
Some("research-lead")
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("research_agent", false),
|
|
Some("research-agent")
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("tool_maker", false),
|
|
Some("tools-agent")
|
|
);
|
|
config.teams.insert(
|
|
"code".into(),
|
|
TeamModelConfig {
|
|
lead_model: Some("code-lead".into()),
|
|
agent_model: Some("code-agent".into()),
|
|
},
|
|
);
|
|
config.teams.insert(
|
|
"integrations".into(),
|
|
TeamModelConfig {
|
|
lead_model: None,
|
|
agent_model: Some("integrations-agent".into()),
|
|
},
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("code_executor", true),
|
|
Some("code-lead")
|
|
);
|
|
assert_eq!(
|
|
config.configured_agent_model("integrations_agent", false),
|
|
Some("integrations-agent")
|
|
);
|
|
assert_eq!(config.configured_agent_model(" ", false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn config_schema_defaults_cover_dashboard_capability_memory_and_security_shapes() {
|
|
let capability = CapabilityProviderConfig::default();
|
|
assert_eq!(
|
|
capability.trust_state,
|
|
CapabilityProviderTrustState::Untrusted
|
|
);
|
|
assert!(!capability.enabled);
|
|
let trusted_capability: CapabilityProviderConfig = serde_json::from_value(json!({
|
|
"id": "external-mcp",
|
|
"display_name": "External MCP",
|
|
"source_uri": "https://example.test/catalog.json",
|
|
"source_digest": "sha256:abc123",
|
|
"trust_state": "trusted",
|
|
"enabled": true
|
|
}))
|
|
.expect("capability provider config should deserialize");
|
|
assert_eq!(
|
|
trusted_capability.trust_state,
|
|
CapabilityProviderTrustState::Trusted
|
|
);
|
|
|
|
let dashboard = DashboardConfig::default();
|
|
assert!(dashboard.event_stream.enabled);
|
|
assert_eq!(dashboard.event_stream.max_entries, 200);
|
|
assert_eq!(dashboard.event_stream.new_entries, "top");
|
|
assert!(dashboard.model_health.enabled);
|
|
assert_eq!(dashboard.model_health.min_tasks_for_rating, 10);
|
|
assert_eq!(dashboard.model_health.evaluation_window_tasks, 50);
|
|
assert!(dashboard.diagram_viewer.enabled);
|
|
assert_eq!(
|
|
dashboard.diagram_viewer.source_url,
|
|
"http://localhost:8787/workspace/diagrams/latest.png"
|
|
);
|
|
assert_eq!(dashboard.diagram_viewer.refresh_interval_seconds, 10);
|
|
let partial_dashboard: DashboardConfig = serde_json::from_value(json!({
|
|
"event_stream": {},
|
|
"model_health": {},
|
|
"diagram_viewer": {}
|
|
}))
|
|
.expect("partial dashboard config should fill serde defaults");
|
|
assert!(partial_dashboard.event_stream.enabled);
|
|
assert_eq!(partial_dashboard.model_health.hallucination_threshold, 0.10);
|
|
assert_eq!(
|
|
partial_dashboard.diagram_viewer.refresh_interval_seconds,
|
|
10
|
|
);
|
|
let event_stream: EventStreamConfig =
|
|
serde_json::from_value(json!({})).expect("event stream defaults");
|
|
assert_eq!(event_stream.new_entries, "top");
|
|
let model_health: ModelHealthConfig =
|
|
serde_json::from_value(json!({})).expect("model health defaults");
|
|
assert_eq!(model_health.evaluation_window_tasks, 50);
|
|
|
|
let memory = MemoryConfig {
|
|
agentmemory_url: Some("https://memory.example.test".to_string()),
|
|
agentmemory_secret: Some("secret-token".to_string()),
|
|
agentmemory_timeout_ms: Some(750),
|
|
..MemoryConfig::default()
|
|
};
|
|
let debug = format!("{memory:?}");
|
|
assert!(debug.contains("<redacted>"));
|
|
assert!(!debug.contains("secret-token"));
|
|
assert_eq!(LlmBackend::Cloud.as_str(), "cloud");
|
|
assert_eq!(LlmBackend::Local.as_str(), "local");
|
|
assert_eq!(LlmBackend::parse(" LOCAL "), Ok(LlmBackend::Local));
|
|
assert!(LlmBackend::parse("remote").is_err());
|
|
|
|
let telegram: TelegramConfig = serde_json::from_value(json!({
|
|
"bot_token": "bot-token",
|
|
"allowed_users": ["alice"]
|
|
}))
|
|
.expect("telegram serde defaults");
|
|
assert_eq!(telegram.draft_update_interval_ms, 1000);
|
|
assert!(telegram.silent_streaming);
|
|
assert!(!telegram.mention_only);
|
|
|
|
let sandbox = SandboxConfig::default();
|
|
assert!(sandbox.enabled.is_none());
|
|
assert!(sandbox.firejail_args.is_empty());
|
|
let security = SecurityConfig::default();
|
|
assert!(security.audit.enabled);
|
|
let resources = ResourceLimitsConfig::default();
|
|
assert_eq!(
|
|
serde_json::to_value(resources).expect("resource limits to json"),
|
|
json!({})
|
|
);
|
|
let audit = AuditConfig::default();
|
|
assert_eq!(audit.log_path, "audit.log");
|
|
assert_eq!(audit.max_size_mb, 100);
|
|
|
|
let meet: openhuman_core::openhuman::config::schema::MeetConfig =
|
|
serde_json::from_value(json!({})).expect("meet defaults");
|
|
assert!(!meet.auto_orchestrator_handoff);
|
|
let observability: openhuman_core::openhuman::config::schema::ObservabilityConfig =
|
|
serde_json::from_value(json!({})).expect("observability defaults");
|
|
assert!(observability.analytics_enabled);
|
|
assert!(observability.sentry_dsn.is_none());
|
|
let scheduler_gate: openhuman_core::openhuman::config::schema::SchedulerGateConfig =
|
|
serde_json::from_value(json!({})).expect("scheduler gate defaults");
|
|
assert_eq!(
|
|
scheduler_gate.mode,
|
|
openhuman_core::openhuman::config::schema::SchedulerGateMode::Auto
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::config::schema::SchedulerGateMode::AlwaysOn.as_str(),
|
|
"always_on"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::config::schema::SchedulerGateMode::Off.as_str(),
|
|
"off"
|
|
);
|
|
|
|
let multimodal = openhuman_core::openhuman::config::schema::MultimodalConfig {
|
|
max_images: 99,
|
|
max_image_size_mb: 0,
|
|
allow_remote_fetch: true,
|
|
};
|
|
assert_eq!(multimodal.effective_limits(), (16, 1));
|
|
assert_eq!(multimodal.clamp_image_count(120), 99);
|
|
|
|
let mut local_ai = openhuman_core::openhuman::config::schema::LocalAiConfig {
|
|
runtime_enabled: false,
|
|
usage: openhuman_core::openhuman::config::schema::LocalAiUsage {
|
|
embeddings: true,
|
|
heartbeat: true,
|
|
learning_reflection: true,
|
|
subconscious: true,
|
|
},
|
|
..Default::default()
|
|
};
|
|
assert!(!local_ai.is_active());
|
|
#[allow(deprecated)]
|
|
{
|
|
assert!(!local_ai.use_local_for_embeddings());
|
|
local_ai.runtime_enabled = true;
|
|
assert!(local_ai.is_active());
|
|
assert!(local_ai.use_local_for_embeddings());
|
|
assert!(local_ai.use_local_for_heartbeat());
|
|
assert!(local_ai.use_local_for_learning());
|
|
assert!(local_ai.use_local_for_subconscious());
|
|
}
|
|
|
|
let mut search = openhuman_core::openhuman::config::schema::SearchConfig {
|
|
engine: " Parallel ".into(),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
search.effective_engine(),
|
|
openhuman_core::openhuman::config::schema::SearchEngine::Managed
|
|
);
|
|
search.parallel = openhuman_core::openhuman::config::schema::SearchEngineCredentials {
|
|
api_key: Some(" parallel-key ".into()),
|
|
};
|
|
assert_eq!(
|
|
search.parallel.key(),
|
|
Some("parallel-key"),
|
|
"search credential keys should be trimmed at read time"
|
|
);
|
|
assert_eq!(
|
|
search.effective_engine(),
|
|
openhuman_core::openhuman::config::schema::SearchEngine::Parallel
|
|
);
|
|
assert_eq!(search.requested_engine_str(), "Parallel");
|
|
search.engine = " ".into();
|
|
assert_eq!(search.requested_engine_str(), "managed");
|
|
|
|
let integration = openhuman_core::openhuman::config::schema::IntegrationToggle {
|
|
enabled: true,
|
|
mode: "byo".into(),
|
|
api_key: Some(" ".into()),
|
|
};
|
|
assert!(!integration.is_active());
|
|
let managed_integration = openhuman_core::openhuman::config::schema::IntegrationToggle {
|
|
enabled: true,
|
|
mode: "managed".into(),
|
|
api_key: None,
|
|
};
|
|
assert!(managed_integration.is_active());
|
|
|
|
let mcp_default = openhuman_core::openhuman::config::schema::McpServerConfig::default();
|
|
assert!(mcp_default.enabled);
|
|
assert_eq!(mcp_default.timeout_secs, 30);
|
|
assert!(matches!(
|
|
mcp_default.auth,
|
|
openhuman_core::openhuman::config::schema::McpAuthConfig::None
|
|
));
|
|
let mcp_with_auth: openhuman_core::openhuman::config::schema::McpServerConfig =
|
|
serde_json::from_value(json!({
|
|
"name": "worker-a-mcp",
|
|
"endpoint": "https://mcp.example.test",
|
|
"auth": {
|
|
"kind": "header",
|
|
"name": "x-api-key",
|
|
"value": "secret"
|
|
}
|
|
}))
|
|
.expect("mcp server auth config");
|
|
assert!(matches!(
|
|
mcp_with_auth.auth,
|
|
openhuman_core::openhuman::config::schema::McpAuthConfig::Header { .. }
|
|
));
|
|
for auth in [
|
|
json!({ "kind": "bearer_token", "token": "bearer" }),
|
|
json!({ "kind": "basic", "username": "u", "password": "p" }),
|
|
json!({ "kind": "query_param", "name": "api_key", "value": "secret" }),
|
|
] {
|
|
let _: openhuman_core::openhuman::config::schema::McpAuthConfig =
|
|
serde_json::from_value(auth).expect("mcp auth variant should deserialize");
|
|
}
|
|
|
|
let incomplete_poly = openhuman_core::openhuman::config::schema::PolymarketClobCredentials {
|
|
api_key: " key ".into(),
|
|
secret: " ".into(),
|
|
passphrase: " pass ".into(),
|
|
};
|
|
assert!(!incomplete_poly.is_complete());
|
|
let complete_poly = openhuman_core::openhuman::config::schema::PolymarketClobCredentials {
|
|
api_key: " key ".into(),
|
|
secret: " secret ".into(),
|
|
passphrase: " pass ".into(),
|
|
};
|
|
assert!(complete_poly.is_complete());
|
|
assert_eq!(
|
|
format!("{complete_poly:?}"),
|
|
"PolymarketClobCredentials { api_key: \"<redacted>\", secret: \"<redacted>\", passphrase: \"<redacted>\" }"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_active_user_and_daemon_public_helpers_cover_path_branches() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let root = tmp.path().join(".openhuman");
|
|
|
|
assert_eq!(read_active_user_id(&root), None);
|
|
std::fs::create_dir_all(&root).expect("create root");
|
|
std::fs::write(root.join("active_user.toml"), "user_id = \" \"\n")
|
|
.expect("write blank active user");
|
|
assert_eq!(read_active_user_id(&root), None);
|
|
std::fs::write(root.join("active_user.toml"), "not = [toml\n")
|
|
.expect("write malformed active user");
|
|
assert_eq!(read_active_user_id(&root), None);
|
|
|
|
write_active_user_id(&root, "user-77").expect("write active user");
|
|
assert_eq!(read_active_user_id(&root).as_deref(), Some("user-77"));
|
|
assert_eq!(
|
|
user_openhuman_dir(&root, "user-77"),
|
|
root.join("users").join("user-77")
|
|
);
|
|
assert_eq!(pre_login_user_dir(&root), root.join("users").join("local"));
|
|
|
|
clear_active_user(&root).expect("clear active user");
|
|
clear_active_user(&root).expect("clearing missing active user is idempotent");
|
|
assert_eq!(read_active_user_id(&root), None);
|
|
|
|
let daemon = DaemonConfig::from_app_data_dir(tmp.path());
|
|
assert_eq!(daemon.data_dir, tmp.path().join("openhuman"));
|
|
assert_eq!(
|
|
daemon.workspace_dir,
|
|
tmp.path().join("openhuman").join("workspace")
|
|
);
|
|
assert!(daemon.security.audit.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn config_settings_cli_sections_project_snapshots_and_missing_fields() {
|
|
let snap = ConfigSnapshotFields {
|
|
config: json!({
|
|
"api_url": "https://api.example.test",
|
|
"default_model": "worker-a-model",
|
|
"default_temperature": 0.42,
|
|
"memory": { "provider": "sqlite", "auto_save": true },
|
|
"runtime": { "kind": "native", "reasoning_enabled": true },
|
|
"browser": { "allow_all": false }
|
|
}),
|
|
workspace_dir: "/tmp/openhuman-worker-a/workspace".to_string(),
|
|
config_path: "/tmp/openhuman-worker-a/config.toml".to_string(),
|
|
};
|
|
|
|
let model = settings_section_json("model", &snap, vec!["loaded".to_string()]);
|
|
assert_eq!(model.pointer("/result/section"), Some(&json!("model")));
|
|
assert_eq!(
|
|
model.pointer("/result/settings/default_model"),
|
|
Some(&json!("worker-a-model"))
|
|
);
|
|
assert_eq!(
|
|
model.pointer("/result/workspace_dir"),
|
|
Some(&json!("/tmp/openhuman-worker-a/workspace"))
|
|
);
|
|
assert_eq!(model.pointer("/logs/0"), Some(&json!("loaded")));
|
|
|
|
for (section, pointer, expected) in [
|
|
("memory", "/result/settings/provider", json!("sqlite")),
|
|
("runtime", "/result/settings/kind", json!("native")),
|
|
("browser", "/result/settings/allow_all", json!(false)),
|
|
] {
|
|
let value = settings_section_json(section, &snap, vec![]);
|
|
assert_eq!(value.pointer(pointer), Some(&expected), "{section}");
|
|
}
|
|
|
|
let unknown = settings_section_json("unknown", &snap, vec![]);
|
|
assert!(unknown
|
|
.pointer("/result/settings")
|
|
.is_some_and(Value::is_null));
|
|
|
|
let missing = ConfigSnapshotFields {
|
|
config: json!({ "default_model": "partial-model" }),
|
|
workspace_dir: "/tmp/ws".to_string(),
|
|
config_path: "/tmp/cfg.toml".to_string(),
|
|
};
|
|
let missing_model = settings_section_json("model", &missing, vec![]);
|
|
assert_eq!(
|
|
missing_model.pointer("/result/settings/default_model"),
|
|
Some(&json!("partial-model"))
|
|
);
|
|
assert!(missing_model
|
|
.pointer("/result/settings/api_url")
|
|
.is_some_and(Value::is_null));
|
|
let missing_memory = settings_section_json("memory", &missing, vec![]);
|
|
assert!(missing_memory
|
|
.pointer("/result/settings")
|
|
.is_some_and(Value::is_null));
|
|
}
|
|
|
|
#[test]
|
|
fn config_proxy_public_paths_normalize_validate_and_apply_scope() {
|
|
let _lock = env_lock();
|
|
let _http = EnvVarGuard::unset("HTTP_PROXY");
|
|
let _https = EnvVarGuard::unset("HTTPS_PROXY");
|
|
let _all = EnvVarGuard::unset("ALL_PROXY");
|
|
let _no = EnvVarGuard::unset("NO_PROXY");
|
|
let _http_lower = EnvVarGuard::unset("http_proxy");
|
|
let _https_lower = EnvVarGuard::unset("https_proxy");
|
|
let _all_lower = EnvVarGuard::unset("all_proxy");
|
|
let _no_lower = EnvVarGuard::unset("no_proxy");
|
|
|
|
assert!(ProxyConfig::supported_service_keys()
|
|
.iter()
|
|
.any(|key| *key == "memory.embeddings"));
|
|
assert!(ProxyConfig::supported_service_selectors()
|
|
.iter()
|
|
.any(|selector| *selector == "tool.*"));
|
|
|
|
let services = ProxyConfig {
|
|
enabled: true,
|
|
http_proxy: Some(" http://proxy.example:8080 ".into()),
|
|
https_proxy: Some("https://secure-proxy.example".into()),
|
|
all_proxy: None,
|
|
no_proxy: vec![" localhost, 127.0.0.1 ".into(), "example.test".into()],
|
|
scope: ProxyScope::Services,
|
|
services: vec![
|
|
" Tool.* ".into(),
|
|
"tool.browser".into(),
|
|
"memory.embeddings".into(),
|
|
],
|
|
};
|
|
services.validate().expect("valid services proxy");
|
|
assert_eq!(
|
|
services.normalized_services(),
|
|
vec!["memory.embeddings", "tool.*", "tool.browser"]
|
|
);
|
|
assert_eq!(
|
|
services.normalized_no_proxy(),
|
|
vec!["127.0.0.1", "example.test", "localhost"]
|
|
);
|
|
assert!(services.should_apply_to_service("tool.http_request"));
|
|
assert!(services.should_apply_to_service("memory.embeddings"));
|
|
assert!(!services.should_apply_to_service("provider.openai"));
|
|
assert!(!services.should_apply_to_service(" "));
|
|
let _client = services
|
|
.apply_to_reqwest_builder(reqwest::Client::builder(), "tool.browser")
|
|
.build()
|
|
.expect("proxied client builds");
|
|
|
|
let env_scope = ProxyConfig {
|
|
enabled: true,
|
|
scope: ProxyScope::Environment,
|
|
all_proxy: Some("socks5h://proxy.example:1080".into()),
|
|
..ProxyConfig::default()
|
|
};
|
|
env_scope.validate().expect("valid env proxy");
|
|
assert!(!env_scope.should_apply_to_service("tool.browser"));
|
|
env_scope.apply_to_process_env();
|
|
assert_eq!(
|
|
std::env::var("ALL_PROXY").as_deref(),
|
|
Ok("socks5h://proxy.example:1080")
|
|
);
|
|
assert_eq!(
|
|
std::env::var("all_proxy").as_deref(),
|
|
Ok("socks5h://proxy.example:1080")
|
|
);
|
|
assert!(std::env::var("NO_PROXY").is_err());
|
|
|
|
ProxyConfig::clear_process_env();
|
|
assert!(std::env::var("ALL_PROXY").is_err());
|
|
assert!(std::env::var("all_proxy").is_err());
|
|
|
|
let openhuman_scope = ProxyConfig {
|
|
enabled: true,
|
|
scope: ProxyScope::OpenHuman,
|
|
http_proxy: Some("https://proxy.example".into()),
|
|
no_proxy: vec![" local.test ".into()],
|
|
..ProxyConfig::default()
|
|
};
|
|
assert!(openhuman_scope.has_any_proxy_url());
|
|
assert!(openhuman_scope.should_apply_to_service("provider.openai"));
|
|
assert_eq!(openhuman_scope.normalized_no_proxy(), vec!["local.test"]);
|
|
openhuman_scope.apply_to_process_env();
|
|
assert_eq!(
|
|
std::env::var("HTTP_PROXY").as_deref(),
|
|
Ok("https://proxy.example")
|
|
);
|
|
assert_eq!(std::env::var("NO_PROXY").as_deref(), Ok("local.test"));
|
|
ProxyConfig::clear_process_env();
|
|
|
|
for mut invalid in [
|
|
ProxyConfig {
|
|
enabled: true,
|
|
http_proxy: Some("ftp://proxy.example".into()),
|
|
..ProxyConfig::default()
|
|
},
|
|
ProxyConfig {
|
|
enabled: true,
|
|
scope: ProxyScope::Services,
|
|
services: vec![],
|
|
http_proxy: Some("http://proxy.example".into()),
|
|
..ProxyConfig::default()
|
|
},
|
|
ProxyConfig {
|
|
enabled: true,
|
|
http_proxy: None,
|
|
https_proxy: None,
|
|
all_proxy: None,
|
|
..ProxyConfig::default()
|
|
},
|
|
ProxyConfig {
|
|
enabled: false,
|
|
services: vec!["unknown.service".into()],
|
|
..ProxyConfig::default()
|
|
},
|
|
] {
|
|
assert!(
|
|
invalid.validate().is_err(),
|
|
"invalid proxy config should fail: {invalid:?}"
|
|
);
|
|
invalid.enabled = false;
|
|
}
|
|
|
|
openhuman_core::openhuman::config::set_runtime_proxy_config(services.clone());
|
|
assert!(openhuman_core::openhuman::config::runtime_proxy_config()
|
|
.should_apply_to_service("tool.browser"));
|
|
let _cached = openhuman_core::openhuman::config::build_runtime_proxy_client("tool.browser");
|
|
let _cached_again =
|
|
openhuman_core::openhuman::config::build_runtime_proxy_client("tool.browser");
|
|
let _timeout_client =
|
|
openhuman_core::openhuman::config::build_runtime_proxy_client_with_timeouts(
|
|
"memory.embeddings",
|
|
1,
|
|
1,
|
|
);
|
|
let _builder = openhuman_core::openhuman::config::apply_runtime_proxy_to_builder(
|
|
reqwest::Client::builder(),
|
|
"tool.http_request",
|
|
);
|
|
openhuman_core::openhuman::config::set_runtime_proxy_config(ProxyConfig::default());
|
|
}
|
|
|
|
#[test]
|
|
fn api_config_url_resolution_classifies_backend_and_inference_paths() {
|
|
let _lock = env_lock();
|
|
// Integration-test binaries link the library compiled WITHOUT `cfg(test)`,
|
|
// so `compile_time_api_base_env_values()` resolves `option_env!("BACKEND_URL")`
|
|
// / `option_env!("VITE_BACKEND_URL")`. The mock test harness bakes
|
|
// `BACKEND_URL` at build time, which would make the blank / local-AI override
|
|
// fall-throughs below resolve to the baked URL instead of the compile
|
|
// default. Pin `BACKEND_URL` at runtime — runtime resolution wins over the
|
|
// compile-time bake — so env/default resolution is deterministic regardless
|
|
// of what CI baked into the binary.
|
|
let _backend = EnvVarGuard::set("BACKEND_URL", DEFAULT_API_BASE_URL);
|
|
let _vite_backend = EnvVarGuard::unset("VITE_BACKEND_URL");
|
|
let _app_env = EnvVarGuard::unset(APP_ENV_VAR);
|
|
let _vite_app_env = EnvVarGuard::unset(VITE_APP_ENV_VAR);
|
|
|
|
assert_eq!(
|
|
normalize_api_base_url(" https://api.example.test/// "),
|
|
"https://api.example.test"
|
|
);
|
|
assert_eq!(
|
|
api_url(
|
|
"https://api.tinyhumans.ai/openai/v1/chat/completions",
|
|
"/auth/me"
|
|
),
|
|
"https://api.tinyhumans.ai/auth/me"
|
|
);
|
|
assert_eq!(api_url("not a url/", "auth/me"), "not a url/auth/me");
|
|
assert_eq!(api_url("not a url/", "/auth/me"), "not a url/auth/me");
|
|
assert_eq!(
|
|
api_url(" https://api.tinyhumans.ai/ ", ""),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
|
|
assert!(!looks_like_local_ai_endpoint(""));
|
|
assert!(looks_like_local_ai_endpoint("http://localhost:11434"));
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"http://10.0.0.2/v1/chat/completions"
|
|
));
|
|
assert!(looks_like_local_ai_endpoint(
|
|
"https://api.openai.com/v1/completions"
|
|
));
|
|
assert!(looks_like_local_ai_endpoint("http://0.0.0.0:8000"));
|
|
assert!(looks_like_local_ai_endpoint("http://service.localhost/v1"));
|
|
assert!(looks_like_local_ai_endpoint("http://192.168.1.7:9000/v1"));
|
|
assert!(!looks_like_local_ai_endpoint("http://127.0.0.1:45678"));
|
|
assert!(!looks_like_local_ai_endpoint("https://api.example.test/v1"));
|
|
assert!(!looks_like_local_ai_endpoint(
|
|
"https://api.example.test/audit/v1/chat/completions-logs"
|
|
));
|
|
assert!(!looks_like_local_ai_endpoint("not a url"));
|
|
|
|
assert_eq!(effective_api_url(&Some(" ".into())), DEFAULT_API_BASE_URL);
|
|
assert_eq!(
|
|
effective_api_url(&Some(" http://127.0.0.1:11434/ ".into())),
|
|
"http://127.0.0.1:11434"
|
|
);
|
|
assert_eq!(
|
|
effective_inference_url(&Some("https://api.tinyhumans.ai".into()), &None),
|
|
format!("https://api.tinyhumans.ai{OPENHUMAN_INFERENCE_PATH}")
|
|
);
|
|
assert_eq!(
|
|
effective_inference_url(
|
|
&Some("https://api.tinyhumans.ai".into()),
|
|
&Some(" http://127.0.0.1:11434/v1/chat/completions ".into())
|
|
),
|
|
"http://127.0.0.1:11434/v1/chat/completions"
|
|
);
|
|
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some(" http://127.0.0.1:11434/v1 ".into())),
|
|
DEFAULT_API_BASE_URL
|
|
);
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some(
|
|
" https://api.tinyhumans.ai/openai/v1/chat/completions?x=1#frag ".into()
|
|
)),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some("api.tinyhumans.ai/openai/v1/chat/completions".into())),
|
|
"https://api.tinyhumans.ai"
|
|
);
|
|
assert_eq!(
|
|
effective_backend_api_url(&Some(" http://backend.example.test/path?q=1#frag ".into())),
|
|
"http://backend.example.test"
|
|
);
|
|
|
|
std::env::set_var("BACKEND_URL", "");
|
|
std::env::set_var(
|
|
"VITE_BACKEND_URL",
|
|
" https://backend.example.test/openai/v1/chat/completions ",
|
|
);
|
|
assert_eq!(
|
|
api_base_from_env().as_deref(),
|
|
Some("https://backend.example.test/openai/v1/chat/completions")
|
|
);
|
|
assert_eq!(
|
|
effective_backend_api_url(&None),
|
|
"https://backend.example.test"
|
|
);
|
|
|
|
std::env::set_var(APP_ENV_VAR, " Staging ");
|
|
assert_eq!(app_env_from_env().as_deref(), Some("staging"));
|
|
assert_eq!(
|
|
default_api_base_url_for_env(app_env_from_env().as_deref()),
|
|
DEFAULT_STAGING_API_BASE_URL
|
|
);
|
|
|
|
std::env::remove_var(APP_ENV_VAR);
|
|
std::env::set_var(VITE_APP_ENV_VAR, " Production ");
|
|
assert_eq!(app_env_from_env().as_deref(), Some("production"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credentials_session_expired_subscriber_ignores_unrelated_events() {
|
|
let subscriber = SessionExpiredSubscriber::new();
|
|
assert_eq!(subscriber.name(), "credentials::session_expired_handler");
|
|
assert_eq!(subscriber.domains(), Some(&["auth"][..]));
|
|
|
|
subscriber
|
|
.handle(&DomainEvent::AgentTurnStarted {
|
|
session_id: "worker-a-session".to_string(),
|
|
channel: "e2e".to_string(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credentials_session_expired_subscriber_clears_remote_session_but_keeps_local_session() {
|
|
let _lock = env_lock();
|
|
let (backend_base, _backend_state, backend_join) = serve_static_auth_backend(json!({
|
|
"id": "session-expired-user",
|
|
"name": "Session Expired Worker",
|
|
"email": "session-expired@example.test"
|
|
}))
|
|
.await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let remote_session = rpc(
|
|
&harness.rpc_base,
|
|
18_101,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "session-expired-remote-jwt",
|
|
"user_id": "session-expired-user",
|
|
"user": {
|
|
"id": "session-expired-user",
|
|
"name": "Session Expired Worker",
|
|
"email": "session-expired@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&remote_session, "auth_store_session before SessionExpired")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let subscriber = SessionExpiredSubscriber::new();
|
|
subscriber
|
|
.handle(&DomainEvent::SessionExpired {
|
|
source: "coverage-test".to_string(),
|
|
reason: "remote token rejected".to_string(),
|
|
})
|
|
.await;
|
|
|
|
let cleared_state = rpc(
|
|
&harness.rpc_base,
|
|
18_102,
|
|
"openhuman.auth_get_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&cleared_state, "auth_get_state after remote SessionExpired")
|
|
.get("isAuthenticated")
|
|
.and_then(Value::as_bool),
|
|
Some(false),
|
|
"remote SessionExpired events should clear the stored session"
|
|
);
|
|
|
|
let local_session = rpc(
|
|
&harness.rpc_base,
|
|
18_103,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "header.payload.local",
|
|
"user": {
|
|
"id": "renderer-local-session-expired",
|
|
"name": "Local Session Expired Worker",
|
|
"email": "local-session-expired@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(
|
|
&local_session,
|
|
"auth_store_session local before SessionExpired"
|
|
)
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
subscriber
|
|
.handle(&DomainEvent::SessionExpired {
|
|
source: "coverage-test".to_string(),
|
|
reason: "local token should survive".to_string(),
|
|
})
|
|
.await;
|
|
|
|
let local_state = rpc(
|
|
&harness.rpc_base,
|
|
18_104,
|
|
"openhuman.auth_get_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&local_state, "auth_get_state after local SessionExpired")
|
|
.get("isAuthenticated")
|
|
.and_then(Value::as_bool),
|
|
Some(true),
|
|
"local offline sessions should not be cleared by SessionExpired events"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_loaders_resolve_user_workspace_markers_and_ignore_workspace_when_scoped() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().join("home");
|
|
let root = home.join(".openhuman");
|
|
let user_dir = root.join("users").join("user-42");
|
|
let explicit_config_dir = tmp.path().join("explicit");
|
|
let explicit_workspace = tmp.path().join("explicit-workspace");
|
|
let env_workspace = tmp.path().join("env-workspace");
|
|
let legacy_parent = tmp.path().join("legacy-parent");
|
|
let legacy_config_dir = legacy_parent.join(".openhuman");
|
|
let legacy_workspace = legacy_parent.join("workspace");
|
|
|
|
let _guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", &home),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::unset("OPENHUMAN_MODEL"),
|
|
EnvVarGuard::unset(APP_ENV_VAR),
|
|
EnvVarGuard::unset(VITE_APP_ENV_VAR),
|
|
EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
];
|
|
|
|
std::fs::create_dir_all(&root).expect("create root config dir");
|
|
write_active_user_id(&root, "user-42").expect("write active user marker");
|
|
write_min_config(&user_dir);
|
|
|
|
let active_user_config = Config::load_or_init()
|
|
.await
|
|
.expect("load active user config");
|
|
assert_eq!(active_user_config.config_path, user_dir.join("config.toml"));
|
|
assert_eq!(active_user_config.workspace_dir, user_dir.join("workspace"));
|
|
|
|
{
|
|
write_min_config(&env_workspace);
|
|
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &env_workspace);
|
|
let env_config = Config::load_or_init()
|
|
.await
|
|
.expect("load env workspace config");
|
|
assert_eq!(env_config.config_path, env_workspace.join("config.toml"));
|
|
assert_eq!(env_config.workspace_dir, env_workspace.join("workspace"));
|
|
}
|
|
|
|
{
|
|
write_min_config(&legacy_config_dir);
|
|
std::fs::create_dir_all(&legacy_workspace).expect("create legacy workspace");
|
|
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &legacy_workspace);
|
|
let legacy_config = Config::load_or_init()
|
|
.await
|
|
.expect("load legacy workspace config");
|
|
assert_eq!(
|
|
legacy_config.config_path,
|
|
legacy_config_dir.join("config.toml")
|
|
);
|
|
assert_eq!(legacy_config.workspace_dir, legacy_workspace);
|
|
}
|
|
|
|
clear_active_user(&root).expect("clear active user marker");
|
|
std::fs::write(root.join("active_workspace.toml"), "config_dir = [\n")
|
|
.expect("write malformed active workspace marker");
|
|
let malformed_marker_config = Config::load_or_init()
|
|
.await
|
|
.expect("malformed active workspace marker should be ignored");
|
|
assert_eq!(
|
|
malformed_marker_config.config_path,
|
|
root.join("users").join("local").join("config.toml")
|
|
);
|
|
|
|
std::fs::remove_file(root.join("active_workspace.toml"))
|
|
.expect("remove malformed active workspace marker");
|
|
std::fs::create_dir(root.join("active_workspace.toml"))
|
|
.expect("create unreadable active workspace marker");
|
|
let unreadable_marker_config = Config::load_or_init()
|
|
.await
|
|
.expect("unreadable active workspace marker should be ignored");
|
|
assert_eq!(
|
|
unreadable_marker_config.config_path,
|
|
root.join("users").join("local").join("config.toml")
|
|
);
|
|
std::fs::remove_dir(root.join("active_workspace.toml"))
|
|
.expect("remove unreadable active workspace marker");
|
|
|
|
let active_marker_dir = root.join("relative-active");
|
|
write_min_config(&active_marker_dir);
|
|
std::fs::write(
|
|
root.join("active_workspace.toml"),
|
|
"config_dir = \"relative-active\"\n",
|
|
)
|
|
.expect("write active workspace marker");
|
|
let marker_config = Config::load_or_init()
|
|
.await
|
|
.expect("load active workspace marker config");
|
|
assert_eq!(
|
|
marker_config.config_path,
|
|
active_marker_dir.join("config.toml")
|
|
);
|
|
assert_eq!(
|
|
marker_config.workspace_dir,
|
|
active_marker_dir.join("workspace")
|
|
);
|
|
|
|
write_min_config(&explicit_config_dir);
|
|
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &env_workspace);
|
|
let _model_guard = EnvVarGuard::set("OPENHUMAN_MODEL", " scoped-env-model ");
|
|
let explicit = Config::load_from_config_path(
|
|
&explicit_config_dir.join("config.toml"),
|
|
&explicit_workspace,
|
|
)
|
|
.await
|
|
.expect("load explicit config path");
|
|
assert_eq!(explicit.workspace_dir, explicit_workspace);
|
|
assert_eq!(explicit.default_model.as_deref(), Some("scoped-env-model"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_loaders_recover_corrupted_primary_from_backup_or_defaults() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
let recovered_dir = tmp.path().join("recovered");
|
|
std::fs::create_dir_all(&recovered_dir).expect("create recovered config dir");
|
|
let recovered_config_path = recovered_dir.join("config.toml");
|
|
std::fs::write(&recovered_config_path, "this is not = toml = valid")
|
|
.expect("write corrupted primary config");
|
|
std::fs::write(
|
|
recovered_config_path.with_extension("toml.bak"),
|
|
r#"
|
|
api_url = "http://127.0.0.1:9"
|
|
default_model = "backup-model"
|
|
default_temperature = 0.33
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#,
|
|
)
|
|
.expect("write valid backup config");
|
|
|
|
let recovered = Config::load_from_config_path(&recovered_config_path, &workspace_dir)
|
|
.await
|
|
.expect("load config recovered from backup");
|
|
assert_eq!(recovered.config_path, recovered_config_path);
|
|
assert_eq!(recovered.workspace_dir, workspace_dir);
|
|
assert_eq!(recovered.default_model.as_deref(), Some("backup-model"));
|
|
assert_eq!(recovered.default_temperature, 0.33);
|
|
|
|
let defaulted_dir = tmp.path().join("defaulted");
|
|
std::fs::create_dir_all(&defaulted_dir).expect("create defaulted config dir");
|
|
let defaulted_config_path = defaulted_dir.join("config.toml");
|
|
std::fs::write(&defaulted_config_path, "this is not = toml = valid")
|
|
.expect("write corrupted primary config");
|
|
std::fs::write(
|
|
defaulted_config_path.with_extension("toml.bak"),
|
|
"still not = valid = toml",
|
|
)
|
|
.expect("write corrupted backup config");
|
|
|
|
let defaulted = Config::load_from_config_path(&defaulted_config_path, &workspace_dir)
|
|
.await
|
|
.expect("load config defaulted after corrupted backup");
|
|
assert_eq!(defaulted.config_path, defaulted_config_path);
|
|
assert_eq!(defaulted.workspace_dir, workspace_dir);
|
|
assert_eq!(
|
|
defaulted.default_temperature,
|
|
Config::default().default_temperature
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_default_path_loader_ignores_workspace_override_and_projects_dir_trims() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().join("home");
|
|
let root = home.join(".openhuman");
|
|
let user_dir = root.join("users").join("default-loader-user");
|
|
let workspace_override = tmp.path().join("workspace-override");
|
|
let _guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", &home),
|
|
EnvVarGuard::unset(APP_ENV_VAR),
|
|
EnvVarGuard::unset(VITE_APP_ENV_VAR),
|
|
EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace_override),
|
|
EnvVarGuard::set("OPENHUMAN_MODEL", " default-loader-model "),
|
|
];
|
|
|
|
let missing = Config::load_from_default_paths()
|
|
.await
|
|
.expect("load default paths without an existing config");
|
|
assert_eq!(
|
|
missing.config_path,
|
|
root.join("users").join("local").join("config.toml")
|
|
);
|
|
assert_eq!(missing.workspace_dir, workspace_override.join("workspace"));
|
|
assert_eq!(
|
|
missing.default_model.as_deref(),
|
|
Some("default-loader-model")
|
|
);
|
|
|
|
write_active_user_id(&root, "default-loader-user").expect("write active user");
|
|
write_min_config(&user_dir);
|
|
write_min_config(&workspace_override);
|
|
let loaded = Config::load_from_default_paths()
|
|
.await
|
|
.expect("load default active-user config while workspace override is set");
|
|
assert_eq!(loaded.config_path, user_dir.join("config.toml"));
|
|
assert_eq!(loaded.workspace_dir, workspace_override.join("workspace"));
|
|
assert_eq!(
|
|
loaded.default_model.as_deref(),
|
|
Some("default-loader-model")
|
|
);
|
|
|
|
let custom_projects = tmp.path().join("OpenHuman Projects");
|
|
{
|
|
let _projects_guard = EnvVarGuard::set_to_path("OPENHUMAN_PROJECTS_DIR", &custom_projects);
|
|
assert_eq!(default_projects_dir(), custom_projects);
|
|
}
|
|
let _blank_projects_guard = EnvVarGuard::set("OPENHUMAN_PROJECTS_DIR", " ");
|
|
assert_eq!(
|
|
default_projects_dir(),
|
|
home.join("OpenHuman").join("projects")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_env_overlay_public_loader_applies_runtime_and_tool_overrides() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let config_dir = tmp.path().join("config");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
write_min_config(&config_dir);
|
|
|
|
let _guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", tmp.path()),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::set("OPENHUMAN_MODEL", " env-model "),
|
|
EnvVarGuard::set("OPENHUMAN_TEMPERATURE", "1.25"),
|
|
EnvVarGuard::set("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "17"),
|
|
EnvVarGuard::set("OPENHUMAN_OUTPUT_LANGUAGE", " ja "),
|
|
EnvVarGuard::set("OPENHUMAN_REASONING_ENABLED", "yes"),
|
|
EnvVarGuard::set("OPENHUMAN_SELTZ_API_KEY", "seltz-key"),
|
|
EnvVarGuard::set("OPENHUMAN_SELTZ_API_URL", "https://seltz.example/v1"),
|
|
EnvVarGuard::set("OPENHUMAN_SELTZ_MAX_RESULTS", "13"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARXNG_ENABLED", "on"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARXNG_BASE_URL", "https://searx.example"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARXNG_MAX_RESULTS", "31"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE", "de"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARXNG_TIMEOUT_SECS", "9"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARCH_ENGINE", "brave"),
|
|
EnvVarGuard::set("OPENHUMAN_PARALLEL_API_KEY", "parallel-key"),
|
|
EnvVarGuard::set("OPENHUMAN_BRAVE_API_KEY", "brave-key"),
|
|
EnvVarGuard::set("OPENHUMAN_QUERIT_API_KEY", "querit-key"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARCH_MAX_RESULTS", "11"),
|
|
EnvVarGuard::set("OPENHUMAN_SEARCH_TIMEOUT_SECS", "8"),
|
|
EnvVarGuard::set("OPENHUMAN_WEB_SEARCH_ENABLED", "0"),
|
|
EnvVarGuard::set("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "7"),
|
|
EnvVarGuard::set("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "6"),
|
|
EnvVarGuard::set("OPENHUMAN_PROXY_ENABLED", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_HTTP_PROXY", " http://proxy.example:8080 "),
|
|
EnvVarGuard::set("OPENHUMAN_NO_PROXY", " localhost,example.test "),
|
|
EnvVarGuard::set("OPENHUMAN_PROXY_SCOPE", "services"),
|
|
EnvVarGuard::set("OPENHUMAN_PROXY_SERVICES", "tool.browser,memory.embeddings"),
|
|
EnvVarGuard::set("OPENHUMAN_NODE_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_NODE_VERSION", "v24.0.0"),
|
|
EnvVarGuard::set("OPENHUMAN_NODE_CACHE_DIR", "/tmp/openhuman-node-cache"),
|
|
EnvVarGuard::set("OPENHUMAN_NODE_PREFER_SYSTEM", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_RUNTIME_PYTHON_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_RUNTIME_PYTHON_MINIMUM_VERSION", "3.13.0"),
|
|
EnvVarGuard::set(
|
|
"OPENHUMAN_RUNTIME_PYTHON_CACHE_DIR",
|
|
"/tmp/openhuman-python-cache",
|
|
),
|
|
EnvVarGuard::set("OPENHUMAN_RUNTIME_PYTHON_MANAGED_RELEASE_TAG", "20260401"),
|
|
EnvVarGuard::set("OPENHUMAN_RUNTIME_PYTHON_PREFER_SYSTEM", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_RUNTIME_PYTHON_PREFERRED_COMMAND", "python3.13"),
|
|
EnvVarGuard::set("OPENHUMAN_CORE_SENTRY_DSN", "https://dsn.example/1"),
|
|
EnvVarGuard::set("OPENHUMAN_ANALYTICS_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_ENABLED", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_REFLECTION_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_USER_PROFILE_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_TOOL_TRACKING_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_TOOL_MEMORY_CAPTURE_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_EXPLICIT_PREFERENCES_ENABLED", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_REFLECTION_SOURCE", "cloud"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_MAX_REFLECTIONS_PER_SESSION", "3"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_MIN_TURN_COMPLEXITY", "2"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_EPISODIC_CAPTURE_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_STM_RECALL_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_LEARNING_UNIFIED_COMPACTION_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", "https://embed.example"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", "embed-env"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_TIMEOUT_MS", "1234"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_RATE_LIMIT", "42"),
|
|
EnvVarGuard::set(
|
|
"OPENHUMAN_MEMORY_EXTRACT_ENDPOINT",
|
|
"https://extract.example",
|
|
),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EXTRACT_MODEL", "extract-env"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EXTRACT_TIMEOUT_MS", "2345"),
|
|
EnvVarGuard::set(
|
|
"OPENHUMAN_MEMORY_SUMMARISE_ENDPOINT",
|
|
"https://summarise.example",
|
|
),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_SUMMARISE_MODEL", "summarise-env"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_SUMMARISE_TIMEOUT_MS", "3456"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_TREE_CONTENT_DIR", "/tmp/openhuman-tree"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_TREE_LLM_BACKEND", "local"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL", "cloud-tree-model"),
|
|
EnvVarGuard::set("OPENHUMAN_AUTO_UPDATE_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_AUTO_UPDATE_INTERVAL_MINUTES", "1440"),
|
|
EnvVarGuard::set("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY", "supervisor"),
|
|
EnvVarGuard::set("OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_ENABLED", "true"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_HOTKEY", "CmdOrCtrl+Shift+D"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_ACTIVATION_MODE", "toggle"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_LLM_REFINEMENT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_STREAMING", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_DICTATION_STREAMING_INTERVAL_MS", "333"),
|
|
EnvVarGuard::set("OPENHUMAN_CONTEXT_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_CONTEXT_MICROCOMPACT_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_CONTEXT_AUTOCOMPACT_ENABLED", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES", "12345"),
|
|
EnvVarGuard::set("OPENHUMAN_CONTEXT_SUMMARIZER_MODEL", "summary-env"),
|
|
];
|
|
|
|
let config = Config::load_from_config_path(&config_dir.join("config.toml"), &workspace_dir)
|
|
.await
|
|
.expect("load config with env overlay");
|
|
|
|
assert_eq!(config.default_model.as_deref(), Some("env-model"));
|
|
assert_eq!(config.default_temperature, 1.25);
|
|
assert_eq!(config.autonomy.max_actions_per_hour, 17);
|
|
assert_eq!(config.output_language.as_deref(), Some("ja"));
|
|
assert_eq!(config.runtime.reasoning_enabled, Some(true));
|
|
assert!(config.seltz.enabled);
|
|
assert_eq!(config.seltz.api_key.as_deref(), Some("seltz-key"));
|
|
assert_eq!(config.seltz.max_results, 13);
|
|
assert!(config.searxng.enabled);
|
|
assert_eq!(config.searxng.base_url, "https://searx.example");
|
|
assert_eq!(config.searxng.max_results, 31);
|
|
assert_eq!(config.search.engine, "brave");
|
|
assert!(config.search.parallel.has_key());
|
|
assert!(config.search.brave.has_key());
|
|
assert!(config.search.querit.has_key());
|
|
assert_eq!(config.search.max_results, 11);
|
|
assert_eq!(config.web_search.max_results, 7);
|
|
assert!(config.proxy.enabled);
|
|
assert_eq!(config.proxy.scope, ProxyScope::Services);
|
|
assert!(config.proxy.should_apply_to_service("tool.browser"));
|
|
assert!(!config.node.enabled);
|
|
assert_eq!(config.node.version, "v24.0.0");
|
|
assert!(!config.node.prefer_system);
|
|
assert!(!config.runtime_python.enabled);
|
|
assert_eq!(config.runtime_python.minimum_version, "3.13.0");
|
|
assert!(config.runtime_python.prefer_system);
|
|
assert_eq!(config.observability.analytics_enabled, false);
|
|
assert_eq!(
|
|
config.observability.sentry_dsn.as_deref(),
|
|
Some("https://dsn.example/1")
|
|
);
|
|
assert!(config.learning.enabled);
|
|
assert!(!config.learning.reflection_enabled);
|
|
assert_eq!(config.learning.reflection_source, ReflectionSource::Cloud);
|
|
assert_eq!(config.learning.max_reflections_per_session, 3);
|
|
assert_eq!(config.learning.min_turn_complexity, 2);
|
|
assert!(!config.learning.episodic_capture_enabled);
|
|
assert_eq!(config.memory.embedding_rate_limit_per_min, 42);
|
|
assert_eq!(
|
|
config.memory_tree.embedding_endpoint.as_deref(),
|
|
Some("https://embed.example")
|
|
);
|
|
assert_eq!(
|
|
config.memory_tree.embedding_model.as_deref(),
|
|
Some("embed-env")
|
|
);
|
|
assert_eq!(config.memory_tree.embedding_timeout_ms, Some(1234));
|
|
assert!(config.memory_tree.embedding_strict);
|
|
assert_eq!(config.memory_tree.llm_backend, LlmBackend::Local);
|
|
assert_eq!(
|
|
config.memory_tree.content_dir.as_deref(),
|
|
Some(Path::new("/tmp/openhuman-tree"))
|
|
);
|
|
assert!(!config.update.enabled);
|
|
assert_eq!(config.update.interval_minutes, 1440);
|
|
assert_eq!(
|
|
config.update.restart_strategy,
|
|
UpdateRestartStrategy::Supervisor
|
|
);
|
|
assert!(!config.update.rpc_mutations_enabled);
|
|
assert!(config.dictation.enabled);
|
|
assert_eq!(config.dictation.hotkey, "CmdOrCtrl+Shift+D");
|
|
assert_eq!(
|
|
config.dictation.activation_mode,
|
|
DictationActivationMode::Toggle
|
|
);
|
|
assert!(!config.dictation.llm_refinement);
|
|
assert!(!config.dictation.streaming);
|
|
assert_eq!(config.dictation.streaming_interval_ms, 333);
|
|
assert!(!config.context.enabled);
|
|
assert!(!config.context.microcompact_enabled);
|
|
assert!(!config.context.autocompact_enabled);
|
|
assert_eq!(config.context.tool_result_budget_bytes, 12345);
|
|
assert_eq!(
|
|
config.context.summarizer_model.as_deref(),
|
|
Some("summary-env")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_save_and_load_encrypts_channel_secret_fields() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().join("home");
|
|
let _guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", &home),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::unset(APP_ENV_VAR),
|
|
EnvVarGuard::unset(VITE_APP_ENV_VAR),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
|
|
];
|
|
let config_path = home
|
|
.join(".openhuman")
|
|
.join("users")
|
|
.join("local")
|
|
.join("config.toml");
|
|
let workspace_dir = config_path
|
|
.parent()
|
|
.expect("config parent")
|
|
.join("workspace");
|
|
|
|
let mut config = Config::default();
|
|
config.config_path = config_path.clone();
|
|
config.workspace_dir = workspace_dir.clone();
|
|
config.secrets.encrypt = true;
|
|
config.api_key = Some("api-secret".into());
|
|
config.search.parallel.api_key = Some("parallel-secret".into());
|
|
config.search.brave.api_key = Some("brave-secret".into());
|
|
config.search.querit.api_key = Some("querit-secret".into());
|
|
config.channels_config.telegram = Some(TelegramConfig {
|
|
bot_token: "telegram-secret".into(),
|
|
chat_id: None,
|
|
allowed_users: vec!["alice".into()],
|
|
stream_mode: Default::default(),
|
|
draft_update_interval_ms: 1000,
|
|
silent_streaming: true,
|
|
mention_only: false,
|
|
});
|
|
config.channels_config.discord = Some(DiscordConfig {
|
|
bot_token: "discord-secret".into(),
|
|
guild_id: Some("guild".into()),
|
|
channel_id: Some("channel".into()),
|
|
allowed_users: vec![],
|
|
listen_to_bots: false,
|
|
mention_only: false,
|
|
});
|
|
config.channels_config.slack = Some(SlackConfig {
|
|
bot_token: "slack-bot-secret".into(),
|
|
app_token: Some("slack-app-secret".into()),
|
|
channel_id: Some("C123".into()),
|
|
allowed_users: vec![],
|
|
});
|
|
config.channels_config.matrix = Some(MatrixConfig {
|
|
homeserver: "https://matrix.example.test".into(),
|
|
access_token: "matrix-secret".into(),
|
|
user_id: Some("@worker:example.test".into()),
|
|
device_id: None,
|
|
room_id: "!room:example.test".into(),
|
|
allowed_users: vec![],
|
|
});
|
|
config.channels_config.whatsapp = Some(WhatsAppConfig {
|
|
access_token: Some("whatsapp-access-secret".into()),
|
|
phone_number_id: Some("phone".into()),
|
|
verify_token: Some("whatsapp-verify-secret".into()),
|
|
app_secret: Some("whatsapp-app-secret".into()),
|
|
session_path: None,
|
|
pair_phone: None,
|
|
pair_code: None,
|
|
allowed_numbers: vec![],
|
|
});
|
|
config.channels_config.webhook = Some(WebhookConfig {
|
|
port: 0,
|
|
secret: Some("webhook-secret".into()),
|
|
});
|
|
config.channels_config.irc = Some(IrcConfig {
|
|
server: "irc.example.test".into(),
|
|
port: 6697,
|
|
nickname: "worker".into(),
|
|
username: Some("worker".into()),
|
|
channels: vec!["#openhuman".into()],
|
|
allowed_users: vec![],
|
|
server_password: Some("irc-server-secret".into()),
|
|
nickserv_password: Some("irc-nickserv-secret".into()),
|
|
sasl_password: Some("irc-sasl-secret".into()),
|
|
verify_tls: Some(true),
|
|
});
|
|
config.channels_config.lark = Some(LarkConfig {
|
|
app_id: "lark-app".into(),
|
|
app_secret: "lark-app-secret".into(),
|
|
encrypt_key: Some("lark-encrypt-secret".into()),
|
|
verification_token: Some("lark-verify-secret".into()),
|
|
allowed_users: vec![],
|
|
use_feishu: false,
|
|
receive_mode: Default::default(),
|
|
port: None,
|
|
});
|
|
config.channels_config.dingtalk = Some(DingTalkConfig {
|
|
client_id: "dingtalk-client".into(),
|
|
client_secret: "dingtalk-secret".into(),
|
|
allowed_users: vec![],
|
|
});
|
|
config.channels_config.qq = Some(QQConfig {
|
|
app_id: "qq-app".into(),
|
|
app_secret: "qq-secret".into(),
|
|
allowed_users: vec![],
|
|
});
|
|
|
|
config.save().await.expect("save encrypted config");
|
|
let raw = std::fs::read_to_string(&config_path).expect("read saved encrypted config");
|
|
for secret in [
|
|
"api-secret",
|
|
"parallel-secret",
|
|
"telegram-secret",
|
|
"discord-secret",
|
|
"slack-bot-secret",
|
|
"slack-app-secret",
|
|
"matrix-secret",
|
|
"whatsapp-access-secret",
|
|
"webhook-secret",
|
|
"irc-server-secret",
|
|
"lark-app-secret",
|
|
"dingtalk-secret",
|
|
"qq-secret",
|
|
] {
|
|
assert!(
|
|
!raw.contains(secret),
|
|
"saved encrypted config should not contain plaintext {secret}: {raw}"
|
|
);
|
|
}
|
|
|
|
let loaded = Config::load_or_init()
|
|
.await
|
|
.expect("load encrypted config from default path");
|
|
assert_eq!(loaded.config_path, config_path);
|
|
assert_eq!(loaded.api_key.as_deref(), Some("api-secret"));
|
|
assert_eq!(
|
|
loaded.search.parallel.api_key.as_deref(),
|
|
Some("parallel-secret")
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.channels_config
|
|
.telegram
|
|
.as_ref()
|
|
.map(|telegram| telegram.bot_token.as_str()),
|
|
Some("telegram-secret")
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.channels_config
|
|
.slack
|
|
.as_ref()
|
|
.and_then(|slack| slack.app_token.as_deref()),
|
|
Some("slack-app-secret")
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.channels_config
|
|
.lark
|
|
.as_ref()
|
|
.map(|lark| lark.app_secret.as_str()),
|
|
Some("lark-app-secret")
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.channels_config
|
|
.qq
|
|
.as_ref()
|
|
.map(|qq| qq.app_secret.as_str()),
|
|
Some("qq-secret")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_service_direct_paths_cover_profile_selection_and_validation() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let auth = AuthService::new(tmp.path(), false);
|
|
let store = AuthProfilesStore::new(tmp.path(), false);
|
|
|
|
assert_eq!(normalize_provider(" GitHub ").unwrap(), "github");
|
|
assert!(normalize_provider(" ").is_err());
|
|
assert!(auth.set_active_profile("github", "missing").is_err());
|
|
assert_eq!(
|
|
auth.get_provider_bearer_token("github", None)
|
|
.expect("missing profile lookup"),
|
|
None
|
|
);
|
|
|
|
let stored = auth
|
|
.store_provider_token(
|
|
"github",
|
|
"personal",
|
|
" ghp-token ",
|
|
[("scope".to_string(), "repo".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
false,
|
|
)
|
|
.expect("store token profile");
|
|
assert_eq!(stored.provider, "github");
|
|
assert_eq!(
|
|
auth.get_provider_bearer_token("GitHub", Some("personal"))
|
|
.expect("profile override lookup"),
|
|
Some(" ghp-token ".to_string())
|
|
);
|
|
assert_eq!(
|
|
auth.get_provider_bearer_token("GitHub", None)
|
|
.expect("no active/default lookup"),
|
|
Some(" ghp-token ".to_string())
|
|
);
|
|
|
|
let active_id = auth
|
|
.set_active_profile("GitHub", "personal")
|
|
.expect("set active by profile name");
|
|
assert_eq!(active_id, stored.id);
|
|
assert!(auth
|
|
.remove_profile("GitHub", "personal")
|
|
.expect("remove stored profile"));
|
|
assert!(!auth
|
|
.remove_profile("GitHub", "personal")
|
|
.expect("remove missing profile"));
|
|
|
|
let oauth_profile = AuthProfile::new_oauth(
|
|
"gitlab",
|
|
"main",
|
|
TokenSet {
|
|
access_token: "gitlab-access-token".to_string(),
|
|
refresh_token: Some("gitlab-refresh-token".to_string()),
|
|
id_token: None,
|
|
expires_at: None,
|
|
token_type: Some("Bearer".to_string()),
|
|
scope: Some("read_user".to_string()),
|
|
},
|
|
);
|
|
let oauth_id = oauth_profile.id.clone();
|
|
store
|
|
.upsert_profile(oauth_profile, true)
|
|
.expect("store oauth profile through shared store");
|
|
assert_eq!(
|
|
auth.get_provider_bearer_token("gitlab", None)
|
|
.expect("oauth bearer lookup"),
|
|
Some("gitlab-access-token".to_string())
|
|
);
|
|
assert!(auth
|
|
.get_profile("gitlab", Some("missing"))
|
|
.expect("missing override lookup")
|
|
.is_none());
|
|
let wrong_provider_err = auth
|
|
.set_active_profile("github", &oauth_id)
|
|
.expect_err("full profile id from another provider should fail")
|
|
.to_string();
|
|
assert!(
|
|
wrong_provider_err.contains("belongs to provider gitlab"),
|
|
"full profile ids must still match the requested provider: {wrong_provider_err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_session_support_public_helpers_normalize_tokens_fields_and_summaries() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = Config::default();
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.workspace_dir = tmp.path().join("workspace");
|
|
config.secrets.encrypt = false;
|
|
std::fs::create_dir_all(config.config_path.parent().expect("config parent"))
|
|
.expect("create config parent");
|
|
|
|
assert_eq!(profile_name_or_default(None), "default");
|
|
assert_eq!(profile_name_or_default(Some(" ")), "default");
|
|
assert_eq!(profile_name_or_default(Some(" work ")), "work");
|
|
assert!(is_local_session_token(" header.payload.local "));
|
|
assert!(!is_local_session_token("header.payload.remote"));
|
|
assert!(parse_fields_value(Some(json!("bad"))).is_err());
|
|
assert!(parse_fields_value(Some(json!({ " ": "bad" }))).is_err());
|
|
let fields = parse_fields_value(Some(json!({
|
|
"string": "value",
|
|
"number": 42,
|
|
"bool": true,
|
|
"empty": null
|
|
})))
|
|
.expect("fields object should parse");
|
|
assert_eq!(fields.get("number").map(String::as_str), Some("42"));
|
|
assert_eq!(fields.get("bool").map(String::as_str), Some("true"));
|
|
assert_eq!(fields.get("empty").map(String::as_str), Some(""));
|
|
|
|
assert!(!session_state_from_profile(None).is_authenticated);
|
|
assert_eq!(session_token_from_profile(None), None);
|
|
|
|
let auth = AuthService::from_config(&config);
|
|
let mut profile = AuthProfile::new_token(
|
|
APP_SESSION_PROVIDER,
|
|
"default",
|
|
" header.payload.local ".to_string(),
|
|
);
|
|
profile
|
|
.metadata
|
|
.insert("user_id".to_string(), "session-user".to_string());
|
|
profile.metadata.insert(
|
|
"user_json".to_string(),
|
|
json!({
|
|
"id": "session-user",
|
|
"name": "Session Worker",
|
|
"email": "session-worker@example.test"
|
|
})
|
|
.to_string(),
|
|
);
|
|
profile
|
|
.metadata
|
|
.insert("zeta".to_string(), "last".to_string());
|
|
profile
|
|
.metadata
|
|
.insert("alpha".to_string(), "first".to_string());
|
|
auth.load_profiles().expect("profile store should be empty");
|
|
AuthProfilesStore::new(
|
|
config.config_path.parent().expect("config parent"),
|
|
config.secrets.encrypt,
|
|
)
|
|
.upsert_profile(profile.clone(), true)
|
|
.expect("store app session profile");
|
|
|
|
let loaded = load_app_session_profile(&config)
|
|
.expect("load app session profile")
|
|
.expect("stored app session profile");
|
|
let state = session_state_from_profile(Some(&loaded));
|
|
assert!(state.is_authenticated);
|
|
assert_eq!(state.user_id.as_deref(), Some("session-user"));
|
|
assert_eq!(
|
|
state
|
|
.user
|
|
.as_ref()
|
|
.and_then(|user| user.get("email"))
|
|
.and_then(Value::as_str),
|
|
Some("session-worker@example.test")
|
|
);
|
|
assert_eq!(
|
|
session_token_from_profile(Some(&loaded)),
|
|
Some("header.payload.local".to_string())
|
|
);
|
|
assert_eq!(
|
|
get_session_token(&config).expect("session token from config"),
|
|
Some("header.payload.local".to_string())
|
|
);
|
|
assert!(
|
|
build_session_state(&config)
|
|
.expect("session state from config")
|
|
.is_authenticated
|
|
);
|
|
|
|
let summary = summarize_auth_profile(&loaded);
|
|
assert_eq!(summary.provider, APP_SESSION_PROVIDER);
|
|
assert_eq!(summary.kind, "token");
|
|
assert!(summary.has_token);
|
|
assert!(!summary.has_token_set);
|
|
assert!(
|
|
summary
|
|
.metadata_keys
|
|
.windows(2)
|
|
.all(|pair| pair[0] <= pair[1]),
|
|
"metadata keys should be sorted for stable UI output: {:?}",
|
|
summary.metadata_keys
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_provider_prefix_listing_sorts_filters_and_excludes_app_session() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = Config::default();
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.workspace_dir = tmp.path().join("workspace");
|
|
config.secrets.encrypt = false;
|
|
std::fs::create_dir_all(config.config_path.parent().expect("config parent"))
|
|
.expect("create config parent");
|
|
|
|
let auth = AuthService::from_config(&config);
|
|
auth.store_provider_token(
|
|
"channel:slack:bot",
|
|
"default",
|
|
"slack-token",
|
|
[("team".to_string(), "T1".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
true,
|
|
)
|
|
.expect("store slack channel token");
|
|
auth.store_provider_token(
|
|
"channel:telegram:managed_dm",
|
|
"default",
|
|
"telegram-token",
|
|
[("chat_id".to_string(), "42".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
false,
|
|
)
|
|
.expect("store telegram channel token");
|
|
auth.store_provider_token(
|
|
"github",
|
|
"default",
|
|
"github-token",
|
|
Default::default(),
|
|
true,
|
|
)
|
|
.expect("store non-channel token");
|
|
auth.store_provider_token(
|
|
APP_SESSION_PROVIDER,
|
|
"default",
|
|
"session-token",
|
|
Default::default(),
|
|
true,
|
|
)
|
|
.expect("store app session token");
|
|
|
|
let channels = list_provider_credentials_by_prefix(&config, "channel:")
|
|
.await
|
|
.expect("list channel credentials by prefix");
|
|
let providers = channels
|
|
.iter()
|
|
.map(|profile| profile.provider.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
providers,
|
|
vec!["channel:slack:bot", "channel:telegram:managed_dm"]
|
|
);
|
|
assert!(channels.iter().all(|profile| profile.has_token));
|
|
assert!(channels.iter().all(|profile| !profile.has_token_set));
|
|
|
|
let missing = list_provider_credentials_by_prefix(&config, "calendar:")
|
|
.await
|
|
.expect("missing prefix listing should succeed");
|
|
assert!(missing.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn composio_direct_credentials_helpers_trim_store_and_clear_key() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = Config::default();
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.workspace_dir = tmp.path().join("workspace");
|
|
config.secrets.encrypt = false;
|
|
std::fs::create_dir_all(config.config_path.parent().expect("config parent"))
|
|
.expect("create config parent");
|
|
|
|
assert_eq!(
|
|
get_composio_api_key(&config).expect("empty composio key store"),
|
|
None
|
|
);
|
|
assert!(
|
|
store_composio_api_key(&config, " ").await.is_err(),
|
|
"blank composio keys should be rejected"
|
|
);
|
|
|
|
let stored = store_composio_api_key(&config, " cmp_worker_a_secret ")
|
|
.await
|
|
.expect("store direct composio key");
|
|
assert_eq!(
|
|
stored.value.get("provider").and_then(Value::as_str),
|
|
Some(COMPOSIO_DIRECT_PROVIDER)
|
|
);
|
|
assert_eq!(
|
|
get_composio_api_key(&config).expect("stored composio key"),
|
|
Some("cmp_worker_a_secret".to_string())
|
|
);
|
|
|
|
let stored_via_rpc = rpc_store_composio_api_key(&config, "cmp_worker_a_second")
|
|
.await
|
|
.expect("store direct composio key via rpc helper");
|
|
assert_eq!(
|
|
stored_via_rpc.value.get("stored").and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
get_composio_api_key(&config).expect("updated composio key"),
|
|
Some("cmp_worker_a_second".to_string())
|
|
);
|
|
|
|
let cleared = clear_composio_api_key(&config)
|
|
.await
|
|
.expect("clear direct composio key");
|
|
assert_eq!(
|
|
cleared.value.get("removed").and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
get_composio_api_key(&config).expect("cleared composio key"),
|
|
None
|
|
);
|
|
let cleared_again = clear_composio_api_key(&config)
|
|
.await
|
|
.expect("clear missing direct composio key");
|
|
assert_eq!(
|
|
cleared_again.value.get("removed").and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credentials_public_ops_cover_service_and_missing_session_error_paths() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = Config::default();
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.workspace_dir = tmp.path().join("workspace");
|
|
config.secrets.encrypt = false;
|
|
config.local_ai.runtime_enabled = false;
|
|
config.voice_server.auto_start = false;
|
|
std::fs::create_dir_all(config.config_path.parent().expect("config parent"))
|
|
.expect("create config parent");
|
|
|
|
openhuman_core::openhuman::credentials::start_login_gated_services(&config).await;
|
|
openhuman_core::openhuman::credentials::stop_login_gated_services(&config).await;
|
|
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::auth_create_channel_link_token(&config, " ")
|
|
.await
|
|
.expect_err("blank channel should fail")
|
|
.contains("channel is required")
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::auth_create_channel_link_token(&config, "matrix")
|
|
.await
|
|
.expect_err("unsupported channel should fail")
|
|
.contains("unsupported channel")
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::auth_create_channel_link_token(&config, "telegram")
|
|
.await
|
|
.expect_err("missing session should fail")
|
|
.contains("session JWT required")
|
|
);
|
|
assert!(openhuman_core::openhuman::credentials::oauth_connect(
|
|
&config,
|
|
"github",
|
|
Some("skill"),
|
|
Some("code"),
|
|
Some("handoff"),
|
|
)
|
|
.await
|
|
.expect_err("oauth connect without session should fail")
|
|
.contains("session JWT required"));
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::oauth_list_integrations(&config)
|
|
.await
|
|
.expect_err("oauth list without session should fail")
|
|
.contains("session JWT required")
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::oauth_fetch_integration_tokens(
|
|
&config,
|
|
"0123456789abcdef01234567",
|
|
"0123456789abcdef0123456789abcdef",
|
|
)
|
|
.await
|
|
.expect_err("oauth token fetch without session should fail")
|
|
.contains("session JWT required")
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::oauth_fetch_client_key(
|
|
&config,
|
|
"0123456789abcdef01234567",
|
|
)
|
|
.await
|
|
.expect_err("client key fetch without session should fail")
|
|
.contains("session JWT required")
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::credentials::oauth_revoke_integration(
|
|
&config,
|
|
"0123456789abcdef01234567",
|
|
)
|
|
.await
|
|
.expect_err("oauth revoke without session should fail")
|
|
.contains("session JWT required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credentials_secret_helpers_round_trip_with_file_keyring_backend() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = Config::default();
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(config.config_path.parent().expect("config parent"))
|
|
.expect("create config parent");
|
|
|
|
let encrypted = encrypt_secret(&config, "worker-a-sensitive-value")
|
|
.await
|
|
.expect("encrypt secret")
|
|
.value;
|
|
assert_ne!(encrypted, "worker-a-sensitive-value");
|
|
assert!(
|
|
encrypted.starts_with("enc"),
|
|
"encrypted secret should carry an encrypted payload marker: {encrypted}"
|
|
);
|
|
|
|
let decrypted = decrypt_secret(&config, &encrypted)
|
|
.await
|
|
.expect("decrypt secret")
|
|
.value;
|
|
assert_eq!(decrypted, "worker-a-sensitive-value");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_cli_flows_cover_app_session_and_provider_storage_paths() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let fields = parse_field_equals_entries(&[
|
|
"scope=repo".to_string(),
|
|
"refresh_token=refresh-1".to_string(),
|
|
])
|
|
.expect("parse cli fields");
|
|
assert_eq!(fields.get("scope").and_then(Value::as_str), Some("repo"));
|
|
assert!(parse_field_equals_entries(&["not-key-value".to_string()]).is_err());
|
|
assert!(parse_field_equals_entries(&[" =blank".to_string()]).is_err());
|
|
|
|
let provider_login = cli_auth_login(
|
|
" github ".to_string(),
|
|
"provider-token".to_string(),
|
|
None,
|
|
None,
|
|
fields,
|
|
Some("work".to_string()),
|
|
true,
|
|
)
|
|
.await
|
|
.expect("provider cli login");
|
|
assert!(
|
|
provider_login.to_string().contains("github"),
|
|
"provider login should mention provider: {provider_login}"
|
|
);
|
|
|
|
let provider_status = cli_auth_status("github".to_string(), None)
|
|
.await
|
|
.expect("provider status");
|
|
assert!(
|
|
provider_status.to_string().contains("github"),
|
|
"provider status should include github profile: {provider_status}"
|
|
);
|
|
|
|
let provider_list = cli_auth_list(Some(" github ".to_string()))
|
|
.await
|
|
.expect("provider list");
|
|
assert!(
|
|
provider_list.to_string().contains("github"),
|
|
"provider list should include github profile: {provider_list}"
|
|
);
|
|
|
|
let provider_logout = cli_auth_logout("github".to_string(), Some("work".to_string()))
|
|
.await
|
|
.expect("provider logout");
|
|
assert!(
|
|
provider_logout.to_string().contains("removed")
|
|
|| provider_logout.to_string().contains("true"),
|
|
"provider logout should report removal: {provider_logout}"
|
|
);
|
|
|
|
let session_login = cli_auth_login(
|
|
APP_SESSION_PROVIDER.to_string(),
|
|
"header.payload.local".to_string(),
|
|
Some("cli-user".to_string()),
|
|
Some(json!({ "id": "cli-user", "name": "CLI User" })),
|
|
Value::Object(Default::default()),
|
|
None,
|
|
true,
|
|
)
|
|
.await
|
|
.expect("app-session cli login");
|
|
assert!(
|
|
session_login.to_string().contains("app-session")
|
|
&& session_login.to_string().contains("session stored"),
|
|
"session login should store app session profile: {session_login}"
|
|
);
|
|
|
|
let session_status = cli_auth_status(APP_SESSION_PROVIDER.to_string(), None)
|
|
.await
|
|
.expect("session status");
|
|
assert!(
|
|
session_status.to_string().contains("isAuthenticated")
|
|
|| session_status.to_string().contains("cli-user"),
|
|
"session status should expose auth state: {session_status}"
|
|
);
|
|
|
|
let session_logout = cli_auth_logout(APP_SESSION_PROVIDER.to_string(), None)
|
|
.await
|
|
.expect("session logout");
|
|
assert!(
|
|
session_logout.to_string().contains("isAuthenticated")
|
|
|| session_logout.to_string().contains("false")
|
|
|| session_logout.to_string().contains("removed"),
|
|
"session logout should clear auth state: {session_logout}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn worker_a_controller_schemas_are_fully_exposed() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let schema = schema(&harness.rpc_base).await;
|
|
|
|
for (namespace, expected) in [
|
|
(
|
|
"config",
|
|
vec![
|
|
"openhuman.config_agent_server_status",
|
|
"openhuman.config_get",
|
|
"openhuman.config_get_activity_level_settings",
|
|
"openhuman.config_get_agent_paths",
|
|
"openhuman.config_get_agent_settings",
|
|
"openhuman.config_get_analytics_settings",
|
|
"openhuman.config_get_autonomy_settings",
|
|
"openhuman.config_get_client_config",
|
|
"openhuman.config_get_composio_trigger_settings",
|
|
"openhuman.config_get_dashboard_settings",
|
|
"openhuman.config_get_data_paths",
|
|
"openhuman.config_get_dictation_settings",
|
|
"openhuman.config_get_meet_settings",
|
|
"openhuman.config_get_memory_sync_settings",
|
|
"openhuman.config_get_onboarding_completed",
|
|
"openhuman.config_get_runtime_flags",
|
|
"openhuman.config_get_sandbox_settings",
|
|
"openhuman.config_get_search_settings",
|
|
"openhuman.config_get_super_context_enabled",
|
|
"openhuman.config_get_voice_server_settings",
|
|
"openhuman.config_reset_local_data",
|
|
"openhuman.config_resolve_api_url",
|
|
"openhuman.config_set_browser_allow_all",
|
|
"openhuman.config_set_onboarding_completed",
|
|
"openhuman.config_set_super_context_enabled",
|
|
"openhuman.config_update_activity_level_settings",
|
|
"openhuman.config_update_agent_paths",
|
|
"openhuman.config_update_agent_settings",
|
|
"openhuman.config_update_analytics_settings",
|
|
"openhuman.config_update_autonomy_settings",
|
|
"openhuman.config_update_browser_settings",
|
|
"openhuman.config_update_composio_trigger_settings",
|
|
"openhuman.config_update_dictation_settings",
|
|
"openhuman.config_update_local_ai_settings",
|
|
"openhuman.config_update_meet_settings",
|
|
"openhuman.config_update_memory_settings",
|
|
"openhuman.config_update_memory_sync_settings",
|
|
"openhuman.config_update_model_settings",
|
|
"openhuman.config_update_runtime_settings",
|
|
"openhuman.config_update_sandbox_settings",
|
|
"openhuman.config_update_screen_intelligence_settings",
|
|
"openhuman.config_update_search_settings",
|
|
"openhuman.config_update_voice_server_settings",
|
|
"openhuman.config_workspace_onboarding_flag_exists",
|
|
"openhuman.config_workspace_onboarding_flag_set",
|
|
],
|
|
),
|
|
(
|
|
"auth",
|
|
vec![
|
|
"openhuman.auth_clear_session",
|
|
"openhuman.auth_consume_login_token",
|
|
"openhuman.auth_create_channel_link_token",
|
|
"openhuman.auth_get_me",
|
|
"openhuman.auth_get_session_token",
|
|
"openhuman.auth_get_state",
|
|
"openhuman.auth_list_provider_credentials",
|
|
"openhuman.auth_oauth_connect",
|
|
"openhuman.auth_oauth_fetch_client_key",
|
|
"openhuman.auth_oauth_fetch_integration_tokens",
|
|
"openhuman.auth_oauth_list_integrations",
|
|
"openhuman.auth_oauth_revoke_integration",
|
|
"openhuman.auth_remove_provider_credentials",
|
|
"openhuman.auth_store_provider_credentials",
|
|
"openhuman.auth_store_session",
|
|
],
|
|
),
|
|
(
|
|
"app_state",
|
|
vec![
|
|
"openhuman.app_state_snapshot",
|
|
"openhuman.app_state_update_local_state",
|
|
],
|
|
),
|
|
("connectivity", vec!["openhuman.connectivity_diag"]),
|
|
(
|
|
"memory_sources",
|
|
vec![
|
|
"openhuman.memory_sources_add",
|
|
"openhuman.memory_sources_apply_all_in",
|
|
"openhuman.memory_sources_estimate_sync_cost",
|
|
"openhuman.memory_sources_get",
|
|
"openhuman.memory_sources_list",
|
|
"openhuman.memory_sources_list_items",
|
|
"openhuman.memory_sources_monthly_cost_summary",
|
|
"openhuman.memory_sources_read_item",
|
|
"openhuman.memory_sources_reconcile",
|
|
"openhuman.memory_sources_remove",
|
|
"openhuman.memory_sources_status_list",
|
|
"openhuman.memory_sources_supported_toolkits",
|
|
"openhuman.memory_sources_sync",
|
|
"openhuman.memory_sources_sync_audit_log",
|
|
"openhuman.memory_sources_update",
|
|
],
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
schema_method_names(&schema, namespace),
|
|
expected,
|
|
"schema catalog mismatch for namespace {namespace}"
|
|
);
|
|
}
|
|
|
|
let unknown_app_state = app_state_schemas("missing");
|
|
assert_eq!(unknown_app_state.namespace, "app_state");
|
|
assert_eq!(unknown_app_state.function, "unknown");
|
|
assert_eq!(unknown_app_state.outputs[0].name, "error");
|
|
assert!(unknown_app_state.description.contains("Unknown app_state"));
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_controller_mutations_round_trip_over_json_rpc() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let initial = rpc(&harness.rpc_base, 10_001, "openhuman.config_get", json!({})).await;
|
|
assert!(
|
|
payload(&initial, "config_get")
|
|
.get("workspace_dir")
|
|
.and_then(Value::as_str)
|
|
.is_some(),
|
|
"config_get should expose resolved paths: {initial}"
|
|
);
|
|
|
|
let model = rpc(
|
|
&harness.rpc_base,
|
|
10_002,
|
|
"openhuman.config_update_model_settings",
|
|
json!({
|
|
"api_url": "http://127.0.0.1:9",
|
|
"inference_url": "http://127.0.0.1:19999/v1",
|
|
"api_key": "worker-a-secret",
|
|
"default_model": "worker-a-updated",
|
|
"default_temperature": 0.4,
|
|
"model_routes": [{ "hint": "reasoning", "model": "route-model" }],
|
|
"cloud_providers": [{
|
|
"id": "provider-a",
|
|
"slug": "worker-a-cloud",
|
|
"label": "Worker A Cloud",
|
|
"endpoint": "http://127.0.0.1:19999/v1",
|
|
"auth_style": "bearer"
|
|
}],
|
|
"primary_cloud": "provider-a",
|
|
"chat_provider": "worker-a-cloud:chat",
|
|
"reasoning_provider": "worker-a-cloud:reason",
|
|
"agentic_provider": "worker-a-cloud:agent",
|
|
"coding_provider": "worker-a-cloud:code",
|
|
"memory_provider": "worker-a-cloud:memory",
|
|
"embeddings_provider": "worker-a-cloud:embeddings",
|
|
"heartbeat_provider": "worker-a-cloud:heartbeat",
|
|
"learning_provider": "worker-a-cloud:learning",
|
|
"subconscious_provider": "worker-a-cloud:subconscious"
|
|
}),
|
|
)
|
|
.await;
|
|
ok(&model, "update_model_settings");
|
|
|
|
let client = rpc(
|
|
&harness.rpc_base,
|
|
10_003,
|
|
"openhuman.config_get_client_config",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let client_payload = payload(&client, "get_client_config");
|
|
assert_eq!(
|
|
client_payload.get("default_model").and_then(Value::as_str),
|
|
Some("worker-a-updated")
|
|
);
|
|
assert_eq!(
|
|
client_payload.get("api_key_set").and_then(Value::as_bool),
|
|
Some(true),
|
|
"client config should expose only API key presence: {client_payload}"
|
|
);
|
|
assert!(
|
|
!client_payload.to_string().contains("worker-a-secret"),
|
|
"client config must not echo local API keys: {client_payload}"
|
|
);
|
|
|
|
// The model registry is seeded + price/context-window-enriched from the
|
|
// static pricing catalog on load (in-memory), so a fresh workspace surfaces
|
|
// real numbers over RPC. Validates the full path: catalog → seed-on-load →
|
|
// client_config_json → JSON-RPC. (The earlier update_model_settings call did
|
|
// not include `model_registry`, so the seeded registry is left intact.)
|
|
let registry = client_payload
|
|
.get("model_registry")
|
|
.and_then(Value::as_array)
|
|
.expect("client config should expose model_registry");
|
|
assert!(
|
|
!registry.is_empty(),
|
|
"model_registry should be auto-seeded from the pricing catalog: {client_payload}"
|
|
);
|
|
let opus = registry
|
|
.iter()
|
|
.find(|m| m.get("id").and_then(Value::as_str) == Some("claude-opus-4-8"))
|
|
.unwrap_or_else(|| panic!("seeded registry should contain claude-opus-4-8: {registry:?}"));
|
|
assert_eq!(
|
|
opus.get("provider").and_then(Value::as_str),
|
|
Some("anthropic")
|
|
);
|
|
assert_eq!(
|
|
opus.get("cost_per_1m_input").and_then(Value::as_f64),
|
|
Some(5.0),
|
|
"input price should be pre-filled from the catalog: {opus:?}"
|
|
);
|
|
assert_eq!(
|
|
opus.get("cost_per_1m_output").and_then(Value::as_f64),
|
|
Some(25.0),
|
|
"output price should be pre-filled from the catalog: {opus:?}"
|
|
);
|
|
assert_eq!(
|
|
opus.get("context_window").and_then(Value::as_u64),
|
|
Some(1_000_000),
|
|
"context window should be pre-filled from the catalog: {opus:?}"
|
|
);
|
|
// Every seeded entry carries non-zero pricing + context window — the whole
|
|
// point of the catalog pre-fill.
|
|
for entry in registry {
|
|
let id = entry.get("id").and_then(Value::as_str).unwrap_or("<none>");
|
|
assert!(
|
|
entry
|
|
.get("cost_per_1m_output")
|
|
.and_then(Value::as_f64)
|
|
.unwrap_or(0.0)
|
|
> 0.0,
|
|
"{id} missing output price: {entry:?}"
|
|
);
|
|
assert!(
|
|
entry
|
|
.get("context_window")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0)
|
|
> 0,
|
|
"{id} missing context window: {entry:?}"
|
|
);
|
|
}
|
|
|
|
let memory = rpc(
|
|
&harness.rpc_base,
|
|
10_004,
|
|
"openhuman.config_update_memory_settings",
|
|
json!({
|
|
"backend": "sqlite",
|
|
"auto_save": true,
|
|
"embedding_provider": "none",
|
|
"embedding_model": "none",
|
|
"embedding_dimensions": 0,
|
|
"memory_window": "minimal"
|
|
}),
|
|
)
|
|
.await;
|
|
ok(&memory, "update_memory_settings");
|
|
|
|
for (id, method, params) in [
|
|
(
|
|
10_005,
|
|
"openhuman.config_update_screen_intelligence_settings",
|
|
json!({
|
|
"enabled": false,
|
|
"capture_policy": "off",
|
|
"baseline_fps": 0.5,
|
|
"vision_enabled": false,
|
|
"autocomplete_enabled": false,
|
|
"use_vision_model": false,
|
|
"keep_screenshots": false,
|
|
"allowlist": ["Finder"],
|
|
"denylist": ["Passwords"]
|
|
}),
|
|
),
|
|
(
|
|
10_006,
|
|
"openhuman.config_update_runtime_settings",
|
|
json!({ "kind": "local", "reasoning_enabled": true }),
|
|
),
|
|
(
|
|
10_007,
|
|
"openhuman.config_update_browser_settings",
|
|
json!({ "enabled": true }),
|
|
),
|
|
(
|
|
10_008,
|
|
"openhuman.config_update_local_ai_settings",
|
|
json!({
|
|
"runtime_enabled": false,
|
|
"opt_in_confirmed": false,
|
|
"provider": "ollama",
|
|
"base_url": "http://127.0.0.1:11434",
|
|
"model_id": "llama3",
|
|
"chat_model_id": "llama3",
|
|
"usage_embeddings": false,
|
|
"usage_heartbeat": false,
|
|
"usage_learning_reflection": false,
|
|
"usage_subconscious": false
|
|
}),
|
|
),
|
|
(
|
|
10_009,
|
|
"openhuman.config_update_voice_server_settings",
|
|
json!({
|
|
"auto_start": false,
|
|
"hotkey": "Fn",
|
|
"activation_mode": "push",
|
|
"skip_cleanup": true,
|
|
"min_duration_secs": 0.25,
|
|
"silence_threshold": 0.01,
|
|
"custom_dictionary": ["OpenHuman", "WorkerA"]
|
|
}),
|
|
),
|
|
(
|
|
10_010,
|
|
"openhuman.config_update_composio_trigger_settings",
|
|
json!({
|
|
"triage_disabled": true,
|
|
"triage_disabled_toolkits": ["gmail", "slack"]
|
|
}),
|
|
),
|
|
(
|
|
10_011,
|
|
"openhuman.config_update_autonomy_settings",
|
|
json!({
|
|
"level": "supervised",
|
|
"workspace_only": true,
|
|
"allowed_commands": ["git", "cargo"],
|
|
"forbidden_paths": ["/tmp/forbidden-worker-a"],
|
|
"trusted_roots": [{
|
|
"path": harness.home.display().to_string(),
|
|
"access": "read"
|
|
}],
|
|
"allow_tool_install": false,
|
|
"max_actions_per_hour": 42,
|
|
"auto_approve": ["memory.search"],
|
|
"require_task_plan_approval": true
|
|
}),
|
|
),
|
|
(
|
|
10_012,
|
|
"openhuman.config_update_search_settings",
|
|
json!({
|
|
"engine": "managed",
|
|
"max_results": 5,
|
|
"timeout_secs": 12,
|
|
"parallel_api_key": "parallel-secret",
|
|
"brave_api_key": "brave-secret",
|
|
"querit_api_key": "querit-secret",
|
|
"allowed_domains": ["example.com"],
|
|
"allow_all": false
|
|
}),
|
|
),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, params).await;
|
|
ok(&response, method);
|
|
}
|
|
|
|
for (id, method) in [
|
|
(10_101, "openhuman.config_resolve_api_url"),
|
|
(10_102, "openhuman.config_get_runtime_flags"),
|
|
(10_103, "openhuman.config_get_dashboard_settings"),
|
|
(10_104, "openhuman.config_agent_server_status"),
|
|
(10_105, "openhuman.config_get_data_paths"),
|
|
(10_106, "openhuman.config_get_voice_server_settings"),
|
|
(10_107, "openhuman.config_get_composio_trigger_settings"),
|
|
(10_108, "openhuman.config_get_autonomy_settings"),
|
|
(10_109, "openhuman.config_get_search_settings"),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, json!({})).await;
|
|
ok(&response, method);
|
|
}
|
|
|
|
let allow_all = rpc(
|
|
&harness.rpc_base,
|
|
10_201,
|
|
"openhuman.config_set_browser_allow_all",
|
|
json!({ "enabled": false }),
|
|
)
|
|
.await;
|
|
ok(&allow_all, "set_browser_allow_all false");
|
|
|
|
let exists_before = rpc(
|
|
&harness.rpc_base,
|
|
10_202,
|
|
"openhuman.config_workspace_onboarding_flag_exists",
|
|
json!({ "flag_name": ".worker-a-onboarding" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&exists_before, "workspace_onboarding_flag_exists before").as_bool(),
|
|
Some(false)
|
|
);
|
|
|
|
let set_flag = rpc(
|
|
&harness.rpc_base,
|
|
10_203,
|
|
"openhuman.config_workspace_onboarding_flag_set",
|
|
json!({ "flag_name": ".worker-a-onboarding", "value": true }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&set_flag, "workspace_onboarding_flag_set true").as_bool(),
|
|
Some(true)
|
|
);
|
|
|
|
let clear_flag = rpc(
|
|
&harness.rpc_base,
|
|
10_204,
|
|
"openhuman.config_workspace_onboarding_flag_set",
|
|
json!({ "flag_name": ".worker-a-onboarding", "value": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&clear_flag, "workspace_onboarding_flag_set false").as_bool(),
|
|
Some(false)
|
|
);
|
|
|
|
let reset = rpc(
|
|
&harness.rpc_base,
|
|
10_301,
|
|
"openhuman.config_reset_local_data",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&reset, "reset_local_data").is_object(),
|
|
"reset should return a result payload: {reset}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_runtime_flags_settings_readbacks_and_validation_paths_are_exercised() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let refused = rpc(
|
|
&harness.rpc_base,
|
|
11_001,
|
|
"openhuman.config_set_browser_allow_all",
|
|
json!({ "enabled": true }),
|
|
)
|
|
.await;
|
|
assert_error_contains(
|
|
&refused,
|
|
"set_browser_allow_all true without operator opt-in",
|
|
"Refusing to enable OPENHUMAN_BROWSER_ALLOW_ALL",
|
|
);
|
|
|
|
std::env::set_var("OPENHUMAN_BROWSER_ALLOW_ALL_RPC_ENABLE", "1");
|
|
let enabled = rpc(
|
|
&harness.rpc_base,
|
|
11_002,
|
|
"openhuman.config_set_browser_allow_all",
|
|
json!({ "enabled": true }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&enabled, "set_browser_allow_all true")
|
|
.get("browser_allow_all")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let disabled = rpc(
|
|
&harness.rpc_base,
|
|
11_003,
|
|
"openhuman.config_set_browser_allow_all",
|
|
json!({ "enabled": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disabled, "set_browser_allow_all false")
|
|
.get("browser_allow_all")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
ok(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_004,
|
|
"openhuman.config_update_analytics_settings",
|
|
json!({ "enabled": false }),
|
|
)
|
|
.await,
|
|
"update_analytics_settings false",
|
|
);
|
|
let analytics = rpc(
|
|
&harness.rpc_base,
|
|
11_005,
|
|
"openhuman.config_get_analytics_settings",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&analytics, "get_analytics_settings")
|
|
.get("enabled")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
ok(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_006,
|
|
"openhuman.config_update_meet_settings",
|
|
json!({ "auto_orchestrator_handoff": true }),
|
|
)
|
|
.await,
|
|
"update_meet_settings true",
|
|
);
|
|
let meet = rpc(
|
|
&harness.rpc_base,
|
|
11_007,
|
|
"openhuman.config_get_meet_settings",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&meet, "get_meet_settings")
|
|
.get("auto_orchestrator_handoff")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let onboarding_before = rpc(
|
|
&harness.rpc_base,
|
|
11_008,
|
|
"openhuman.config_get_onboarding_completed",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&onboarding_before, "get_onboarding_completed before").as_bool(),
|
|
Some(false)
|
|
);
|
|
for (id, value) in [(11_009, true), (11_010, false)] {
|
|
let updated = rpc(
|
|
&harness.rpc_base,
|
|
id,
|
|
"openhuman.config_set_onboarding_completed",
|
|
json!({ "value": value }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&updated, "set_onboarding_completed").as_bool(),
|
|
Some(value)
|
|
);
|
|
}
|
|
|
|
ok(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_011,
|
|
"openhuman.config_update_dictation_settings",
|
|
json!({
|
|
"enabled": true,
|
|
"hotkey": "Ctrl+Space",
|
|
"activation_mode": "toggle",
|
|
"llm_refinement": false,
|
|
"streaming": true,
|
|
"streaming_interval_ms": 750
|
|
}),
|
|
)
|
|
.await,
|
|
"update_dictation_settings valid",
|
|
);
|
|
let dictation = rpc(
|
|
&harness.rpc_base,
|
|
11_012,
|
|
"openhuman.config_get_dictation_settings",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let dictation_payload = payload(&dictation, "get_dictation_settings");
|
|
assert_eq!(
|
|
dictation_payload
|
|
.get("activation_mode")
|
|
.and_then(Value::as_str),
|
|
Some("toggle")
|
|
);
|
|
assert_eq!(
|
|
dictation_payload
|
|
.get("streaming_interval_ms")
|
|
.and_then(Value::as_u64),
|
|
Some(750)
|
|
);
|
|
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_013,
|
|
"openhuman.config_update_dictation_settings",
|
|
json!({ "activation_mode": "hold" }),
|
|
)
|
|
.await,
|
|
"update_dictation_settings invalid activation",
|
|
"invalid activation_mode",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_014,
|
|
"openhuman.config_update_voice_server_settings",
|
|
json!({ "activation_mode": "hold" }),
|
|
)
|
|
.await,
|
|
"update_voice_server_settings invalid activation",
|
|
"invalid activation_mode",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_015,
|
|
"openhuman.config_update_search_settings",
|
|
json!({ "engine": "bing" }),
|
|
)
|
|
.await,
|
|
"update_search_settings invalid engine",
|
|
"engine must be one of",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_016,
|
|
"openhuman.config_update_search_settings",
|
|
json!({ "max_results": 0 }),
|
|
)
|
|
.await,
|
|
"update_search_settings invalid max_results",
|
|
"max_results must be between",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_024,
|
|
"openhuman.config_update_search_settings",
|
|
json!({ "timeout_secs": 0 }),
|
|
)
|
|
.await,
|
|
"update_search_settings invalid timeout_secs",
|
|
"timeout_secs must be between",
|
|
);
|
|
let valid_search = rpc(
|
|
&harness.rpc_base,
|
|
11_025,
|
|
"openhuman.config_update_search_settings",
|
|
json!({
|
|
"engine": " brave ",
|
|
"max_results": 12,
|
|
"timeout_secs": 42,
|
|
"parallel_api_key": " parallel-rpc-key ",
|
|
"brave_api_key": " brave-rpc-key ",
|
|
"querit_api_key": " querit-rpc-key ",
|
|
"allowed_domains": [" example.com ", "", "example.com", "docs.example.com"],
|
|
"allow_all": false
|
|
}),
|
|
)
|
|
.await;
|
|
let valid_search_payload = payload(&valid_search, "update_search_settings valid");
|
|
assert_eq!(
|
|
valid_search_payload.pointer("/config/search/engine"),
|
|
Some(&json!("brave"))
|
|
);
|
|
assert_eq!(
|
|
valid_search_payload.pointer("/config/search/max_results"),
|
|
Some(&json!(12))
|
|
);
|
|
assert_eq!(
|
|
valid_search_payload.pointer("/config/search/timeout_secs"),
|
|
Some(&json!(42))
|
|
);
|
|
let search_readback = rpc(
|
|
&harness.rpc_base,
|
|
11_026,
|
|
"openhuman.config_get_search_settings",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let search_payload = payload(&search_readback, "get_search_settings after valid update");
|
|
assert_eq!(
|
|
search_payload.get("engine").and_then(Value::as_str),
|
|
Some("brave")
|
|
);
|
|
assert_eq!(
|
|
search_payload
|
|
.get("effective_engine")
|
|
.and_then(Value::as_str),
|
|
Some("brave")
|
|
);
|
|
assert_eq!(
|
|
search_payload
|
|
.get("parallel_configured")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
search_payload
|
|
.get("brave_configured")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
search_payload
|
|
.get("querit_configured")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
search_payload.get("allow_all").and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
search_payload.get("allowed_domains"),
|
|
Some(&json!(["docs.example.com", "example.com"]))
|
|
);
|
|
let allow_all_search = rpc(
|
|
&harness.rpc_base,
|
|
11_027,
|
|
"openhuman.config_update_search_settings",
|
|
json!({
|
|
"parallel_api_key": " ",
|
|
"brave_api_key": " ",
|
|
"querit_api_key": " ",
|
|
"allow_all": true
|
|
}),
|
|
)
|
|
.await;
|
|
let allow_all_payload = payload(&allow_all_search, "update_search_settings allow_all");
|
|
assert_eq!(
|
|
allow_all_payload.pointer("/config/http_request/allowed_domains"),
|
|
Some(&json!(["*"]))
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_017,
|
|
"openhuman.config_update_autonomy_settings",
|
|
json!({ "level": "reckless" }),
|
|
)
|
|
.await,
|
|
"update_autonomy_settings invalid level",
|
|
"invalid autonomy level",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_018,
|
|
"openhuman.config_update_model_settings",
|
|
json!({
|
|
"cloud_providers": [{
|
|
"slug": "",
|
|
"endpoint": "http://127.0.0.1:19999/v1",
|
|
"auth_style": "bearer"
|
|
}]
|
|
}),
|
|
)
|
|
.await,
|
|
"update_model_settings empty cloud provider slug",
|
|
"cloud provider slug must not be empty",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_022,
|
|
"openhuman.config_update_model_settings",
|
|
json!({
|
|
"cloud_providers": [{
|
|
"slug": "worker-a-invalid-auth-style",
|
|
"label": "Invalid Auth Style",
|
|
"endpoint": "http://127.0.0.1:19999/v1",
|
|
"auth_style": "magic"
|
|
}]
|
|
}),
|
|
)
|
|
.await,
|
|
"update_model_settings invalid cloud provider auth_style",
|
|
"unknown auth_style",
|
|
);
|
|
let filtered_reserved = rpc(
|
|
&harness.rpc_base,
|
|
11_023,
|
|
"openhuman.config_update_model_settings",
|
|
json!({
|
|
"cloud_providers": [
|
|
{
|
|
"slug": "openhuman",
|
|
"label": "Reserved OpenHuman",
|
|
"endpoint": "https://api.openhuman.ai/v1",
|
|
"auth_style": "openhuman_jwt"
|
|
},
|
|
{
|
|
"slug": "worker-a-valid-cloud",
|
|
"label": "Worker A Valid Cloud",
|
|
"endpoint": "http://127.0.0.1:19999/v1",
|
|
"auth_style": "none"
|
|
}
|
|
]
|
|
}),
|
|
)
|
|
.await;
|
|
let filtered_payload = payload(
|
|
&filtered_reserved,
|
|
"update_model_settings filters reserved cloud provider",
|
|
);
|
|
let cloud_providers = filtered_payload
|
|
.pointer("/config/cloud_providers")
|
|
.and_then(Value::as_array)
|
|
.expect("config snapshot should expose cloud_providers");
|
|
assert!(
|
|
cloud_providers
|
|
.iter()
|
|
.any(|provider| provider.get("slug").and_then(Value::as_str)
|
|
== Some("worker-a-valid-cloud")),
|
|
"valid cloud provider should survive reserved filtering: {filtered_payload}"
|
|
);
|
|
assert_eq!(
|
|
cloud_providers
|
|
.iter()
|
|
.filter(|provider| provider.get("slug").and_then(Value::as_str) == Some("openhuman"))
|
|
.count(),
|
|
1,
|
|
"reserved cloud providers already in config should be preserved once, not duplicated by echoed client payloads: {filtered_payload}"
|
|
);
|
|
|
|
ok(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_019,
|
|
"openhuman.config_update_screen_intelligence_settings",
|
|
json!({ "baseline_fps": 99.0 }),
|
|
)
|
|
.await,
|
|
"update_screen_intelligence_settings clamps baseline",
|
|
);
|
|
ok(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
11_020,
|
|
"openhuman.config_update_voice_server_settings",
|
|
json!({
|
|
"min_duration_secs": -1.0,
|
|
"silence_threshold": -0.5
|
|
}),
|
|
)
|
|
.await,
|
|
"update_voice_server_settings clamps non-negative floats",
|
|
);
|
|
let config = rpc(&harness.rpc_base, 11_021, "openhuman.config_get", json!({})).await;
|
|
let config_payload = payload(&config, "config_get after clamps");
|
|
assert_eq!(
|
|
config_payload.pointer("/config/screen_intelligence/baseline_fps"),
|
|
Some(&json!(30.0))
|
|
);
|
|
assert_eq!(
|
|
config_payload.pointer("/config/voice_server/min_duration_secs"),
|
|
Some(&json!(0.0))
|
|
);
|
|
assert_eq!(
|
|
config_payload.pointer("/config/voice_server/silence_threshold"),
|
|
Some(&json!(0.0))
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_auto_approve_public_helper_persists_once_and_is_idempotent() {
|
|
let _lock = env_lock();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().join("home");
|
|
let _guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", &home),
|
|
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
|
|
EnvVarGuard::unset(APP_ENV_VAR),
|
|
EnvVarGuard::unset(VITE_APP_ENV_VAR),
|
|
];
|
|
|
|
openhuman_core::openhuman::config::add_auto_approve_tool("tool.config.round10")
|
|
.await
|
|
.expect("add auto approve tool");
|
|
openhuman_core::openhuman::config::add_auto_approve_tool("tool.config.round10")
|
|
.await
|
|
.expect("idempotent auto approve tool");
|
|
|
|
let loaded = Config::load_or_init()
|
|
.await
|
|
.expect("load config after auto approve helper");
|
|
assert_eq!(
|
|
loaded
|
|
.autonomy
|
|
.auto_approve
|
|
.iter()
|
|
.filter(|tool| tool.as_str() == "tool.config.round10")
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_credentials_controller_paths_round_trip_and_validate_errors() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let state = rpc(
|
|
&harness.rpc_base,
|
|
20_001,
|
|
"openhuman.auth_get_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&state, "auth_get_state")
|
|
.get("isAuthenticated")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
let token = rpc(
|
|
&harness.rpc_base,
|
|
20_002,
|
|
"openhuman.auth_get_session_token",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&token, "auth_get_session_token")
|
|
.get("token")
|
|
.is_some(),
|
|
"session token read should return a token field even when empty: {token}"
|
|
);
|
|
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_003,
|
|
"openhuman.auth_get_me",
|
|
json!({}),
|
|
)
|
|
.await,
|
|
"auth_get_me without session",
|
|
"session JWT required",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_004,
|
|
"openhuman.auth_consume_login_token",
|
|
json!({ "loginToken": "" }),
|
|
)
|
|
.await,
|
|
"auth_consume_login_token empty",
|
|
"loginToken is required",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_005,
|
|
"openhuman.auth_create_channel_link_token",
|
|
json!({ "channel": "mastodon" }),
|
|
)
|
|
.await,
|
|
"auth_create_channel_link_token unsupported",
|
|
"unsupported channel",
|
|
);
|
|
|
|
for (id, method, params, needle) in [
|
|
(
|
|
20_006,
|
|
"openhuman.auth_oauth_connect",
|
|
json!({ "provider": "github" }),
|
|
"session JWT required",
|
|
),
|
|
(
|
|
20_007,
|
|
"openhuman.auth_oauth_list_integrations",
|
|
json!({}),
|
|
"session JWT required",
|
|
),
|
|
(
|
|
20_008,
|
|
"openhuman.auth_oauth_fetch_integration_tokens",
|
|
json!({ "integrationId": "abc", "key": "secret" }),
|
|
"session JWT required",
|
|
),
|
|
(
|
|
20_009,
|
|
"openhuman.auth_oauth_fetch_client_key",
|
|
json!({ "integrationId": "abc" }),
|
|
"session JWT required",
|
|
),
|
|
(
|
|
20_010,
|
|
"openhuman.auth_oauth_revoke_integration",
|
|
json!({ "integrationId": "abc" }),
|
|
"session JWT required",
|
|
),
|
|
] {
|
|
let response = rpc(&harness.rpc_base, id, method, params).await;
|
|
assert_error_contains(&response, method, needle);
|
|
}
|
|
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_011,
|
|
"openhuman.auth_store_provider_credentials",
|
|
json!({ "provider": " " }),
|
|
)
|
|
.await,
|
|
"auth_store_provider_credentials empty provider",
|
|
"provider is required",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_012,
|
|
"openhuman.auth_store_provider_credentials",
|
|
json!({ "provider": "worker-a", "fields": "not-an-object" }),
|
|
)
|
|
.await,
|
|
"auth_store_provider_credentials invalid fields",
|
|
"fields must be a JSON object",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_019,
|
|
"openhuman.auth_store_provider_credentials",
|
|
json!({ "provider": "worker-a-empty" }),
|
|
)
|
|
.await,
|
|
"auth_store_provider_credentials missing credential material",
|
|
"provide at least one credential",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
20_020,
|
|
"openhuman.auth_store_session",
|
|
json!({ "token": "header.payload.local" }),
|
|
)
|
|
.await,
|
|
"auth_store_session local token without user",
|
|
"local session requires a user payload",
|
|
);
|
|
|
|
let stored_provider = rpc(
|
|
&harness.rpc_base,
|
|
20_013,
|
|
"openhuman.auth_store_provider_credentials",
|
|
json!({
|
|
"provider": "worker-a",
|
|
"profile": "secondary",
|
|
"token": "provider-secret",
|
|
"fields": { "region": "test" },
|
|
"setActive": false
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&stored_provider, "auth_store_provider_credentials")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("worker-a")
|
|
);
|
|
|
|
let listed = rpc(
|
|
&harness.rpc_base,
|
|
20_014,
|
|
"openhuman.auth_list_provider_credentials",
|
|
json!({ "provider": "worker-a" }),
|
|
)
|
|
.await;
|
|
let listed_payload = payload(&listed, "auth_list_provider_credentials");
|
|
assert!(
|
|
listed_payload
|
|
.as_array()
|
|
.expect("credentials list")
|
|
.iter()
|
|
.any(|profile| profile.get("profileName").and_then(Value::as_str) == Some("secondary")),
|
|
"stored provider profile should be listed: {listed_payload}"
|
|
);
|
|
|
|
let listed_without_filter = rpc(
|
|
&harness.rpc_base,
|
|
20_021,
|
|
"openhuman.auth_list_provider_credentials",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(
|
|
&listed_without_filter,
|
|
"auth_list_provider_credentials default params"
|
|
)
|
|
.as_array()
|
|
.expect("unfiltered credentials list")
|
|
.iter()
|
|
.any(|profile| profile.get("provider").and_then(Value::as_str) == Some("worker-a")),
|
|
"unfiltered credentials list should include stored provider: {listed_without_filter}"
|
|
);
|
|
|
|
let removed = rpc(
|
|
&harness.rpc_base,
|
|
20_015,
|
|
"openhuman.auth_remove_provider_credentials",
|
|
json!({ "provider": "worker-a", "profile": "secondary" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&removed, "auth_remove_provider_credentials")
|
|
.get("removed")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
20_016,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "header.payload.local",
|
|
"user": {
|
|
"id": "ignored",
|
|
"name": "Worker A",
|
|
"email": "worker-a@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let authed = rpc(
|
|
&harness.rpc_base,
|
|
20_017,
|
|
"openhuman.auth_get_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&authed, "auth_get_state")
|
|
.get("isAuthenticated")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let cleared = rpc(
|
|
&harness.rpc_base,
|
|
20_018,
|
|
"openhuman.auth_clear_session",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&cleared, "auth_clear_session")
|
|
.get("removed")
|
|
.and_then(Value::as_bool)
|
|
.is_some(),
|
|
"clear session should return removal status: {cleared}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_local_session_normalizes_user_and_app_state_snapshot_uses_stored_identity() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
21_001,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "header.payload.local",
|
|
"user": {
|
|
"id": "renderer-supplied-id",
|
|
"name": "Local Worker",
|
|
"email": "local-worker@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session local")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let state = rpc(
|
|
&harness.rpc_base,
|
|
21_002,
|
|
"openhuman.auth_get_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let state_payload = payload(&state, "auth_get_state after local session");
|
|
assert_eq!(
|
|
state_payload
|
|
.get("isAuthenticated")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
let user_id = state_payload
|
|
.get("userId")
|
|
.and_then(Value::as_str)
|
|
.expect("local session should set userId");
|
|
assert!(
|
|
user_id.starts_with("local-"),
|
|
"local session user id should be host-scoped, got {user_id:?}"
|
|
);
|
|
assert_eq!(
|
|
state_payload.pointer("/user/id").and_then(Value::as_str),
|
|
Some(user_id)
|
|
);
|
|
assert_eq!(
|
|
state_payload.pointer("/user/_id").and_then(Value::as_str),
|
|
Some(user_id)
|
|
);
|
|
|
|
let token = rpc(
|
|
&harness.rpc_base,
|
|
21_003,
|
|
"openhuman.auth_get_session_token",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&token, "auth_get_session_token after local session")
|
|
.get("token")
|
|
.and_then(Value::as_str),
|
|
Some("header.payload.local")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
21_004,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let snapshot_payload = payload(&snapshot, "app_state_snapshot local session");
|
|
assert_eq!(
|
|
snapshot_payload.get("sessionToken").and_then(Value::as_str),
|
|
Some("header.payload.local")
|
|
);
|
|
assert_eq!(
|
|
snapshot_payload
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("local-worker@example.test")
|
|
);
|
|
|
|
let cleared = rpc(
|
|
&harness.rpc_base,
|
|
21_005,
|
|
"openhuman.auth_clear_session",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&cleared, "auth_clear_session after local session")
|
|
.get("removed")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_remote_backend_paths_and_app_state_current_user_cache_round_trip() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_mock_backend().await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_001,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "remote-jwt",
|
|
"user_id": "remote-user-1",
|
|
"user": {
|
|
"id": "stale-renderer-user",
|
|
"name": "Renderer Cache",
|
|
"email": "renderer-cache@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session remote")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"store_session should validate the JWT once"
|
|
);
|
|
|
|
let me = rpc(
|
|
&harness.rpc_base,
|
|
22_002,
|
|
"openhuman.auth_get_me",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&me, "auth_get_me remote")
|
|
.get("email")
|
|
.and_then(Value::as_str),
|
|
Some("remote-worker@example.test")
|
|
);
|
|
|
|
let consumed = rpc(
|
|
&harness.rpc_base,
|
|
22_003,
|
|
"openhuman.auth_consume_login_token",
|
|
json!({ "loginToken": "telegram-login-token" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&consumed, "auth_consume_login_token remote")
|
|
.get("jwtToken")
|
|
.and_then(Value::as_str),
|
|
Some("jwt-from-telegram-login-token")
|
|
);
|
|
|
|
let link = rpc(
|
|
&harness.rpc_base,
|
|
22_004,
|
|
"openhuman.auth_create_channel_link_token",
|
|
json!({ "channel": " Telegram " }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&link, "auth_create_channel_link_token remote")
|
|
.get("linkToken")
|
|
.and_then(Value::as_str),
|
|
Some("link-token-123")
|
|
);
|
|
|
|
let integrations = rpc(
|
|
&harness.rpc_base,
|
|
22_005,
|
|
"openhuman.auth_oauth_list_integrations",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&integrations, "auth_oauth_list_integrations remote")
|
|
.pointer("/0/provider")
|
|
.and_then(Value::as_str),
|
|
Some("github")
|
|
);
|
|
|
|
let oauth_connect = rpc(
|
|
&harness.rpc_base,
|
|
22_011,
|
|
"openhuman.auth_oauth_connect",
|
|
json!({
|
|
"provider": "github",
|
|
"skillId": "worker-a-skill",
|
|
"responseType": "code",
|
|
"encryptionMode": "handoff"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&oauth_connect, "auth_oauth_connect remote")
|
|
.get("state")
|
|
.and_then(Value::as_str),
|
|
Some("worker-a-state")
|
|
);
|
|
assert_eq!(
|
|
payload(&oauth_connect, "auth_oauth_connect remote")
|
|
.get("oauthUrl")
|
|
.and_then(Value::as_str),
|
|
Some("https://github.example.test/oauth?state=worker-a-state")
|
|
);
|
|
|
|
let integration_tokens = rpc(
|
|
&harness.rpc_base,
|
|
22_012,
|
|
"openhuman.auth_oauth_fetch_integration_tokens",
|
|
json!({
|
|
"integrationId": "0123456789abcdef01234567",
|
|
"key": "0123456789abcdef0123456789abcdef"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(
|
|
&integration_tokens,
|
|
"auth_oauth_fetch_integration_tokens remote"
|
|
)
|
|
.get("accessToken")
|
|
.and_then(Value::as_str),
|
|
Some("gh-access-token")
|
|
);
|
|
assert_eq!(
|
|
payload(
|
|
&integration_tokens,
|
|
"auth_oauth_fetch_integration_tokens remote"
|
|
)
|
|
.get("refreshToken")
|
|
.and_then(Value::as_str),
|
|
Some("gh-refresh-token")
|
|
);
|
|
|
|
let client_key = rpc(
|
|
&harness.rpc_base,
|
|
22_006,
|
|
"openhuman.auth_oauth_fetch_client_key",
|
|
json!({ "integrationId": "0123456789abcdef01234567" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&client_key, "auth_oauth_fetch_client_key remote")
|
|
.get("clientKey")
|
|
.and_then(Value::as_str),
|
|
Some("client-key-share")
|
|
);
|
|
|
|
let revoked = rpc(
|
|
&harness.rpc_base,
|
|
22_007,
|
|
"openhuman.auth_oauth_revoke_integration",
|
|
json!({ "integrationId": "0123456789abcdef01234567" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&revoked, "auth_oauth_revoke_integration remote")
|
|
.get("revoked")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
22_008,
|
|
"openhuman.auth_oauth_fetch_integration_tokens",
|
|
json!({ "integrationId": "short", "key": "secret" }),
|
|
)
|
|
.await,
|
|
"auth_oauth_fetch_integration_tokens invalid id with session",
|
|
"integrationId must be a 24-char hex id",
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_009,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "app_state_snapshot remote")
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("remote-worker@example.test")
|
|
);
|
|
let hits_after_first_snapshot = backend_state.auth_me_hits.load(Ordering::SeqCst);
|
|
assert!(
|
|
hits_after_first_snapshot >= 3,
|
|
"store_session, auth_get_me, and snapshot should all touch /auth/me at least once"
|
|
);
|
|
|
|
let cached_snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_010,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&cached_snapshot, "app_state_snapshot remote cached")
|
|
.pointer("/currentUser/name")
|
|
.and_then(Value::as_str),
|
|
Some("Remote Worker")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
hits_after_first_snapshot,
|
|
"the second snapshot should reuse the current-user cache"
|
|
);
|
|
|
|
let identity = openhuman_core::openhuman::app_state::peek_cached_current_user_identity()
|
|
.expect("snapshot should seed cached identity");
|
|
assert_eq!(identity.id.as_deref(), Some("remote-user-1"));
|
|
assert_eq!(identity.name.as_deref(), Some("Remote Worker"));
|
|
assert_eq!(
|
|
identity.email.as_deref(),
|
|
Some("remote-worker@example.test")
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_remote_backend_path_prefix_is_preserved_for_app_state_refresh() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_mock_backend().await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &format!("{backend_base}/api"));
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_051,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "remote-path-prefix-jwt",
|
|
"user_id": "remote-user-1",
|
|
"user": {
|
|
"id": "stale-path-prefix-user",
|
|
"name": "Path Prefix Renderer",
|
|
"email": "path-prefix-renderer@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session with backend path prefix")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_052,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "app_state_snapshot with backend path prefix")
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("remote-worker@example.test"),
|
|
"app_state should join auth/me below the configured backend path prefix"
|
|
);
|
|
assert!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst) >= 2,
|
|
"store_session and app_state snapshot should both hit the prefixed backend"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_clears_empty_current_user_cache_and_falls_back_to_stored_user() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_sequence_auth_backend().await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_101,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "sequence-remote-jwt",
|
|
"user_id": "stored-sequence-user",
|
|
"user": {
|
|
"id": "stored-sequence-user",
|
|
"name": "Stored Sequence Worker",
|
|
"email": "stored-sequence@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session sequence")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"store_session should validate the sequence JWT once"
|
|
);
|
|
|
|
let empty_user_snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_102,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
2,
|
|
"first snapshot should refresh and receive the empty user payload"
|
|
);
|
|
assert_eq!(
|
|
payload(&empty_user_snapshot, "empty current-user snapshot")
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("stored-sequence@example.test"),
|
|
"empty backend users should clear the cache and fall back to stored identity"
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::app_state::peek_cached_current_user_identity().is_none(),
|
|
"empty backend user should clear the process current-user cache"
|
|
);
|
|
|
|
let failed_user_snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_103,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
3,
|
|
"second snapshot should retry after the empty-user cache clear"
|
|
);
|
|
assert_eq!(
|
|
payload(&failed_user_snapshot, "failed current-user snapshot")
|
|
.pointer("/currentUser/name")
|
|
.and_then(Value::as_str),
|
|
Some("Stored Sequence Worker"),
|
|
"failed backend user fetches should preserve stored session identity"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_falls_back_to_stored_user_when_current_user_refresh_errors() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_static_auth_backend(json!({
|
|
"id": "refresh-error-user",
|
|
"name": "Refresh Error Worker",
|
|
"email": "refresh-error@example.test"
|
|
}))
|
|
.await;
|
|
let harness = setup().await;
|
|
let backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_121,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "refresh-error-remote-jwt",
|
|
"user_id": "stored-refresh-error-user",
|
|
"user": {
|
|
"id": "stored-refresh-error-user",
|
|
"name": "Stored Refresh Error Worker",
|
|
"email": "stored-refresh-error@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session refresh-error")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"store_session should validate the remote JWT once"
|
|
);
|
|
|
|
drop(backend_guard);
|
|
let _broken_backend = EnvVarGuard::set("BACKEND_URL", "http://127.0.0.1:1");
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_122,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "app_state_snapshot refresh-error")
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("stored-refresh-error@example.test"),
|
|
"backend refresh transport failures should preserve stored session identity"
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"snapshot should use the broken backend URL instead of hitting the original backend"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_clears_null_current_user_cache_and_falls_back_to_stored_user() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_null_auth_backend().await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_151,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "null-sequence-remote-jwt",
|
|
"user_id": "stored-null-sequence-user",
|
|
"user": {
|
|
"id": "stored-null-sequence-user",
|
|
"name": "Stored Null Sequence Worker",
|
|
"email": "stored-null-sequence@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session null sequence")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
1,
|
|
"store_session should validate the null-sequence JWT once"
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_152,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
2,
|
|
"snapshot should refresh and receive the null user payload"
|
|
);
|
|
assert_eq!(
|
|
payload(&snapshot, "null current-user snapshot")
|
|
.pointer("/currentUser/email")
|
|
.and_then(Value::as_str),
|
|
Some("stored-null-sequence@example.test"),
|
|
"null backend users should clear the cache and fall back to stored identity"
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::app_state::peek_cached_current_user_identity().is_none(),
|
|
"null backend user should clear the process current-user cache"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_cached_identity_peek_accepts_legacy_current_user_fields() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_static_auth_backend(json!({
|
|
"user_id": "legacy-user-id",
|
|
"displayName": "Legacy Display",
|
|
"email": "legacy-display@example.test"
|
|
}))
|
|
.await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_201,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "legacy-field-remote-jwt",
|
|
"user_id": "stored-legacy-user",
|
|
"user": {
|
|
"id": "stored-legacy-user",
|
|
"name": "Stored Legacy Worker",
|
|
"email": "stored-legacy@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session legacy fields")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_202,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "legacy-field current-user snapshot")
|
|
.pointer("/currentUser/user_id")
|
|
.and_then(Value::as_str),
|
|
Some("legacy-user-id")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
2,
|
|
"store_session and snapshot should each fetch the static backend once"
|
|
);
|
|
let identity = openhuman_core::openhuman::app_state::peek_cached_current_user_identity()
|
|
.expect("legacy current-user keys should produce a prompt identity");
|
|
assert_eq!(identity.id.as_deref(), Some("legacy-user-id"));
|
|
assert_eq!(identity.name.as_deref(), Some("Legacy Display"));
|
|
assert_eq!(
|
|
identity.email.as_deref(),
|
|
Some("legacy-display@example.test")
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_cached_identity_peek_accepts_camel_case_fallback_fields() {
|
|
let _lock = env_lock();
|
|
let (backend_base, _backend_state, backend_join) = serve_static_auth_backend(json!({
|
|
"userId": "camel-user-id",
|
|
"fullName": "Camel Full Name"
|
|
}))
|
|
.await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_301,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "camel-field-remote-jwt",
|
|
"user_id": "stored-camel-user",
|
|
"user": {
|
|
"id": "stored-camel-user",
|
|
"name": "Stored Camel Worker",
|
|
"email": "stored-camel@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session camel fields")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_302,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "camel-field current-user snapshot")
|
|
.pointer("/currentUser/userId")
|
|
.and_then(Value::as_str),
|
|
Some("camel-user-id")
|
|
);
|
|
let identity = openhuman_core::openhuman::app_state::peek_cached_current_user_identity()
|
|
.expect("camel-case current-user keys should produce a prompt identity");
|
|
assert_eq!(identity.id.as_deref(), Some("camel-user-id"));
|
|
assert_eq!(identity.name.as_deref(), Some("Camel Full Name"));
|
|
assert_eq!(identity.email, None);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_cached_identity_peek_ignores_current_user_without_identity_fields() {
|
|
let _lock = env_lock();
|
|
let (backend_base, backend_state, backend_join) = serve_static_auth_backend(json!({
|
|
"metadata": "present-but-not-identity"
|
|
}))
|
|
.await;
|
|
let harness = setup().await;
|
|
let _backend_guard = EnvVarGuard::set("BACKEND_URL", &backend_base);
|
|
|
|
let session = rpc(
|
|
&harness.rpc_base,
|
|
22_351,
|
|
"openhuman.auth_store_session",
|
|
json!({
|
|
"token": "identity-empty-remote-jwt",
|
|
"user_id": "stored-empty-identity-user",
|
|
"user": {
|
|
"id": "stored-empty-identity-user",
|
|
"name": "Stored Empty Identity Worker",
|
|
"email": "stored-empty-identity@example.test"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&session, "auth_store_session identity-empty")
|
|
.get("provider")
|
|
.and_then(Value::as_str),
|
|
Some("app-session")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
22_352,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&snapshot, "identity-empty current-user snapshot")
|
|
.pointer("/currentUser/metadata")
|
|
.and_then(Value::as_str),
|
|
Some("present-but-not-identity")
|
|
);
|
|
assert_eq!(
|
|
backend_state.auth_me_hits.load(Ordering::SeqCst),
|
|
2,
|
|
"store_session and snapshot should each fetch the no-identity backend once"
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::app_state::peek_cached_current_user_identity().is_none(),
|
|
"current-user objects without id/name/email should not produce prompt identity"
|
|
);
|
|
|
|
harness.join.abort();
|
|
backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_update_persists_and_snapshot_reads_local_state() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let updated = rpc(
|
|
&harness.rpc_base,
|
|
30_001,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({
|
|
"encryptionKey": "worker-a-key",
|
|
"onboardingTasks": {
|
|
"accessibilityPermissionGranted": true,
|
|
"localModelConsentGiven": true,
|
|
"localModelDownloadStarted": false,
|
|
"enabledTools": ["memory.search", "tools.web_search"],
|
|
"connectedSources": ["gmail"],
|
|
"updatedAtMs": 123456
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&updated, "app_state_update_local_state")
|
|
.get("encryptionKey")
|
|
.and_then(Value::as_str),
|
|
Some("worker-a-key")
|
|
);
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
30_002,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let local_state = payload(&snapshot, "app_state_snapshot")
|
|
.get("localState")
|
|
.unwrap_or_else(|| panic!("snapshot should include localState: {snapshot}"));
|
|
assert_eq!(
|
|
local_state.get("encryptionKey").and_then(Value::as_str),
|
|
Some("worker-a-key")
|
|
);
|
|
assert_eq!(
|
|
local_state.pointer("/onboardingTasks/enabledTools/0"),
|
|
Some(&json!("memory.search"))
|
|
);
|
|
|
|
let cleared = rpc(
|
|
&harness.rpc_base,
|
|
30_003,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({
|
|
"encryptionKey": null,
|
|
"onboardingTasks": null
|
|
}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&cleared, "app_state_update_local_state")
|
|
.get("encryptionKey")
|
|
.is_none(),
|
|
"null patch should clear optional encryption key: {cleared}"
|
|
);
|
|
|
|
let blank_cleared = rpc(
|
|
&harness.rpc_base,
|
|
30_004,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({ "encryptionKey": " " }),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&blank_cleared, "app_state_update_local_state blank key")
|
|
.get("encryptionKey")
|
|
.is_none(),
|
|
"blank encryption key should also clear the optional value: {blank_cleared}"
|
|
);
|
|
|
|
let invalid_patch = rpc(
|
|
&harness.rpc_base,
|
|
30_005,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({ "onboardingTasks": "not-an-object" }),
|
|
)
|
|
.await;
|
|
assert_error_contains(
|
|
&invalid_patch,
|
|
"app_state_update_local_state invalid onboardingTasks",
|
|
"invalid params",
|
|
);
|
|
|
|
let unchanged = rpc(
|
|
&harness.rpc_base,
|
|
30_006,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
payload(&unchanged, "app_state_update_local_state empty patch")
|
|
.get("encryptionKey")
|
|
.is_none(),
|
|
"empty patch should preserve the already-cleared encryption key: {unchanged}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_degrades_runtime_service_status_failures() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
let service_state_path = harness.home.join("service-status-failure.json");
|
|
std::fs::write(
|
|
&service_state_path,
|
|
serde_json::to_vec_pretty(&json!({
|
|
"installed": true,
|
|
"running": true,
|
|
"agent_running": true,
|
|
"failures": {
|
|
"status": "forced status failure from app_state test"
|
|
}
|
|
}))
|
|
.expect("serialize service mock state"),
|
|
)
|
|
.expect("write service mock state");
|
|
let _service_mock = EnvVarGuard::set("OPENHUMAN_SERVICE_MOCK", "1");
|
|
let _service_state =
|
|
EnvVarGuard::set_to_path("OPENHUMAN_SERVICE_MOCK_STATE_FILE", &service_state_path);
|
|
|
|
// The runtime snapshot cache is process-global and not keyed by config.
|
|
// Let prior app_state_snapshot tests age out so this call exercises the
|
|
// service-status fallback instead of returning a cached runtime.
|
|
tokio::time::sleep(Duration::from_millis(2_100)).await;
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
30_051,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let service = payload(&snapshot, "app_state_snapshot with service status failure")
|
|
.pointer("/runtime/service")
|
|
.expect("snapshot should include runtime service status");
|
|
assert!(
|
|
service
|
|
.pointer("/state/Unknown")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|message| message.contains("forced status failure")),
|
|
"service status failures should degrade to Unknown state: {service}"
|
|
);
|
|
assert_eq!(
|
|
service.get("label").and_then(Value::as_str),
|
|
Some("OpenHuman")
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_and_update_surface_state_dir_creation_errors() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let config = rpc(&harness.rpc_base, 30_101, "openhuman.config_get", json!({})).await;
|
|
let workspace_dir = payload(&config, "config_get for app_state state-dir error")
|
|
.get("workspace_dir")
|
|
.and_then(Value::as_str)
|
|
.map(PathBuf::from)
|
|
.expect("config_get should expose workspace_dir");
|
|
let state_path = workspace_dir.join("state");
|
|
std::fs::create_dir_all(&workspace_dir).expect("create workspace dir");
|
|
std::fs::write(&state_path, "not a directory").expect("write state path as file");
|
|
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
30_102,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await,
|
|
"app_state_snapshot with file at state path",
|
|
"failed to create workspace state dir",
|
|
);
|
|
assert_error_contains(
|
|
&rpc(
|
|
&harness.rpc_base,
|
|
30_103,
|
|
"openhuman.app_state_update_local_state",
|
|
json!({ "encryptionKey": "cannot-save" }),
|
|
)
|
|
.await,
|
|
"app_state_update_local_state with file at state path",
|
|
"failed to create workspace state dir",
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_keeps_unquarantinable_local_state_path_but_uses_defaults() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let config = rpc(&harness.rpc_base, 31_151, "openhuman.config_get", json!({})).await;
|
|
let workspace_dir = payload(&config, "config_get for unquarantinable app_state path")
|
|
.get("workspace_dir")
|
|
.and_then(Value::as_str)
|
|
.map(PathBuf::from)
|
|
.expect("config_get should expose workspace_dir");
|
|
let state_dir = workspace_dir.join("state");
|
|
std::fs::create_dir_all(&state_dir).expect("create state dir");
|
|
let app_state_path = state_dir.join("app-state.json");
|
|
std::fs::create_dir(&app_state_path).expect("create app-state directory");
|
|
|
|
let original_permissions = std::fs::metadata(&state_dir)
|
|
.expect("state dir metadata")
|
|
.permissions();
|
|
let mut read_only_permissions = original_permissions.clone();
|
|
read_only_permissions.set_mode(0o500);
|
|
std::fs::set_permissions(&state_dir, read_only_permissions).expect("make state dir unwritable");
|
|
|
|
// Unix permission bits only block writes for non-root users. CI containers
|
|
// frequently run as root, where 0o500 does NOT prevent rename/removal — the
|
|
// quarantine would succeed and this test's precondition (an *unquarantinable*
|
|
// path) can't be established. Probe whether the mode is actually enforced;
|
|
// if writes still succeed, skip rather than assert a guarantee the OS isn't
|
|
// providing.
|
|
let probe = state_dir.join(".perm-probe");
|
|
if std::fs::write(&probe, b"x").is_ok() {
|
|
let _ = std::fs::remove_file(&probe);
|
|
std::fs::set_permissions(&state_dir, original_permissions)
|
|
.expect("restore state dir permissions");
|
|
eprintln!(
|
|
"[skip] app_state_snapshot_keeps_unquarantinable_local_state_path_but_uses_defaults: \
|
|
filesystem permissions not enforced (running as root?); cannot make state dir unwritable"
|
|
);
|
|
harness.join.abort();
|
|
return;
|
|
}
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
31_152,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
|
|
std::fs::set_permissions(&state_dir, original_permissions)
|
|
.expect("restore state dir permissions");
|
|
|
|
let local_state = payload(
|
|
&snapshot,
|
|
"app_state_snapshot after failed quarantine of unreadable path",
|
|
)
|
|
.get("localState")
|
|
.expect("snapshot should include localState");
|
|
assert!(
|
|
local_state.as_object().is_some_and(|map| map.is_empty()),
|
|
"unquarantinable app state should still fall back to defaults: {local_state}"
|
|
);
|
|
assert!(
|
|
app_state_path.exists(),
|
|
"unwritable state dir should prevent quarantine rename/removal of the live path"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_quarantines_corrupted_local_state_file() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let config = rpc(&harness.rpc_base, 31_001, "openhuman.config_get", json!({})).await;
|
|
let workspace_dir = payload(&config, "config_get for app_state corruption")
|
|
.get("workspace_dir")
|
|
.and_then(Value::as_str)
|
|
.map(PathBuf::from)
|
|
.expect("config_get should expose workspace_dir");
|
|
let state_dir = workspace_dir.join("state");
|
|
std::fs::create_dir_all(&state_dir).expect("create state dir");
|
|
let app_state_path = state_dir.join("app-state.json");
|
|
std::fs::write(&app_state_path, "{ not valid json").expect("write corrupted app state");
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
31_002,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let local_state = payload(&snapshot, "app_state_snapshot after corrupt state")
|
|
.get("localState")
|
|
.expect("snapshot should include localState");
|
|
assert!(
|
|
local_state.as_object().is_some_and(|map| map.is_empty()),
|
|
"corrupted app state should fall back to defaults: {local_state}"
|
|
);
|
|
assert!(
|
|
!app_state_path.exists(),
|
|
"corrupted app state file should be moved out of the live path"
|
|
);
|
|
let quarantined = std::fs::read_dir(&state_dir)
|
|
.expect("read state dir")
|
|
.filter_map(Result::ok)
|
|
.any(|entry| {
|
|
entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("app-state.json.corrupted.")
|
|
});
|
|
assert!(
|
|
quarantined,
|
|
"corrupted app state file should be quarantined under {state_dir:?}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_state_snapshot_quarantines_unreadable_local_state_path() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let config = rpc(&harness.rpc_base, 31_101, "openhuman.config_get", json!({})).await;
|
|
let workspace_dir = payload(&config, "config_get for unreadable app_state path")
|
|
.get("workspace_dir")
|
|
.and_then(Value::as_str)
|
|
.map(PathBuf::from)
|
|
.expect("config_get should expose workspace_dir");
|
|
let state_dir = workspace_dir.join("state");
|
|
std::fs::create_dir_all(&state_dir).expect("create state dir");
|
|
let app_state_path = state_dir.join("app-state.json");
|
|
std::fs::create_dir(&app_state_path).expect("create unreadable app-state directory");
|
|
|
|
let snapshot = rpc(
|
|
&harness.rpc_base,
|
|
31_102,
|
|
"openhuman.app_state_snapshot",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let local_state = payload(&snapshot, "app_state_snapshot after unreadable state path")
|
|
.get("localState")
|
|
.expect("snapshot should include localState");
|
|
assert!(
|
|
local_state.as_object().is_some_and(|map| map.is_empty()),
|
|
"unreadable app state should fall back to defaults: {local_state}"
|
|
);
|
|
assert!(
|
|
!app_state_path.exists(),
|
|
"unreadable app state path should be moved out of the live path"
|
|
);
|
|
let quarantined = std::fs::read_dir(&state_dir)
|
|
.expect("read state dir")
|
|
.filter_map(Result::ok)
|
|
.any(|entry| {
|
|
entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("app-state.json.corrupted.")
|
|
&& entry.path().is_dir()
|
|
});
|
|
assert!(
|
|
quarantined,
|
|
"unreadable app state directory should be quarantined under {state_dir:?}"
|
|
);
|
|
|
|
harness.join.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_profile_store_public_api_persists_updates_and_recovers_bad_files() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let state_dir = tmp.path().join("profiles");
|
|
let store = AuthProfilesStore::new(&state_dir, true);
|
|
|
|
assert_eq!(store.path(), state_dir.join("auth-profiles.json"));
|
|
let empty = store.load().expect("empty store load");
|
|
assert!(empty.profiles.is_empty());
|
|
|
|
assert!(TokenSet {
|
|
access_token: "soon".to_string(),
|
|
refresh_token: None,
|
|
id_token: None,
|
|
expires_at: Some(chrono::Utc::now() + chrono::Duration::seconds(1)),
|
|
token_type: None,
|
|
scope: None,
|
|
}
|
|
.is_expiring_within(Duration::from_secs(5)));
|
|
assert!(!TokenSet {
|
|
access_token: "no-expiry".to_string(),
|
|
refresh_token: None,
|
|
id_token: None,
|
|
expires_at: None,
|
|
token_type: None,
|
|
scope: None,
|
|
}
|
|
.is_expiring_within(Duration::from_secs(5)));
|
|
|
|
let mut token_profile = AuthProfile::new_token("openai", "work", "sk-worker-a".to_string());
|
|
token_profile
|
|
.metadata
|
|
.insert("region".to_string(), "test".to_string());
|
|
let token_id = token_profile.id.clone();
|
|
store
|
|
.upsert_profile(token_profile, true)
|
|
.expect("upsert token profile");
|
|
|
|
let mut oauth_profile = AuthProfile::new_oauth(
|
|
"github",
|
|
"default",
|
|
TokenSet {
|
|
access_token: "gh-access".to_string(),
|
|
refresh_token: Some("gh-refresh".to_string()),
|
|
id_token: Some("gh-id".to_string()),
|
|
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
|
token_type: Some("Bearer".to_string()),
|
|
scope: Some("repo user".to_string()),
|
|
},
|
|
);
|
|
oauth_profile.account_id = Some("acct-1".to_string());
|
|
oauth_profile.workspace_id = Some("workspace-1".to_string());
|
|
let oauth_id = oauth_profile.id.clone();
|
|
store
|
|
.upsert_profile(oauth_profile, false)
|
|
.expect("upsert oauth profile");
|
|
|
|
let loaded = store.load().expect("load stored profiles");
|
|
assert_eq!(
|
|
loaded.active_profiles.get("openai").map(String::as_str),
|
|
Some(token_id.as_str())
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.profiles
|
|
.get(&token_id)
|
|
.and_then(|profile| profile.token.as_deref()),
|
|
Some("sk-worker-a")
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.profiles
|
|
.get(&oauth_id)
|
|
.and_then(|profile| profile.token_set.as_ref())
|
|
.map(|tokens| tokens.access_token.as_str()),
|
|
Some("gh-access")
|
|
);
|
|
|
|
store
|
|
.set_active_profile("github", &oauth_id)
|
|
.expect("set active oauth profile");
|
|
store
|
|
.clear_active_profile("openai")
|
|
.expect("clear active token profile");
|
|
let updated = store
|
|
.update_profile(&oauth_id, |profile| {
|
|
profile
|
|
.metadata
|
|
.insert("updated".to_string(), "true".to_string());
|
|
Ok(())
|
|
})
|
|
.expect("update oauth metadata");
|
|
assert_eq!(
|
|
updated.metadata.get("updated").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
|
|
assert!(
|
|
store
|
|
.remove_profile(&token_id)
|
|
.expect("remove existing token profile"),
|
|
"existing token profile should be removed"
|
|
);
|
|
assert!(
|
|
!store
|
|
.remove_profile(&token_id)
|
|
.expect("remove missing token profile"),
|
|
"missing token profile removal should be idempotent"
|
|
);
|
|
|
|
let after_remove = store.load().expect("load after remove");
|
|
assert!(!after_remove.profiles.contains_key(&token_id));
|
|
assert_eq!(
|
|
after_remove
|
|
.active_profiles
|
|
.get("github")
|
|
.map(String::as_str),
|
|
Some(oauth_id.as_str())
|
|
);
|
|
|
|
let corrupt_dir = tmp.path().join("corrupt");
|
|
std::fs::create_dir_all(&corrupt_dir).expect("create corrupt profile dir");
|
|
let corrupt_path = corrupt_dir.join("auth-profiles.json");
|
|
std::fs::write(&corrupt_path, "{ not valid json").expect("write corrupt profile store");
|
|
let corrupt_store = AuthProfilesStore::new(&corrupt_dir, true);
|
|
let recovered = corrupt_store
|
|
.load()
|
|
.expect("corrupt profile store recovers");
|
|
assert!(recovered.profiles.is_empty());
|
|
assert!(
|
|
!corrupt_path.exists(),
|
|
"corrupted auth profile store should be moved away"
|
|
);
|
|
assert!(
|
|
std::fs::read_dir(&corrupt_dir)
|
|
.expect("read corrupt dir")
|
|
.filter_map(Result::ok)
|
|
.any(|entry| entry.file_name().to_string_lossy().contains(".corrupt-")),
|
|
"corrupt auth profile store should leave a quarantine file"
|
|
);
|
|
|
|
let legacy_dir = tmp.path().join("legacy");
|
|
std::fs::create_dir_all(&legacy_dir).expect("create legacy profile dir");
|
|
std::fs::write(
|
|
legacy_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 0,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {},
|
|
"profiles": {}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write legacy profile store");
|
|
let legacy = AuthProfilesStore::new(&legacy_dir, true)
|
|
.load()
|
|
.expect("legacy schema 0 should normalize");
|
|
assert_eq!(legacy.schema_version, 1);
|
|
|
|
let future_dir = tmp.path().join("future");
|
|
std::fs::create_dir_all(&future_dir).expect("create future profile dir");
|
|
std::fs::write(
|
|
future_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 99,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {},
|
|
"profiles": {}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write future profile store");
|
|
let future_err = AuthProfilesStore::new(&future_dir, true)
|
|
.load()
|
|
.expect_err("future schema should fail");
|
|
assert!(
|
|
future_err
|
|
.to_string()
|
|
.contains("Unsupported auth profile schema version"),
|
|
"unexpected future schema error: {future_err:#}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_auth_service_selects_active_default_and_requested_profiles() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "disabled");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let service = AuthService::new(&tmp.path().join("auth-service"), false);
|
|
|
|
service
|
|
.store_provider_token(
|
|
"github",
|
|
"default",
|
|
"default-token",
|
|
Default::default(),
|
|
false,
|
|
)
|
|
.expect("store default github profile");
|
|
assert_eq!(
|
|
service
|
|
.get_provider_bearer_token("github", None)
|
|
.expect("get default provider token")
|
|
.as_deref(),
|
|
Some("default-token"),
|
|
"without an active profile, provider token lookup should fall back to default"
|
|
);
|
|
|
|
let oauth_profile = AuthProfile::new_oauth(
|
|
"github",
|
|
"work",
|
|
TokenSet {
|
|
access_token: "oauth-access".to_string(),
|
|
refresh_token: None,
|
|
id_token: None,
|
|
expires_at: None,
|
|
token_type: Some("Bearer".to_string()),
|
|
scope: Some("repo".to_string()),
|
|
},
|
|
);
|
|
let oauth_id = oauth_profile.id.clone();
|
|
service.load_profiles().expect("load before oauth upsert");
|
|
AuthProfilesStore::new(&tmp.path().join("auth-service"), false)
|
|
.upsert_profile(oauth_profile, false)
|
|
.expect("upsert oauth work profile");
|
|
assert_eq!(
|
|
service
|
|
.set_active_profile(" GITHUB ", "github:work")
|
|
.expect("set active by full profile id"),
|
|
oauth_id
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.get_provider_bearer_token("github", None)
|
|
.expect("get active oauth bearer")
|
|
.as_deref(),
|
|
Some("oauth-access"),
|
|
"active OAuth profiles should expose their access token as bearer material"
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.get_provider_bearer_token("github", Some("default"))
|
|
.expect("explicit default token")
|
|
.as_deref(),
|
|
Some("default-token"),
|
|
"explicit profile overrides should win over active profiles"
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.get_provider_bearer_token("github", Some("missing"))
|
|
.expect("missing explicit profile")
|
|
.as_deref(),
|
|
None
|
|
);
|
|
|
|
let slack_profile = AuthProfile::new_token("slack", "main", "slack-token".to_string());
|
|
let slack_id = slack_profile.id.clone();
|
|
AuthProfilesStore::new(&tmp.path().join("auth-service"), false)
|
|
.upsert_profile(slack_profile, false)
|
|
.expect("upsert slack profile");
|
|
let mismatch = service
|
|
.set_active_profile("github", &slack_id)
|
|
.expect_err("provider/profile mismatch should fail");
|
|
assert!(
|
|
mismatch.to_string().contains("belongs to provider slack"),
|
|
"unexpected provider mismatch error: {mismatch:#}"
|
|
);
|
|
|
|
assert!(service
|
|
.remove_profile("github", "github:work")
|
|
.expect("remove active oauth profile"));
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_profile_store_recovers_dropped_entries_empty_files_and_datetime_errors() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
|
|
let default_profiles =
|
|
openhuman_core::openhuman::credentials::profiles::AuthProfilesData::default();
|
|
assert_eq!(default_profiles.schema_version, 1);
|
|
assert!(default_profiles.profiles.is_empty());
|
|
|
|
let empty_path_store = AuthProfilesStore::new(Path::new(""), false);
|
|
assert_eq!(empty_path_store.path(), Path::new("auth-profiles.json"));
|
|
|
|
let empty_dir = tmp.path().join("empty-file");
|
|
std::fs::create_dir_all(&empty_dir).expect("create empty profile dir");
|
|
std::fs::write(empty_dir.join("auth-profiles.json"), "").expect("write empty profile file");
|
|
let empty = AuthProfilesStore::new(&empty_dir, false)
|
|
.load()
|
|
.expect("empty persisted profile file should load as default");
|
|
assert!(empty.profiles.is_empty());
|
|
assert!(empty.active_profiles.is_empty());
|
|
|
|
let mixed_dir = tmp.path().join("mixed");
|
|
std::fs::create_dir_all(&mixed_dir).expect("create mixed profile dir");
|
|
std::fs::write(
|
|
mixed_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"gitlab": "gitlab:main",
|
|
"legacy": "legacy:bad-kind"
|
|
},
|
|
"profiles": {
|
|
"gitlab:main": {
|
|
"provider": "gitlab",
|
|
"profile_name": "main",
|
|
"kind": "token",
|
|
"token": "plain-gitlab-token",
|
|
"created_at": "not-a-date",
|
|
"updated_at": "also-not-a-date",
|
|
"metadata": {
|
|
"origin": "fixture"
|
|
}
|
|
},
|
|
"legacy:bad-kind": {
|
|
"provider": "legacy",
|
|
"profile_name": "bad-kind",
|
|
"kind": "api_key",
|
|
"token": "drop-me",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write mixed profile fixture");
|
|
let mixed_store = AuthProfilesStore::new(&mixed_dir, false);
|
|
let mixed = mixed_store
|
|
.load()
|
|
.expect("mixed profile store should drop only bad entries");
|
|
assert_eq!(
|
|
mixed
|
|
.profiles
|
|
.get("gitlab:main")
|
|
.and_then(|profile| profile.token.as_deref()),
|
|
Some("plain-gitlab-token")
|
|
);
|
|
assert!(!mixed.profiles.contains_key("legacy:bad-kind"));
|
|
assert!(!mixed.active_profiles.contains_key("legacy"));
|
|
assert_eq!(
|
|
mixed.active_profiles.get("gitlab").map(String::as_str),
|
|
Some("gitlab:main")
|
|
);
|
|
let rewritten: Value = serde_json::from_str(
|
|
&std::fs::read_to_string(mixed_dir.join("auth-profiles.json"))
|
|
.expect("read rewritten mixed profile store"),
|
|
)
|
|
.expect("rewritten mixed store should be json");
|
|
assert!(
|
|
rewritten.pointer("/profiles/legacy:bad-kind").is_none(),
|
|
"dropped profile should be purged from persisted store: {rewritten}"
|
|
);
|
|
|
|
let invalid_datetime_dir = tmp.path().join("invalid-datetime");
|
|
std::fs::create_dir_all(&invalid_datetime_dir).expect("create invalid datetime dir");
|
|
std::fs::write(
|
|
invalid_datetime_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"github": "github:oauth"
|
|
},
|
|
"profiles": {
|
|
"github:oauth": {
|
|
"provider": "github",
|
|
"profile_name": "oauth",
|
|
"kind": "oauth",
|
|
"access_token": "plain-access",
|
|
"expires_at": "not-rfc3339",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write invalid datetime profile fixture");
|
|
let invalid_datetime_err = AuthProfilesStore::new(&invalid_datetime_dir, false)
|
|
.load()
|
|
.expect_err("invalid oauth expiry should fail profile load");
|
|
assert!(
|
|
invalid_datetime_err
|
|
.to_string()
|
|
.contains("Invalid RFC3339 timestamp"),
|
|
"unexpected invalid datetime error: {invalid_datetime_err:#}"
|
|
);
|
|
|
|
let missing_oauth_secret_dir = tmp.path().join("missing-oauth-secret");
|
|
std::fs::create_dir_all(&missing_oauth_secret_dir).expect("create missing oauth secret dir");
|
|
std::fs::write(
|
|
missing_oauth_secret_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"github": "github:missing-access"
|
|
},
|
|
"profiles": {
|
|
"github:missing-access": {
|
|
"provider": "github",
|
|
"profile_name": "missing-access",
|
|
"kind": "oauth",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write missing oauth secret fixture");
|
|
let missing_secret = AuthProfilesStore::new(&missing_oauth_secret_dir, false)
|
|
.load()
|
|
.expect("oauth profile missing access token should be dropped");
|
|
assert!(missing_secret.profiles.is_empty());
|
|
assert!(missing_secret.active_profiles.is_empty());
|
|
let rewritten_missing_secret: Value = serde_json::from_str(
|
|
&std::fs::read_to_string(missing_oauth_secret_dir.join("auth-profiles.json"))
|
|
.expect("read rewritten missing oauth secret profile store"),
|
|
)
|
|
.expect("rewritten missing oauth secret store should be json");
|
|
assert!(
|
|
rewritten_missing_secret
|
|
.pointer("/profiles/github:missing-access")
|
|
.is_none(),
|
|
"missing oauth secret profile should be purged from persisted store: {rewritten_missing_secret}"
|
|
);
|
|
assert!(
|
|
rewritten_missing_secret
|
|
.pointer("/active_profiles/github")
|
|
.is_none(),
|
|
"active pointer to missing oauth secret profile should be purged: {rewritten_missing_secret}"
|
|
);
|
|
|
|
let public_api_dir = tmp.path().join("public-api-errors");
|
|
let public_store = AuthProfilesStore::new(&public_api_dir, false);
|
|
assert!(public_store
|
|
.set_active_profile("github", "github:missing")
|
|
.expect_err("missing active profile should fail")
|
|
.to_string()
|
|
.contains("Auth profile not found"));
|
|
assert!(public_store
|
|
.update_profile("github:missing", |_| Ok(()))
|
|
.expect_err("missing update profile should fail")
|
|
.to_string()
|
|
.contains("Auth profile not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_profile_store_round_trips_oauth_secret_fields_after_backend_selection() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "disabled");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let state_dir = tmp.path().join("json-fallback");
|
|
let store = AuthProfilesStore::new(&state_dir, false);
|
|
let profile = AuthProfile::new_oauth(
|
|
"github",
|
|
"json-fallback",
|
|
TokenSet {
|
|
access_token: "json-access".to_string(),
|
|
refresh_token: Some("json-refresh".to_string()),
|
|
id_token: Some("json-id".to_string()),
|
|
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(2)),
|
|
token_type: Some("Bearer".to_string()),
|
|
scope: Some("repo user".to_string()),
|
|
},
|
|
);
|
|
let profile_id = profile.id.clone();
|
|
|
|
store
|
|
.upsert_profile(profile, true)
|
|
.expect("upsert oauth profile after backend selection");
|
|
let raw: Value = serde_json::from_str(
|
|
&std::fs::read_to_string(state_dir.join("auth-profiles.json"))
|
|
.expect("read persisted auth profiles"),
|
|
)
|
|
.expect("persisted auth profiles json");
|
|
let persisted_access_token = raw.pointer(&format!("/profiles/{profile_id}/access_token"));
|
|
assert!(
|
|
persisted_access_token.is_some_and(|value| {
|
|
value.is_null()
|
|
|| value
|
|
.as_str()
|
|
.is_some_and(|secret| secret.starts_with("enc2:"))
|
|
}),
|
|
"persisted profile should either keychain-strip or encrypt access token: {raw}"
|
|
);
|
|
|
|
let loaded = store.load().expect("reload json fallback profile");
|
|
let tokens = loaded
|
|
.profiles
|
|
.get(&profile_id)
|
|
.and_then(|profile| profile.token_set.as_ref())
|
|
.expect("oauth token set should round-trip");
|
|
assert_eq!(tokens.access_token, "json-access");
|
|
assert_eq!(tokens.refresh_token.as_deref(), Some("json-refresh"));
|
|
assert_eq!(tokens.id_token.as_deref(), Some("json-id"));
|
|
|
|
let root_path_store = AuthProfilesStore::new(Path::new("/"), false);
|
|
assert_eq!(
|
|
root_path_store.path(),
|
|
Path::new("/").join("auth-profiles.json")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_profile_store_keychain_migration_and_fallback_paths_are_deterministic() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
|
|
let hit_dir = tmp.path().join("keychain-hit");
|
|
std::fs::create_dir_all(&hit_dir).expect("create keychain hit dir");
|
|
let hit_profile_id = "github:main";
|
|
openhuman_core::openhuman::keyring::set(
|
|
"keychain-hit",
|
|
&format!("auth:{hit_profile_id}"),
|
|
&json!({
|
|
"access_token": "kc-access",
|
|
"refresh_token": "kc-refresh",
|
|
"id_token": "kc-id",
|
|
"token": null
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("seed file-backed keyring secret");
|
|
std::fs::write(
|
|
hit_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"github": hit_profile_id
|
|
},
|
|
"profiles": {
|
|
"github:main": {
|
|
"provider": "github",
|
|
"profile_name": "main",
|
|
"kind": "oauth",
|
|
"access_token": "legacy-access",
|
|
"refresh_token": "legacy-refresh",
|
|
"id_token": "legacy-id",
|
|
"expires_at": "2026-01-01T00:00:00Z",
|
|
"token_type": "Bearer",
|
|
"scope": "repo",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write keychain hit fixture");
|
|
let hit_store = AuthProfilesStore::new(&hit_dir, true);
|
|
let hit_loaded = hit_store.load().expect("keychain hit should load");
|
|
let hit_tokens = hit_loaded
|
|
.profiles
|
|
.get(hit_profile_id)
|
|
.and_then(|profile| profile.token_set.as_ref())
|
|
.expect("keychain hit token set");
|
|
assert_eq!(hit_tokens.access_token, "kc-access");
|
|
assert_eq!(hit_tokens.refresh_token.as_deref(), Some("kc-refresh"));
|
|
let hit_rewritten: Value = serde_json::from_str(
|
|
&std::fs::read_to_string(hit_dir.join("auth-profiles.json"))
|
|
.expect("read rewritten keychain hit profile store"),
|
|
)
|
|
.expect("rewritten keychain hit json");
|
|
assert!(
|
|
hit_rewritten
|
|
.get("profiles")
|
|
.and_then(Value::as_object)
|
|
.and_then(|profiles| profiles.get(hit_profile_id))
|
|
.and_then(|profile| profile.get("access_token"))
|
|
.is_none_or(Value::is_null),
|
|
"legacy JSON secret fields should be cleared after keychain hit: {hit_rewritten}"
|
|
);
|
|
|
|
let migrate_dir = tmp.path().join("keychain-migrate");
|
|
std::fs::create_dir_all(&migrate_dir).expect("create keychain migrate dir");
|
|
let migrate_profile_id = "gitlab:work";
|
|
std::fs::write(
|
|
migrate_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"gitlab": migrate_profile_id
|
|
},
|
|
"profiles": {
|
|
"gitlab:work": {
|
|
"provider": "gitlab",
|
|
"profile_name": "work",
|
|
"kind": "token",
|
|
"token": "plain-token-for-migration",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write keychain migration fixture");
|
|
let migrate_store = AuthProfilesStore::new(&migrate_dir, true);
|
|
let migrated = migrate_store
|
|
.load()
|
|
.expect("plaintext JSON token should migrate to keychain");
|
|
assert_eq!(
|
|
migrated
|
|
.profiles
|
|
.get(migrate_profile_id)
|
|
.and_then(|profile| profile.token.as_deref()),
|
|
Some("plain-token-for-migration")
|
|
);
|
|
let migrated_keychain = openhuman_core::openhuman::keyring::get(
|
|
"keychain-migrate",
|
|
&format!("auth:{migrate_profile_id}"),
|
|
)
|
|
.expect("read migrated keychain token")
|
|
.expect("migrated keychain token should exist");
|
|
assert!(
|
|
migrated_keychain.contains("plain-token-for-migration"),
|
|
"migrated keychain payload should contain redacted fixture token"
|
|
);
|
|
|
|
let fallback_dir = tmp.path().join("keychain-fallback");
|
|
std::fs::create_dir_all(&fallback_dir).expect("create keychain fallback dir");
|
|
let fallback_profile_id = "slack:bot";
|
|
openhuman_core::openhuman::keyring::set(
|
|
"keychain-fallback",
|
|
&format!("auth:{fallback_profile_id}"),
|
|
"not-json",
|
|
)
|
|
.expect("seed malformed keychain payload");
|
|
std::fs::write(
|
|
fallback_dir.join("auth-profiles.json"),
|
|
json!({
|
|
"schema_version": 1,
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"active_profiles": {
|
|
"slack": fallback_profile_id
|
|
},
|
|
"profiles": {
|
|
"slack:bot": {
|
|
"provider": "slack",
|
|
"profile_name": "bot",
|
|
"kind": "token",
|
|
"token": "json-fallback-token",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write keychain fallback fixture");
|
|
let fallback = AuthProfilesStore::new(&fallback_dir, true)
|
|
.load()
|
|
.expect("malformed keychain payload should fall back to JSON");
|
|
assert_eq!(
|
|
fallback
|
|
.profiles
|
|
.get(fallback_profile_id)
|
|
.and_then(|profile| profile.token.as_deref()),
|
|
Some("json-fallback-token")
|
|
);
|
|
|
|
assert!(
|
|
AuthProfilesStore::new(&migrate_dir, true)
|
|
.remove_profile(migrate_profile_id)
|
|
.expect("remove migrated profile"),
|
|
"migrated profile should be removable"
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::keyring::get(
|
|
"keychain-migrate",
|
|
&format!("auth:{migrate_profile_id}"),
|
|
)
|
|
.expect("read deleted migrated keychain token")
|
|
.is_none(),
|
|
"removing a profile should delete its keychain payload"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_profile_store_reclaims_stale_dead_pid_lock() {
|
|
let _lock = env_lock();
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
let tmp = tempdir().expect("tempdir");
|
|
let state_dir = tmp.path().join("stale-lock");
|
|
std::fs::create_dir_all(&state_dir).expect("create stale lock profile dir");
|
|
let lock_path = state_dir.join("auth-profiles.lock");
|
|
std::fs::write(&lock_path, "pid=999999999\n").expect("write stale auth profile lock");
|
|
|
|
let store = AuthProfilesStore::new(&state_dir, false);
|
|
let loaded = store
|
|
.load()
|
|
.expect("stale dead-pid lock should be reclaimed");
|
|
assert!(loaded.profiles.is_empty());
|
|
assert!(
|
|
!lock_path.exists(),
|
|
"stale lock should be removed after successful load"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connectivity_public_helpers_cover_schemas_and_port_probe() {
|
|
let schemas = all_connectivity_controller_schemas();
|
|
assert_eq!(schemas.len(), 1);
|
|
assert_eq!(schemas[0].namespace, "connectivity");
|
|
assert_eq!(schemas[0].function, "diag");
|
|
assert!(schemas[0].inputs.is_empty());
|
|
assert_eq!(schemas[0].outputs[0].name, "diag");
|
|
|
|
let registered = all_connectivity_registered_controllers();
|
|
assert_eq!(registered.len(), schemas.len());
|
|
|
|
let unknown = connectivity_controller_schema("missing");
|
|
assert_eq!(unknown.namespace, "connectivity");
|
|
assert_eq!(unknown.function, "unknown");
|
|
assert_eq!(unknown.outputs[0].name, "error");
|
|
assert!(unknown.description.contains("Unknown connectivity"));
|
|
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe listener");
|
|
let port = listener.local_addr().expect("probe local addr").port();
|
|
assert!(openhuman_core::openhuman::connectivity::ops::is_port_in_use(port));
|
|
drop(listener);
|
|
let _ = openhuman_core::openhuman::connectivity::ops::is_port_in_use(port);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connectivity_pick_listen_port_uses_fallback_when_preferred_is_busy() {
|
|
let _lock = env_lock();
|
|
let mut held_listener = None;
|
|
let mut preferred = 0;
|
|
for _ in 0..25 {
|
|
let listener =
|
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind candidate preferred listener");
|
|
let port = listener.local_addr().expect("candidate local addr").port();
|
|
if port < u16::MAX - 10 {
|
|
preferred = port;
|
|
held_listener = Some(listener);
|
|
break;
|
|
}
|
|
}
|
|
let held_listener = held_listener.expect("find preferred port with fallback room");
|
|
|
|
let picked = openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host(
|
|
"127.0.0.1",
|
|
preferred,
|
|
)
|
|
.await
|
|
.expect("busy preferred port should fall back");
|
|
assert_ne!(picked.port, preferred);
|
|
assert_eq!(picked.fallback_from, Some(preferred));
|
|
drop(picked.listener);
|
|
drop(held_listener);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connectivity_pick_listen_port_covers_direct_bind_and_exhausted_fallbacks() {
|
|
let _lock = env_lock();
|
|
|
|
let direct =
|
|
openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host("127.0.0.1", 0)
|
|
.await
|
|
.expect("port 0 should bind directly");
|
|
assert_eq!(direct.fallback_from, None);
|
|
drop(direct.listener);
|
|
|
|
let mut held_listeners = Vec::new();
|
|
let mut preferred = None;
|
|
for _ in 0..50 {
|
|
held_listeners.clear();
|
|
let base_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind base listener");
|
|
let base = base_listener.local_addr().expect("base addr").port();
|
|
if base > u16::MAX - 10 {
|
|
continue;
|
|
}
|
|
held_listeners.push(base_listener);
|
|
let mut complete_range = true;
|
|
for port in (base + 1)..=(base + 10) {
|
|
match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
|
Ok(listener) => held_listeners.push(listener),
|
|
Err(_) => {
|
|
complete_range = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if complete_range {
|
|
preferred = Some(base);
|
|
break;
|
|
}
|
|
}
|
|
let preferred = preferred.expect("reserve preferred port and fallback range");
|
|
let exhausted = openhuman_core::openhuman::connectivity::rpc::pick_listen_port_for_host(
|
|
"127.0.0.1",
|
|
preferred,
|
|
)
|
|
.await
|
|
.expect_err("busy preferred and fallback range should fail");
|
|
match &exhausted {
|
|
openhuman_core::openhuman::connectivity::rpc::PickListenPortError::NoAvailablePort {
|
|
preferred: err_preferred,
|
|
attempted,
|
|
fingerprint,
|
|
} => {
|
|
assert_eq!(*err_preferred, preferred);
|
|
assert_eq!(attempted.len(), 10);
|
|
assert!(
|
|
fingerprint.contains("probe"),
|
|
"non-OpenHuman listeners should be identified by probe details: {fingerprint}"
|
|
);
|
|
}
|
|
other => panic!("unexpected exhausted port error: {other:?}"),
|
|
}
|
|
assert!(
|
|
exhausted
|
|
.to_string()
|
|
.contains("no fallback ports available"),
|
|
"Display should explain exhausted fallbacks: {exhausted}"
|
|
);
|
|
|
|
let takeover =
|
|
openhuman_core::openhuman::connectivity::rpc::PickListenPortError::WouldTakeOver {
|
|
preferred,
|
|
fingerprint: "openhuman-core".into(),
|
|
};
|
|
assert!(takeover
|
|
.to_string()
|
|
.contains("stale-listener takeover required"));
|
|
let bind_failed =
|
|
openhuman_core::openhuman::connectivity::rpc::PickListenPortError::BindFailed {
|
|
port: preferred,
|
|
reason: "synthetic bind failure".into(),
|
|
};
|
|
assert!(bind_failed.to_string().contains("synthetic bind failure"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connectivity_diag_reports_runtime_port_sources() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let diag = rpc(
|
|
&harness.rpc_base,
|
|
40_001,
|
|
"openhuman.connectivity_diag",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let diag_payload = payload(&diag, "connectivity_diag")
|
|
.get("diag")
|
|
.unwrap_or_else(|| panic!("connectivity diag should include diag payload: {diag}"));
|
|
assert!(
|
|
diag_payload
|
|
.get("sidecar_pid")
|
|
.and_then(Value::as_u64)
|
|
.is_some(),
|
|
"diag should expose sidecar_pid: {diag_payload}"
|
|
);
|
|
assert!(
|
|
diag_payload
|
|
.get("listen_port")
|
|
.and_then(Value::as_u64)
|
|
.is_some(),
|
|
"diag should expose listen_port: {diag_payload}"
|
|
);
|
|
assert!(
|
|
diag_payload
|
|
.get("listen_port_in_use")
|
|
.and_then(Value::as_bool)
|
|
.is_some(),
|
|
"diag should expose listen_port_in_use: {diag_payload}"
|
|
);
|
|
|
|
{
|
|
let _rpc_url = EnvVarGuard::set("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:4567/rpc");
|
|
let _core_port = EnvVarGuard::set("OPENHUMAN_CORE_PORT", "7788");
|
|
let snapshot = openhuman_core::openhuman::connectivity::rpc::snapshot();
|
|
assert_eq!(snapshot.listen_port, 4567);
|
|
}
|
|
{
|
|
let _rpc_url = EnvVarGuard::set("OPENHUMAN_CORE_RPC_URL", "not a url");
|
|
let _core_port = EnvVarGuard::set("OPENHUMAN_CORE_PORT", "4568");
|
|
let snapshot = openhuman_core::openhuman::connectivity::rpc::snapshot();
|
|
assert_eq!(snapshot.listen_port, 4568);
|
|
}
|
|
{
|
|
let _rpc_url = EnvVarGuard::unset("OPENHUMAN_CORE_RPC_URL");
|
|
let _core_port = EnvVarGuard::set("OPENHUMAN_CORE_PORT", "not-a-port");
|
|
let snapshot = openhuman_core::openhuman::connectivity::rpc::snapshot();
|
|
assert_eq!(snapshot.listen_port, 7788);
|
|
}
|
|
|
|
harness.join.abort();
|
|
}
|