use std::collections::BTreeMap;
use camel_api::CamelError;
use crate::boot_scenario::boot_scenario;
use crate::env_layers::{LayeredEnv, ambient_std};
use crate::parse_scenario_document;
use crate::{RouteSource, ScenarioAction, ScenarioDocument};
const ROUTE: &str = r#"
routes:
- id: boot-route
from: direct:start
steps:
- to: log:info
"#;
const DOC: &str = r#"
routeFiles: [routes.yaml]
scenario:
- sleep:
duration: 1s
"#;
fn project(
camel_toml: &str,
route: Option<(&str, &str)>,
doc: &str,
) -> (tempfile::TempDir, ScenarioDocument) {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("Camel.toml"), camel_toml).expect("write Camel.toml");
if let Some((name, text)) = route {
std::fs::write(dir.path().join(name), text).expect("write route file");
}
let doc_path = dir.path().join("case.test.yaml");
std::fs::write(&doc_path, doc).expect("write document");
let document = parse_scenario_document(&doc_path).expect("document parses");
(dir, document)
}
fn empty_env() -> LayeredEnv {
LayeredEnv::new(BTreeMap::new(), BTreeMap::new(), Vec::new(), ambient_std())
}
fn expect_boot_error(
result: Result<crate::boot_scenario::ScenarioRun, CamelError>,
what: &str,
) -> CamelError {
match result {
Ok(_) => panic!("{what}"),
Err(err) => err,
}
}
#[tokio::test]
async fn boot_rejects_keycloak_offline() {
let (dir, doc) = project(
r#"
[security.keycloak]
server_url = "https://kc.example.com"
realm = "camel"
client_id = "camel-api"
client_secret = "kc-secret"
"#,
Some(("routes.yaml", ROUTE)),
DOC,
);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"keycloak security must be rejected in the offline tier",
);
assert!(
matches!(err, CamelError::AuthProviderUnavailable(_)),
"expected AuthProviderUnavailable, got: {err:?}"
);
}
#[tokio::test]
async fn boot_rejects_oidc_offline() {
let (dir, doc) = project(
r#"
[security.oidc]
issuer = "https://oidc.example.test"
"#,
Some(("routes.yaml", ROUTE)),
DOC,
);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"oidc security must be rejected in the offline tier",
);
assert!(
matches!(err, CamelError::AuthProviderUnavailable(_)),
"expected AuthProviderUnavailable, got: {err:?}"
);
}
#[tokio::test]
async fn boot_rejects_wasm_security_policies() {
let (dir, doc) = project(
r#"
[security.policies.wasm.demo]
path = "policies/demo.wasm"
"#,
Some(("routes.yaml", ROUTE)),
DOC,
);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"wasm security policies must be rejected in the scenario tier",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
assert!(
err.to_string()
.contains("not supported in the scenario tier"),
"error must name the v1 tier limitation: {err}"
);
}
#[tokio::test]
async fn boot_hermetic_env_not_resolved_from_process() {
unsafe { std::env::set_var("RC_6BSF_HERMETIC", "leak") };
let route = r#"
routes:
- id: hermetic
from: direct:${env:RC_6BSF_HERMETIC}
steps:
- to: log:info
"#;
let (dir, doc) = project("# minimal\n", Some(("routes.yaml", route)), DOC);
let result = boot_scenario(&doc, dir.path(), &empty_env()).await;
unsafe { std::env::remove_var("RC_6BSF_HERMETIC") };
let err = expect_boot_error(
result,
"the process environment must not resolve scenario placeholders",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected the unresolved-placeholder Config error, got: {err:?}"
);
let display = err.to_string();
assert!(
display.contains("RC_6BSF_HERMETIC"),
"error must name the variable: {display}"
);
assert!(
display.contains("no layer of the scenario environment defines it"),
"error must name the hermetic resolution failure: {display}"
);
}
#[tokio::test]
async fn boot_missing_route_file_names_the_file() {
let doc = r#"
routeFiles: [nope.yaml]
scenario:
- sleep:
duration: 1s
"#;
let (dir, doc) = project("# minimal\n", None, doc);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a missing declared route file must fail the boot",
);
assert!(
matches!(err, CamelError::Io(_)),
"expected Io, got: {err:?}"
);
assert!(
err.to_string().contains("nope.yaml"),
"error must name the missing file: {err}"
);
}
const NESTED_DOC: &str = r#"
routeFiles: [local.yaml]
scenario:
- sleep:
duration: 1s
"#;
const NESTED_FROM_ROOT_DOC: &str = r#"
routeFilesFromRoot: [rr/root-route.yaml]
scenario:
- sleep:
duration: 1s
"#;
#[tokio::test]
async fn boot_root_walks_to_ancestor() {
let root = tempfile::tempdir().expect("temp dir");
let sub = root.path().join("sub");
std::fs::create_dir(&sub).expect("mkdir sub");
std::fs::create_dir_all(root.path().join("rr")).expect("mkdir rr");
std::fs::write(root.path().join("Camel.toml"), "# minimal\n").expect("write Camel.toml");
std::fs::write(root.path().join("rr/root-route.yaml"), ROUTE).expect("write root route");
std::fs::write(sub.join("local.yaml"), ROUTE).expect("write local route");
let files_path = sub.join("doc.test.yaml");
std::fs::write(&files_path, NESTED_DOC).expect("write document");
let files_doc = parse_scenario_document(&files_path).expect("document parses");
let from_root_path = sub.join("from-root.test.yaml");
std::fs::write(&from_root_path, NESTED_FROM_ROOT_DOC).expect("write document");
let from_root_doc = parse_scenario_document(&from_root_path).expect("document parses");
boot_scenario(&files_doc, root.path(), &empty_env())
.await
.expect("relative routeFiles must anchor at the document directory");
boot_scenario(&from_root_doc, root.path(), &empty_env())
.await
.expect("routeFilesFromRoot must follow the resolved root");
}
#[tokio::test]
async fn route_files_anchor_to_doc_dir() {
let root = tempfile::tempdir().expect("temp dir");
let sub = root.path().join("sub");
std::fs::create_dir(&sub).expect("mkdir sub");
std::fs::write(root.path().join("Camel.toml"), "# minimal\n").expect("write Camel.toml");
std::fs::write(sub.join("local.yaml"), ROUTE).expect("write local route");
let doc_path = sub.join("case.test.yaml");
std::fs::write(&doc_path, NESTED_DOC).expect("write document");
let doc = parse_scenario_document(&doc_path).expect("document parses");
boot_scenario(&doc, root.path(), &empty_env())
.await
.expect("the colocated route file must load from the document directory");
}
#[tokio::test]
async fn boot_inline_routes_still_rejected() {
let routes = camel_dsl::parse_yaml(
"routes:\n - id: inline-route\n from: direct:start\n steps:\n - to: log:info\n",
)
.expect("inline routes parse");
let doc = ScenarioDocument {
source_path: std::path::PathBuf::new(),
route_source: RouteSource::Inline(routes),
scenario: vec![ScenarioAction::Sleep {
duration: std::time::Duration::from_secs(1),
}],
partners: None,
env: None,
env_passthrough: None,
profile: None,
send_deadline: None,
inbound: None,
logs: None,
};
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("Camel.toml"), "# minimal\n").expect("write Camel.toml");
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"inline route sources must not boot in v1",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
assert!(
err.to_string()
.contains("inline route sources cannot boot in v1"),
"error must keep the inline-routes message: {err}"
);
}
#[cfg(not(feature = "http"))]
#[tokio::test]
async fn boot_rejects_inbound_without_http_feature() {
let doc = ScenarioDocument {
source_path: std::path::PathBuf::new(),
route_source: RouteSource::RouteFiles(vec!["routes.yaml".into()]),
scenario: vec![ScenarioAction::Sleep {
duration: std::time::Duration::from_secs(1),
}],
partners: None,
env: None,
env_passthrough: None,
profile: None,
send_deadline: None,
inbound: Some(crate::InboundListener {
bind_var: "INBOUND".to_string(),
}),
logs: None,
};
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("Camel.toml"), "# minimal\n").expect("write Camel.toml");
std::fs::write(dir.path().join("routes.yaml"), ROUTE).expect("write route file");
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"an inbound document must not boot without the http feature",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
assert!(
err.to_string().contains("http` feature"),
"error must name the missing feature: {err}"
);
}
#[tokio::test]
async fn boot_rejects_test_suffix_route_file() {
let doc = r#"
routeFiles: [routes.test.yaml]
scenario:
- sleep:
duration: 1s
"#;
let (dir, doc) = project("# minimal\n", Some(("routes.test.yaml", ROUTE)), doc);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a reserved-suffix route file must fail the boot",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
let display = err.to_string();
assert!(
display.contains("routes.test.yaml"),
"error must name the declared file: {display}"
);
assert!(
display.contains("`camel test`"),
"error must carry the `camel test` guidance: {display}"
);
}
#[tokio::test]
async fn boot_rejects_job_suffix_route_file() {
let doc = r#"
routeFiles: [routes.job.yaml]
scenario:
- sleep:
duration: 1s
"#;
let (dir, doc) = project("# minimal\n", Some(("routes.job.yaml", ROUTE)), doc);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a reserved-suffix route file must fail the boot",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
let display = err.to_string();
assert!(
display.contains("routes.job.yaml"),
"error must name the declared file: {display}"
);
assert!(
display.contains("`camel job`"),
"error must carry the `camel job` guidance: {display}"
);
}
const NO_SQL_DOC: &str = r#"
routeFiles: [routes.yaml]
scenario:
- sleep:
duration: 1s
"#;
fn assert_not_memory_lint(
result: Result<crate::boot_scenario::ScenarioRun, CamelError>,
what: &str,
) {
if let Err(err) = result {
let display = err.to_string();
assert!(
!display.contains(crate::SQL_MEMORY_NOT_SHARED),
"{what}: unexpected sql-memory-not-shared lint: {display}"
);
}
}
#[tokio::test]
async fn bare_memory_sqlite_rejected() {
let (dir, doc) = project(
r#"
[datasources.appdb]
db_url = "sqlite::memory:"
"#,
None,
NO_SQL_DOC,
);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a bare sqlite :memory: datasource must fail the boot",
);
assert!(
matches!(err, CamelError::Config(_)),
"expected Config, got: {err:?}"
);
let display = err.to_string();
assert!(
display.contains(crate::SQL_MEMORY_NOT_SHARED),
"error must carry the sql-memory-not-shared key: {display}"
);
assert!(
display.contains("appdb"),
"error must name the datasource: {display}"
);
}
#[tokio::test]
async fn shared_cache_memory_sqlite_passes_lint() {
let (dir, doc) = project(
r#"
[datasources.appdb]
db_url = "sqlite::memory:?cache=shared"
"#,
None,
NO_SQL_DOC,
);
assert_not_memory_lint(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a shared-cache sqlite :memory: datasource must pass the lint",
);
}
#[tokio::test]
async fn file_backed_sqlite_unaffected() {
let (dir, doc) = project(
r#"
[datasources.appdb]
db_url = "sqlite:file:/tmp/x.db"
"#,
None,
NO_SQL_DOC,
);
assert_not_memory_lint(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"a file-backed sqlite datasource must pass the lint",
);
}
#[cfg(not(feature = "sql"))]
#[tokio::test]
async fn lint_is_ungated() {
let (dir, doc) = project(
r#"
[datasources.appdb]
db_url = "sqlite::memory:"
"#,
None,
NO_SQL_DOC,
);
let err = expect_boot_error(
boot_scenario(&doc, dir.path(), &empty_env()).await,
"the sqlite memory lint must fire without the sql feature",
);
assert!(
err.to_string().contains(crate::SQL_MEMORY_NOT_SHARED),
"error must carry the sql-memory-not-shared key: {err}"
);
}
#[cfg(feature = "sql")]
mod boot_freshness {
use std::sync::Arc;
use camel_api::datasource::DatasourceCatalog;
use sqlx::Row;
use super::{DOC, ROUTE, boot_scenario, empty_env, project};
use crate::sql_action::{SqlAction, execute_sql_prepare};
const MEMORY_TOML: &str = r#"
[datasources.appdb]
db_url = "sqlite::memory:?cache=shared"
max_connections = 1
"#;
async fn seed(catalog: &Arc<dyn DatasourceCatalog>, tag: &str) {
execute_sql_prepare(
catalog,
&SqlAction {
datasource: "appdb".into(),
prepare: vec![
"CREATE TABLE IF NOT EXISTS seeded (id INTEGER PRIMARY KEY, label TEXT)".into(),
format!("INSERT INTO seeded (label) VALUES ('{tag}')"),
],
},
)
.await
.expect("seed prepare");
}
async fn count(catalog: &Arc<dyn DatasourceCatalog>) -> i64 {
let handle = catalog.get_pool("appdb").await.expect("pool");
let pool = handle.downcast::<sqlx::AnyPool>().expect("any pool");
let row = sqlx::query("SELECT COUNT(*) FROM seeded")
.fetch_one(&*pool)
.await
.expect("count query");
row.try_get::<i64, usize>(0).expect("count as i64")
}
#[tokio::test]
async fn second_boot_over_same_memory_alias_starts_empty() {
let (dir, doc) = project(MEMORY_TOML, Some(("routes.yaml", ROUTE)), DOC);
let mut run_a = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot A");
let catalog_a = run_a.boot.datasource_catalog();
seed(&catalog_a, "a").await;
assert_eq!(
count(&catalog_a).await,
1,
"boot A must see its own seeded row"
);
run_a
.boot
.shutdown(&mut run_a.ctx)
.await
.expect("shutdown A");
drop(run_a);
let mut run_b = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot B");
let catalog_b = run_b.boot.datasource_catalog();
seed(&catalog_b, "b").await;
let count_b = count(&catalog_b).await;
assert_eq!(
count_b, 1,
"boot B must start from an empty database — boot A's rows leaked across boots"
);
run_b
.boot
.shutdown(&mut run_b.ctx)
.await
.expect("shutdown B");
}
#[tokio::test]
async fn named_shared_memory_uri_dies_with_its_boot() {
const NAMED_TOML: &str = r#"
[datasources.appdb]
db_url = "sqlite:file:memdb_isolation_probe?mode=memory&cache=shared"
max_connections = 1
provider = "sqlx"
"#;
let (dir, doc) = project(NAMED_TOML, Some(("routes.yaml", ROUTE)), DOC);
let mut run_a = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot A");
let catalog_a = run_a.boot.datasource_catalog();
seed(&catalog_a, "a").await;
assert_eq!(count(&catalog_a).await, 1);
run_a
.boot
.shutdown(&mut run_a.ctx)
.await
.expect("shutdown A");
drop(run_a);
let mut run_b = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot B");
let catalog_b = run_b.boot.datasource_catalog();
seed(&catalog_b, "b").await;
let count_b = count(&catalog_b).await;
assert_eq!(
count_b, 1,
"the named shared memory database must die with boot A — B sees A's rows"
);
run_b
.boot
.shutdown(&mut run_b.ctx)
.await
.expect("shutdown B");
}
#[tokio::test]
async fn shutdown_closes_the_datasource_pools() {
let (dir, doc) = project(MEMORY_TOML, Some(("routes.yaml", ROUTE)), DOC);
let mut run = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot");
let catalog = run.boot.datasource_catalog();
seed(&catalog, "x").await;
let handle = catalog.get_pool("appdb").await.expect("pool");
let pool = handle.downcast::<sqlx::AnyPool>().expect("any pool");
run.boot.shutdown(&mut run.ctx).await.expect("shutdown");
assert!(
pool.is_closed(),
"boot teardown must close the datasource pools (bd rc-25lup.4)"
);
}
#[tokio::test]
async fn file_backed_state_persists_across_boots() {
let (dir, doc) = project("", Some(("routes.yaml", ROUTE)), DOC);
let camel_toml = format!(
"[datasources.appdb]\ndb_url = \"sqlite://{}/shared.db?mode=rwc\"\n",
dir.path().display()
);
std::fs::write(dir.path().join("Camel.toml"), camel_toml).expect("rewrite Camel.toml");
let mut run_a = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot A");
let catalog_a = run_a.boot.datasource_catalog();
seed(&catalog_a, "a").await;
run_a
.boot
.shutdown(&mut run_a.ctx)
.await
.expect("shutdown A");
drop(run_a);
let mut run_b = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot B");
let catalog_b = run_b.boot.datasource_catalog();
let count_b = count(&catalog_b).await;
assert_eq!(
count_b, 1,
"file-backed rows persist across boots — the clean-first idiom owns isolation"
);
run_b
.boot
.shutdown(&mut run_b.ctx)
.await
.expect("shutdown B");
}
#[tokio::test]
async fn named_memory_uri_shares_across_pool_connections() {
const NAMED_TOML: &str = r#"
[datasources.appdb]
db_url = "sqlite:file:memdb_sharing_probe?mode=memory&cache=shared"
max_connections = 3
provider = "sqlx"
"#;
let (dir, doc) = project(NAMED_TOML, Some(("routes.yaml", ROUTE)), DOC);
let mut run_a = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot A");
let catalog_a = run_a.boot.datasource_catalog();
let handle_a = catalog_a.get_pool("appdb").await.expect("pool");
let pool_a = handle_a.downcast::<sqlx::AnyPool>().expect("any pool");
let mut conn_a = pool_a.acquire().await.expect("conn A");
let mut conn_b = tokio::time::timeout(std::time::Duration::from_secs(10), pool_a.acquire())
.await
.expect("conn B acquire must not hang")
.expect("conn B");
sqlx::query("CREATE TABLE IF NOT EXISTS seeded (id INTEGER PRIMARY KEY, label TEXT)")
.execute(&mut *conn_a)
.await
.expect("create via conn A");
sqlx::query("INSERT INTO seeded (label) VALUES ('a')")
.execute(&mut *conn_a)
.await
.expect("insert via conn A");
let row = sqlx::query("SELECT COUNT(*) FROM seeded")
.fetch_one(&mut *conn_b)
.await
.expect("count via conn B");
let count_via_b = row.try_get::<i64, usize>(0).expect("count as i64");
assert_eq!(
count_via_b, 1,
"the row written via conn A must be visible via conn B — \
the named shared-memory database must share across pool connections"
);
drop(conn_a);
drop(conn_b);
run_a
.boot
.shutdown(&mut run_a.ctx)
.await
.expect("shutdown A");
drop(run_a);
let mut run_b = boot_scenario(&doc, dir.path(), &empty_env())
.await
.expect("boot B");
let catalog_b = run_b.boot.datasource_catalog();
seed(&catalog_b, "b").await;
let count_b = count(&catalog_b).await;
assert_eq!(
count_b, 1,
"boot B must start from an empty database — boot A's shared rows leaked across boots"
);
run_b
.boot
.shutdown(&mut run_b.ctx)
.await
.expect("shutdown B");
}
}