fabro/lib/apps/fabro-server/src/test_support.rs
2026-07-28 17:23:31 -04:00

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"
);
}
}