fabro/lib/apps/fabro-server/src/test_support.rs
Bryan Helmkamp 80bc51c40e
Open sandbox provider identity to plugin kinds
SandboxProviderKind is now a validated string newtype instead of a
closed enum. The bundled kinds (local, docker, daytona) keep their
constants and a BundledProvider enum for the code paths that still
dispatch on them; any other well-formed sandbox-driver kind name is
accepted and names a plugin executable. EnvironmentProvider is gone:
environment settings carry SandboxProviderKind directly, and
is_clone_based is replaced by a workspace policy where local runs in a
designated directory and every other provider clones.

Server sandbox policy is keyed by kind. [server.sandbox.providers.<kind>]
accepts the bundled kinds with `enabled` and any plugin kind with its
launch settings (path, sha256, dev, args, env, inherit_env); bundled
kinds reject the plugin keys and a kind with no entry is disabled. The
OpenAPI schema, generated Rust and TypeScript clients, web settings
pages, and docs follow. The environments table drops its provider CHECK
enumeration in favour of the kind name rules so a plugin environment
can be stored.

Bundled-only code paths (run start, preflight, reconnect, terminal,
details) now fail with an explicit message for a plugin kind until the
driver construction function lands in the next step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:17:32 -06:00

849 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, test_support as store_test_support};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity, SandboxProviderKind, 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::auth;
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;
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<SandboxProviderKind>,
env_lookup: EnvLookup,
llm_catalog_settings: LlmCatalogSettings,
automation_materializer: Option<TestAutomationRunMaterializer>,
#[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(SandboxProviderKind::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);
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<SandboxProviderKind>) -> 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())?;
let automation_materializer_override = self.automation_materializer.map(|materializer| {
materializer.into_materializer(fabro_workflow_version::WorkflowVersionStore::new(
store.blobs(),
))
});
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,
})
}
}
#[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(store_test_support::test_database(
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(SandboxProviderKind::DOCKER),
)
}
pub(crate) fn test_db_pool_for_vault_path_with_default_environment(
vault_path: &Path,
default_environment_provider: Option<SandboxProviderKind>,
) -> 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<SandboxProviderKind>,
) -> anyhow::Result<DbPool> {
std::thread::spawn(move || {
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"
);
}
}