mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-22 00:31:12 +00:00
847 lines
30 KiB
Rust
847 lines
30 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
#[cfg(test)]
|
|
use std::sync::Mutex;
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context as _;
|
|
use axum::extract::Request;
|
|
#[cfg(test)]
|
|
use axum::extract::State as AxumState;
|
|
use axum::http::{HeaderValue, header};
|
|
use axum::middleware::Next;
|
|
use axum::response::Response;
|
|
use axum::{Router, middleware};
|
|
use chrono::Duration as ChronoDuration;
|
|
use fabro_config::user::default_storage_dir;
|
|
use fabro_config::{RunLayer, ServerSettingsBuilder, Storage, envfile};
|
|
use fabro_db::DbPool;
|
|
use fabro_interview::Interviewer;
|
|
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
|
|
use fabro_model::{Catalog, ProviderId};
|
|
use fabro_sandbox::SandboxProviderRegistry;
|
|
use fabro_static::EnvVars;
|
|
use fabro_store::{ArtifactStore, Database};
|
|
use fabro_types::settings::ServerAuthMethod;
|
|
use fabro_types::settings::run::EnvironmentProvider;
|
|
use fabro_types::{AuthMethod, IdpIdentity, ServerSettings};
|
|
use fabro_vault::{SecretType, Vault};
|
|
use fabro_workflow::handler::HandlerRegistry;
|
|
use object_store::memory::InMemory as MemoryObjectStore;
|
|
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
|
use tokio_util::sync::CancellationToken;
|
|
use ulid::Ulid;
|
|
|
|
use crate::automation_materializer::AutomationRunMaterializer;
|
|
pub use crate::automation_materializer::TestAutomationRunMaterializer;
|
|
use crate::interp::process_env_var;
|
|
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
|
|
#[cfg(test)]
|
|
use crate::principal_middleware::{AuthContextSlot, RequestAuthContext};
|
|
use crate::server::{
|
|
self, AppState, AppStateConfig, EnvLookup, RegistryFactoryOverride, ResolvedAppStateSettings,
|
|
RouterOptions, build_app_state,
|
|
};
|
|
use crate::server_secrets::ServerSecrets;
|
|
#[cfg(test)]
|
|
use crate::worker_runtime::WorkerRuntime;
|
|
use crate::{auth, migrations};
|
|
|
|
pub const TEST_DEV_TOKEN: &str =
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
|
pub const TEST_SESSION_SECRET: &str =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
const TEST_OPENAI_API_KEY: &str = "test-openai-api-key";
|
|
const FABRO_TEST_ASSUME_LLM_READY: &str = "FABRO_TEST_ASSUME_LLM_READY";
|
|
|
|
/// Supply enabled catalog providers to CLI fixture run materialization.
|
|
///
|
|
/// This is scoped to the `test-support` feature and an explicit child-process
|
|
/// flag. The CLI suite shares a server whose credential state can change
|
|
/// between tests, so the flag deliberately ignores that mutable state. It does
|
|
/// not register adapters or make model execution available.
|
|
pub(crate) fn test_run_materialization_provider_ids(
|
|
catalog: &Catalog,
|
|
ready_provider_ids: &[ProviderId],
|
|
) -> Vec<ProviderId> {
|
|
let assume_ready = process_env_var(FABRO_TEST_ASSUME_LLM_READY)
|
|
.is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no"));
|
|
if assume_ready {
|
|
catalog.all_provider_ids().into_iter().collect()
|
|
} else {
|
|
ready_provider_ids.to_vec()
|
|
}
|
|
}
|
|
|
|
pub fn default_test_server_settings() -> ServerSettings {
|
|
ServerSettingsBuilder::from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
"#,
|
|
)
|
|
.expect("default test server settings should resolve")
|
|
}
|
|
|
|
#[must_use]
|
|
pub struct TestAppStateBuilder {
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
|
sandbox_provider_registry: Option<SandboxProviderRegistry>,
|
|
store_bundle: Option<(Arc<Database>, ArtifactStore)>,
|
|
vault_path: Option<PathBuf>,
|
|
vault_entries: Vec<(String, String)>,
|
|
server_env_path: Option<PathBuf>,
|
|
active_config_path: Option<PathBuf>,
|
|
server_secret_env: HashMap<String, String>,
|
|
default_environment_provider: Option<EnvironmentProvider>,
|
|
env_lookup: EnvLookup,
|
|
llm_catalog_settings: LlmCatalogSettings,
|
|
automation_materializer: Option<Arc<dyn AutomationRunMaterializer>>,
|
|
#[cfg(test)]
|
|
worker_runtime: Option<Arc<dyn WorkerRuntime>>,
|
|
}
|
|
|
|
impl Default for TestAppStateBuilder {
|
|
fn default() -> Self {
|
|
Self {
|
|
server_settings: default_test_server_settings(),
|
|
manifest_run_defaults: RunLayer::default(),
|
|
max_concurrent_runs: 5,
|
|
registry_factory_override: None,
|
|
sandbox_provider_registry: None,
|
|
store_bundle: None,
|
|
vault_path: None,
|
|
vault_entries: Vec::new(),
|
|
server_env_path: None,
|
|
active_config_path: None,
|
|
server_secret_env: HashMap::new(),
|
|
default_environment_provider: Some(EnvironmentProvider::Docker),
|
|
env_lookup: default_env_lookup(),
|
|
llm_catalog_settings: LlmCatalogSettings::default(),
|
|
automation_materializer: None,
|
|
#[cfg(test)]
|
|
worker_runtime: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TestAppStateBuilder {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn runtime_settings(
|
|
mut self,
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
) -> Self {
|
|
self.server_settings = server_settings;
|
|
self.manifest_run_defaults = manifest_run_defaults;
|
|
self
|
|
}
|
|
|
|
pub fn max_concurrent_runs(mut self, max_concurrent_runs: usize) -> Self {
|
|
self.max_concurrent_runs = max_concurrent_runs;
|
|
self
|
|
}
|
|
|
|
pub fn registry_factory(
|
|
mut self,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry
|
|
+ Send
|
|
+ Sync
|
|
+ 'static,
|
|
) -> Self {
|
|
self.registry_factory_override = Some(Box::new(registry_factory_override));
|
|
self
|
|
}
|
|
|
|
pub fn sandbox_provider_registry(
|
|
mut self,
|
|
sandbox_provider_registry: SandboxProviderRegistry,
|
|
) -> Self {
|
|
self.sandbox_provider_registry = Some(sandbox_provider_registry);
|
|
self
|
|
}
|
|
|
|
pub fn env_lookup(
|
|
mut self,
|
|
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
|
) -> Self {
|
|
self.env_lookup = Arc::new(env_lookup);
|
|
self
|
|
}
|
|
|
|
pub fn llm_catalog_settings(mut self, settings: LlmCatalogSettings) -> Self {
|
|
self.llm_catalog_settings = settings;
|
|
self
|
|
}
|
|
|
|
pub fn automation_materializer(mut self, materializer: TestAutomationRunMaterializer) -> Self {
|
|
self.automation_materializer = Some(materializer.into_materializer());
|
|
self
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn worker_runtime(mut self, worker_runtime: Arc<dyn WorkerRuntime>) -> Self {
|
|
self.worker_runtime = Some(worker_runtime);
|
|
self
|
|
}
|
|
|
|
pub fn provider_base_url(
|
|
mut self,
|
|
provider: impl Into<String>,
|
|
base_url: impl Into<String>,
|
|
) -> Self {
|
|
self.llm_catalog_settings
|
|
.providers
|
|
.insert(provider.into(), ProviderCatalogSettings {
|
|
base_url: Some(base_url.into()),
|
|
..ProviderCatalogSettings::default()
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn server_secret_env(mut self, server_secret_env: HashMap<String, String>) -> Self {
|
|
self.server_secret_env = server_secret_env;
|
|
self
|
|
}
|
|
|
|
pub fn default_environment_provider(mut self, provider: Option<EnvironmentProvider>) -> Self {
|
|
self.default_environment_provider = provider;
|
|
self
|
|
}
|
|
|
|
pub fn store_bundle(mut self, store: Arc<Database>, artifact_store: ArtifactStore) -> Self {
|
|
self.store_bundle = Some((store, artifact_store));
|
|
self
|
|
}
|
|
|
|
fn server_env_path(mut self, server_env_path: PathBuf) -> Self {
|
|
self.server_env_path = Some(server_env_path);
|
|
self
|
|
}
|
|
|
|
pub fn vault_path(mut self, vault_path: PathBuf) -> Self {
|
|
self.vault_path = Some(vault_path);
|
|
self
|
|
}
|
|
|
|
pub fn active_config_path(mut self, active_config_path: PathBuf) -> Self {
|
|
self.active_config_path = Some(active_config_path);
|
|
self
|
|
}
|
|
|
|
/// Pre-populate the vault file with optional integration secrets (token
|
|
/// type) before [`build_app_state`] opens it.
|
|
pub fn vault_entries<I, K, V>(mut self, entries: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
self.vault_entries
|
|
.extend(entries.into_iter().map(|(k, v)| (k.into(), v.into())));
|
|
self
|
|
}
|
|
|
|
pub fn build(self) -> Arc<AppState> {
|
|
self.try_build().expect("test app state should build")
|
|
}
|
|
|
|
pub fn try_build(mut self) -> anyhow::Result<Arc<AppState>> {
|
|
let (store, artifact_store) = self.store_bundle.unwrap_or_else(test_store_bundle);
|
|
let vault_path = self.vault_path.unwrap_or_else(test_secret_store_path);
|
|
self.server_settings = redirect_default_storage_root(self.server_settings, &vault_path)?;
|
|
if !self.vault_entries.is_empty() {
|
|
let mut vault = Vault::load(vault_path.clone()).expect("test vault should load");
|
|
for (name, value) in &self.vault_entries {
|
|
vault
|
|
.set(name, value, SecretType::Token, None)
|
|
.expect("test vault entry should persist");
|
|
}
|
|
}
|
|
let server_env_path = self
|
|
.server_env_path
|
|
.unwrap_or_else(|| vault_path.with_file_name("server.env"));
|
|
let active_config_path = self
|
|
.active_config_path
|
|
.unwrap_or_else(|| vault_path.with_file_name("settings.toml"));
|
|
let db_pool = test_db_pool_for_vault_path_with_default_environment(
|
|
&vault_path,
|
|
self.default_environment_provider,
|
|
)?;
|
|
import_test_legacy_automations(
|
|
db_pool.clone(),
|
|
server::automation_dir_for_active_config(&active_config_path),
|
|
)?;
|
|
let preloaded_vault = test_secret_snapshot(db_pool.clone())?;
|
|
build_app_state(AppStateConfig {
|
|
resolved_settings: resolved_runtime_settings_for_tests(
|
|
self.server_settings,
|
|
self.manifest_run_defaults,
|
|
self.llm_catalog_settings,
|
|
),
|
|
registry_factory_override: self.registry_factory_override,
|
|
max_concurrent_runs: self.max_concurrent_runs,
|
|
store,
|
|
artifact_store,
|
|
db_pool,
|
|
preloaded_vault,
|
|
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),
|
|
env_lookup: self.env_lookup,
|
|
github_api_base_url: None,
|
|
active_config_path,
|
|
http_client: Some(
|
|
fabro_http::test_http_client().expect("test HTTP client should build"),
|
|
),
|
|
sandbox_provider_registry: self.sandbox_provider_registry,
|
|
shutdown: CancellationToken::new(),
|
|
#[cfg(test)]
|
|
worker_control_bus: None,
|
|
#[cfg(test)]
|
|
worker_runtime: self.worker_runtime,
|
|
automation_materializer_override: self.automation_materializer,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync test builders may run inside Tokio; a dedicated thread avoids a nested runtime"
|
|
)]
|
|
pub(crate) fn test_secret_snapshot(pool: DbPool) -> anyhow::Result<Vault> {
|
|
std::thread::spawn(move || {
|
|
let runtime = TokioRuntimeBuilder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
runtime
|
|
.block_on(fabro_vault::SecretStore::new(pool).snapshot())
|
|
.map(fabro_vault::SecretSnapshot::into_vault)
|
|
.map_err(anyhow::Error::new)
|
|
})
|
|
.join()
|
|
.expect("test secret snapshot thread should not panic")
|
|
}
|
|
|
|
pub fn llm_catalog_settings_with_provider_base_url(
|
|
provider: impl Into<String>,
|
|
base_url: impl Into<String>,
|
|
) -> LlmCatalogSettings {
|
|
let mut settings = LlmCatalogSettings::default();
|
|
settings
|
|
.providers
|
|
.insert(provider.into(), ProviderCatalogSettings {
|
|
base_url: Some(base_url.into()),
|
|
..ProviderCatalogSettings::default()
|
|
});
|
|
settings
|
|
}
|
|
|
|
pub fn test_app_state() -> Arc<AppState> {
|
|
ready_test_app_state_builder().build()
|
|
}
|
|
|
|
pub fn test_app_state_with_registry_factory(
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.registry_factory(registry_factory_override)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_settings_and_registry_factory(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.registry_factory(registry_factory_override)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_options_and_registry_factory(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.registry_factory(registry_factory_override)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_options(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.build()
|
|
}
|
|
|
|
fn ready_test_app_state_builder() -> TestAppStateBuilder {
|
|
TestAppStateBuilder::new().vault_entries([(EnvVars::OPENAI_API_KEY, TEST_OPENAI_API_KEY)])
|
|
}
|
|
|
|
pub(crate) fn resolved_runtime_settings_for_tests(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
llm_catalog_settings: LlmCatalogSettings,
|
|
) -> ResolvedAppStateSettings {
|
|
ResolvedAppStateSettings {
|
|
server_settings,
|
|
manifest_run_defaults,
|
|
llm_catalog_settings,
|
|
}
|
|
}
|
|
|
|
pub fn test_app_state_with_runtime_settings_and_registry_factory(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.registry_factory(registry_factory_override)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_runtime_settings_and_options_and_registry_factory(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.registry_factory(registry_factory_override)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_runtime_settings_and_options(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_runtime_settings_and_env_lookup(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
TestAppStateBuilder::new()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.env_lookup(env_lookup)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
|
server_secret_env: &HashMap<String, String>,
|
|
) -> Arc<AppState> {
|
|
TestAppStateBuilder::new()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.env_lookup(env_lookup)
|
|
.server_secret_env(server_secret_env.clone())
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_env_lookup(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
TestAppStateBuilder::new()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.env_lookup(env_lookup)
|
|
.build()
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "test helper writes a fixture server.env with sync std::fs::write"
|
|
)]
|
|
pub fn test_app_state_with_runtime_settings_and_session_key(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
session_secret: Option<&str>,
|
|
) -> Arc<AppState> {
|
|
let vault_path = test_secret_store_path();
|
|
let server_env_path = vault_path
|
|
.parent()
|
|
.expect("test secrets path should have parent")
|
|
.join("server.env");
|
|
if let Some(session_secret) = session_secret {
|
|
std::fs::write(
|
|
&server_env_path,
|
|
format!("SESSION_SECRET={session_secret}\n"),
|
|
)
|
|
.expect("test server env should be writable");
|
|
}
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.vault_path(vault_path)
|
|
.server_env_path(server_env_path)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_app_state_with_session_key(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
session_secret: Option<&str>,
|
|
) -> Arc<AppState> {
|
|
test_app_state_with_runtime_settings_and_session_key(
|
|
server_settings,
|
|
manifest_run_defaults,
|
|
session_secret,
|
|
)
|
|
}
|
|
|
|
pub fn test_app_state_with_store(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
store: Arc<Database>,
|
|
artifact_store: ArtifactStore,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.store_bundle(store, artifact_store)
|
|
.build()
|
|
}
|
|
|
|
pub fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
|
|
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(MemoryObjectStore::new());
|
|
let store = Arc::new(fabro_store::Database::new(
|
|
Arc::clone(&object_store),
|
|
"",
|
|
Duration::from_millis(1),
|
|
None,
|
|
));
|
|
let artifact_store = ArtifactStore::new(object_store, "artifacts");
|
|
(store, artifact_store)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn test_db_pool_for_vault_path(vault_path: &Path) -> anyhow::Result<DbPool> {
|
|
test_db_pool_for_vault_path_with_default_environment(
|
|
vault_path,
|
|
Some(EnvironmentProvider::Docker),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn test_db_pool_for_vault_path_with_default_environment(
|
|
vault_path: &Path,
|
|
default_environment_provider: Option<EnvironmentProvider>,
|
|
) -> anyhow::Result<DbPool> {
|
|
test_db_pool(
|
|
sqlite_path_for_vault_path(vault_path),
|
|
vault_path.to_path_buf(),
|
|
default_environment_provider,
|
|
)
|
|
}
|
|
|
|
fn sqlite_path_for_vault_path(vault_path: &Path) -> PathBuf {
|
|
vault_path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.join("db")
|
|
.join("fabro.sqlite3")
|
|
}
|
|
|
|
pub async fn test_environment_from_storage_dir(
|
|
storage_dir: &Path,
|
|
id: &str,
|
|
) -> anyhow::Result<Option<fabro_environment::Environment>> {
|
|
let database = fabro_db::Database::connect(Storage::new(storage_dir).sqlite_path()).await?;
|
|
let store = fabro_environment::EnvironmentStore::load(database.clone_pool(), false).await?;
|
|
let id = fabro_environment::EnvironmentId::new(id)?;
|
|
Ok(store.get(&id))
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync test builders may be called inside async tests; a short-lived OS thread avoids nested Tokio runtimes"
|
|
)]
|
|
fn test_db_pool(
|
|
path: PathBuf,
|
|
vault_path: PathBuf,
|
|
default_environment_provider: Option<EnvironmentProvider>,
|
|
) -> anyhow::Result<DbPool> {
|
|
std::thread::spawn(move || {
|
|
migrations::migrate_legacy_vault_file(&vault_path)?;
|
|
let runtime = TokioRuntimeBuilder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
runtime.block_on(async move {
|
|
let database = fabro_db::Database::connect(&path).await?;
|
|
database.migrate().await?;
|
|
fabro_vault::import_legacy_json_once(database.pool(), vault_path).await?;
|
|
if let Some(provider) = default_environment_provider {
|
|
fabro_environment::seed_default_environment(database.pool(), provider).await?;
|
|
}
|
|
Ok(database.clone_pool())
|
|
})
|
|
})
|
|
.join()
|
|
.expect("test database setup thread should not panic")
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync test builders may run inside async tests; a short-lived OS thread avoids nested Tokio runtimes"
|
|
)]
|
|
fn import_test_legacy_automations(pool: DbPool, source_dir: PathBuf) -> anyhow::Result<()> {
|
|
std::thread::spawn(move || {
|
|
let runtime = TokioRuntimeBuilder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
runtime
|
|
.block_on(fabro_automation::import_legacy_directory_once(
|
|
&pool, source_dir,
|
|
))
|
|
.map(|_| ())
|
|
.map_err(anyhow::Error::new)
|
|
})
|
|
.join()
|
|
.expect("test automation import thread should not panic")
|
|
}
|
|
|
|
pub fn test_app_state_with_store_and_runtime_settings(
|
|
server_settings: ServerSettings,
|
|
manifest_run_defaults: RunLayer,
|
|
max_concurrent_runs: usize,
|
|
store: Arc<Database>,
|
|
artifact_store: ArtifactStore,
|
|
) -> Arc<AppState> {
|
|
ready_test_app_state_builder()
|
|
.runtime_settings(server_settings, manifest_run_defaults)
|
|
.max_concurrent_runs(max_concurrent_runs)
|
|
.store_bundle(store, artifact_store)
|
|
.build()
|
|
}
|
|
|
|
pub(crate) fn default_env_lookup() -> EnvLookup {
|
|
Arc::new(process_env_var)
|
|
}
|
|
|
|
pub(crate) fn load_test_server_secrets(
|
|
path: PathBuf,
|
|
env: HashMap<String, String>,
|
|
) -> ServerSecrets {
|
|
let mut env = env;
|
|
let file_has_session_secret = envfile::read_env_file(&path)
|
|
.ok()
|
|
.is_some_and(|entries| entries.contains_key(EnvVars::SESSION_SECRET));
|
|
if !env.contains_key(EnvVars::SESSION_SECRET) && !file_has_session_secret {
|
|
env.insert(
|
|
EnvVars::SESSION_SECRET.to_string(),
|
|
"server-test-session-key-0123456789".to_string(),
|
|
);
|
|
}
|
|
ServerSecrets::load(path, env).expect("test server secrets should load")
|
|
}
|
|
|
|
pub fn test_secret_store_path() -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("fabro-test-{}", Ulid::new()));
|
|
std::fs::create_dir_all(&dir).expect("test temp dir should be creatable");
|
|
dir.join("secrets.json")
|
|
}
|
|
|
|
/// Keeps tests off the developer's real `~/.fabro/storage`.
|
|
///
|
|
/// Settings built for tests usually omit `[server.storage] root`, which
|
|
/// resolves to the production default. Handlers that walk that tree — `df`,
|
|
/// `system/resources`, `prune` — then read whatever runs and scratch
|
|
/// directories the machine happens to have, making tests slow and
|
|
/// machine-dependent, and letting run-creating tests write there.
|
|
///
|
|
/// Only settings still carrying the production default are redirected; a test
|
|
/// that chose its own root keeps it. The redirect goes through
|
|
/// [`ServerSettings::with_storage_override`] so the derived local object-store
|
|
/// roots move with it instead of pointing back at the real storage tree.
|
|
fn redirect_default_storage_root(
|
|
settings: ServerSettings,
|
|
vault_path: &Path,
|
|
) -> anyhow::Result<ServerSettings> {
|
|
if Path::new(&settings.server.storage.root) != default_storage_dir() {
|
|
return Ok(settings);
|
|
}
|
|
let root = vault_path.with_file_name("storage");
|
|
std::fs::create_dir_all(&root)
|
|
.with_context(|| format!("creating test storage root at {}", root.display()))?;
|
|
Ok(settings.with_storage_override(&root))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn test_auth_mode() -> AuthMode {
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::DevToken, ServerAuthMethod::Github],
|
|
dev_token: Some(TEST_DEV_TOKEN.to_string()),
|
|
jwt_key: Some(
|
|
auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes())
|
|
.expect("test jwt signing key should derive"),
|
|
),
|
|
jwt_issuer: Some("https://fabro.test".to_string()),
|
|
})
|
|
}
|
|
|
|
pub fn build_test_router(state: Arc<AppState>) -> Router {
|
|
with_test_user(server::build_router(state, test_auth_mode()))
|
|
}
|
|
|
|
pub fn build_test_router_with_options(state: Arc<AppState>, options: RouterOptions) -> Router {
|
|
with_test_user(server::build_router_with_options(
|
|
state,
|
|
&test_auth_mode(),
|
|
options,
|
|
))
|
|
}
|
|
|
|
pub fn with_test_user(router: Router) -> Router {
|
|
router.layer(middleware::from_fn(inject_test_user_bearer))
|
|
}
|
|
|
|
async fn inject_test_user_bearer(mut req: Request, next: Next) -> Response {
|
|
if req.uri().path().starts_with("/api/") && !req.headers().contains_key(header::AUTHORIZATION) {
|
|
static BEARER: OnceLock<HeaderValue> = OnceLock::new();
|
|
let bearer = BEARER.get_or_init(|| {
|
|
HeaderValue::from_str(&format!("Bearer {}", issue_test_user_token()))
|
|
.expect("test JWT bearer header is valid")
|
|
});
|
|
req.headers_mut()
|
|
.insert(header::AUTHORIZATION, bearer.clone());
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
fn issue_test_user_token() -> String {
|
|
let key = auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes())
|
|
.expect("test jwt signing key should derive");
|
|
auth::issue(
|
|
&key,
|
|
"https://fabro.test",
|
|
&auth::JwtSubject {
|
|
identity: IdpIdentity::new("fabro:dev", "dev")
|
|
.expect("test identity should be valid"),
|
|
login: "dev".to_string(),
|
|
name: "Dev Token".to_string(),
|
|
email: "dev@fabro.local".to_string(),
|
|
avatar_url: String::new(),
|
|
user_url: String::new(),
|
|
auth_method: AuthMethod::DevToken,
|
|
},
|
|
ChronoDuration::days(3650),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn capture_auth_context(
|
|
AxumState(captured): AxumState<Arc<Mutex<Vec<RequestAuthContext>>>>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let slot = AuthContextSlot::initial();
|
|
req.extensions_mut().insert(slot.clone());
|
|
let response = next.run(req).await;
|
|
captured
|
|
.lock()
|
|
.expect("captured auth contexts lock poisoned")
|
|
.push(slot.snapshot());
|
|
response
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use fabro_types::settings::ObjectStoreSettings;
|
|
|
|
use super::*;
|
|
|
|
fn local_store_root(store: &ObjectStoreSettings) -> &Path {
|
|
let ObjectStoreSettings::Local { root } = store else {
|
|
panic!("test server settings should use a local object store");
|
|
};
|
|
Path::new(root)
|
|
}
|
|
|
|
#[test]
|
|
fn default_storage_redirect_updates_derived_local_store_roots() {
|
|
let temp_dir = tempfile::tempdir().expect("test temp dir should be created");
|
|
let vault_path = temp_dir.path().join("secrets.json");
|
|
|
|
let settings = redirect_default_storage_root(default_test_server_settings(), &vault_path)
|
|
.expect("default test storage root should redirect");
|
|
let storage_root = temp_dir.path().join("storage");
|
|
|
|
assert_eq!(Path::new(&settings.server.storage.root), storage_root);
|
|
assert_eq!(
|
|
local_store_root(&settings.server.artifacts.store),
|
|
storage_root.join("objects/artifacts")
|
|
);
|
|
assert_eq!(
|
|
local_store_root(&settings.server.slatedb.store),
|
|
storage_root.join("objects/slatedb")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_storage_redirect_preserves_explicit_storage_root() {
|
|
let temp_dir = tempfile::tempdir().expect("test temp dir should be created");
|
|
let vault_path = temp_dir.path().join("secrets.json");
|
|
let expected =
|
|
default_test_server_settings().with_storage_override(&temp_dir.path().join("custom"));
|
|
|
|
let settings = redirect_default_storage_root(expected.clone(), &vault_path)
|
|
.expect("explicit test storage root should be preserved");
|
|
|
|
assert_eq!(settings, expected);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "synchronous fixture setup creates a blocking file before exercising the helper"
|
|
)]
|
|
fn default_storage_redirect_preserves_directory_creation_error_chain() {
|
|
let temp_dir = tempfile::tempdir().expect("test temp dir should be created");
|
|
let vault_path = temp_dir.path().join("secrets.json");
|
|
std::fs::write(temp_dir.path().join("storage"), "not a directory")
|
|
.expect("blocking storage path should be created");
|
|
|
|
let error = redirect_default_storage_root(default_test_server_settings(), &vault_path)
|
|
.expect_err("storage redirect should reject a file at the directory path");
|
|
|
|
assert!(error.to_string().contains("creating test storage root at"));
|
|
assert!(
|
|
error.chain().count() >= 2,
|
|
"filesystem error should remain in the source chain"
|
|
);
|
|
}
|
|
}
|