Files
core/test/dever-tests/tests/application_config.rs
T

427 lines
15 KiB
Rust

mod support;
use std::fs;
use dever_runtime::config::{Database, LogLevel, PostgresTls, Settings};
const MIXED_SETTING: &str = r#"{
"http": {},
"performance": {
"network_peer": "target/network_bench",
"live_peer": "target/live_bench",
"linux_ci": {"inputs": [], "required_inputs": {}, "author_outputs": []}
},
"database": {
"default": {
"type": "sqlite",
"path": "data/db/cms.db",
"max_connections": 1,
"max_page_size": 100
},
"report": {
"type": "postgres",
"url": "postgres://example.invalid/report",
"tls": "system",
"min_connections": 0,
"max_connections": 4,
"max_page_size": 200
}
},
"log": {}
}"#;
fn project(setting: &str) -> support::temp::TemporaryDirectory {
let root = support::temp::TemporaryDirectory::new();
fs::create_dir(root.path().join("config")).unwrap();
fs::write(root.path().join("config/setting.json"), setting).unwrap();
root
}
#[cfg(feature = "external")]
#[test]
fn worker_file_grants_are_explicit_and_cannot_write_executable_cache() {
let setting = |path: &str, write: bool| {
serde_json::json!({
"adapter": {"sample.worker": {"files": {"content": {"path": path, "write": write}}}}
})
.to_string()
};
let capabilities = vec!["file".into()];
let root = project(&setting("data/upload", true));
let settings = Settings::load_project(root.path()).unwrap();
let grants = settings
.external_grants("sample.worker", &capabilities)
.unwrap();
assert_eq!(grants.len(), 1);
assert_eq!(grants[0].source, root.path().join("data/upload"));
assert_eq!(grants[0].destination, std::path::Path::new("/data/content"));
assert!(grants[0].writable);
assert!(
settings
.external_grants("sample.worker", &[])
.unwrap_err()
.contains("file capability")
);
for path in [
"data",
"data/cache",
"data/cache/lib",
"data/cache/lib/worker",
] {
let root = project(&setting(path, true));
let settings = Settings::load_project(root.path()).unwrap();
assert!(
settings
.external_grants("sample.worker", &capabilities)
.unwrap_err()
.contains("executable resource cache")
);
}
for path in ["../private", "/etc", ""] {
let root = project(&setting(path, false));
let settings = Settings::load_project(root.path()).unwrap();
assert!(
settings
.external_grants("sample.worker", &capabilities)
.unwrap_err()
.contains("relative to the application")
);
}
}
#[test]
fn settings_resolve_connections_and_paths_from_the_application_directory() {
let root = project(MIXED_SETTING);
let settings = Settings::load_project(root.path()).unwrap();
assert_eq!(settings.resolve_database(None, "cms").unwrap().0, "default");
assert_eq!(
settings.resolve_database(None, "report").unwrap().0,
"report"
);
assert!(settings.resolve_database(Some("missing"), "cms").is_err());
match settings.database("default").unwrap() {
Database::Sqlite {
path,
max_connections,
max_page_size,
..
} => {
assert_eq!(path, &root.path().join("data/db/cms.db"));
assert_eq!((*max_connections, *max_page_size), (1, 100));
}
Database::Postgres { .. } => panic!("default must be SQLite"),
}
match settings.database("report").unwrap() {
Database::Postgres {
tls,
wait_timeout_ms,
io_timeout_ms,
..
} => {
assert_eq!(*tls, PostgresTls::System);
assert_eq!((*wait_timeout_ms, *io_timeout_ms), (5_000, 30_000));
}
Database::Sqlite { .. } => panic!("report must be PostgreSQL"),
}
assert!(settings.profile().sqlite);
assert!(settings.profile().postgres);
assert!(
settings
.validate_transaction_bindings(&[&[(None, "cms"), (None, "news")]])
.is_ok()
);
let error = settings
.validate_transaction_bindings(&[&[(None, "cms"), (Some("report"), "cms")]])
.unwrap_err();
assert!(error.contains("multiple database connections"), "{error}");
settings.initialize_data_directories().unwrap();
for directory in ["db", "upload", "log", "cache", "tmp"] {
assert!(root.path().join("data").join(directory).is_dir());
}
}
#[test]
fn tenant_database_settings_use_only_controlled_physical_targets() {
let root = project(
r#"{
"tenant": {
"database": "default",
"max_pools": 8,
"idle_timeout_ms": 60000
},
"database": {
"default": {
"type": "sqlite",
"path": "data/platform.db",
"tenant_directory": "data/tenants"
},
"content": {
"type": "postgres",
"url": "postgres://example.invalid/platform",
"tls": "disabled",
"tenant_database_prefix": "cms_tenant"
},
"platform": {
"type": "sqlite",
"path": "data/platform_only.db"
}
}
}"#,
);
let settings = Settings::load_project(root.path()).unwrap();
let tenant = settings.tenant().unwrap();
assert_eq!(tenant.database(), "default");
assert_eq!(tenant.max_pools(), 8);
assert_eq!(tenant.idle_timeout_ms(), 60_000);
match settings.database("default").unwrap() {
Database::Sqlite {
tenant_directory, ..
} => {
assert_eq!(
tenant_directory.as_ref().unwrap(),
&root.path().join("data/tenants")
);
}
Database::Postgres { .. } => panic!("default must be SQLite"),
}
match settings.database("content").unwrap() {
Database::Postgres {
tenant_database_prefix,
..
} => {
assert_eq!(tenant_database_prefix.as_deref(), Some("cms_tenant"));
}
Database::Sqlite { .. } => panic!("content must be PostgreSQL"),
}
settings
.validate_scoped_database_bindings(
&[(Some("content"), "news", true), (None, "platform", false)],
&[],
)
.unwrap();
let error = settings
.validate_scoped_database_bindings(&[(Some("platform"), "news", true)], &[])
.unwrap_err();
assert!(error.contains("tenant_directory is required"), "{error}");
let transaction = [
(Some("default"), "news", false),
(Some("default"), "news", true),
];
let error = settings
.validate_scoped_database_bindings(&[], &[&transaction])
.unwrap_err();
assert!(
error.contains("cannot mix global and tenant Models"),
"{error}"
);
}
#[test]
fn final_connection_resolution_rejects_table_collisions_and_cross_database_foreign_keys() {
let root = project(MIXED_SETTING);
let settings = Settings::load_project(root.path()).unwrap();
let mut collision_sources = dever_core::source::SourceMap::default();
collision_sources.add(
"user/model/account.dever",
"public type Account { email: Text }",
);
collision_sources.add(
"news/model/account.dever",
"public type Account { slug: Text }",
);
let collision = dever_core::check(&collision_sources).unwrap();
let error = collision.validate_database_settings(&settings).unwrap_err();
assert!(error.contains("share table 'account'"), "{error}");
let mut foreign_key_sources = dever_core::source::SourceMap::default();
foreign_key_sources.add(
"user/model/user.dever",
"database report\npublic type User { email: Text }",
);
foreign_key_sources.add(
"news/model/news.dever",
"public type News { author_id: user.model.user.id }",
);
let foreign_key = dever_core::check(&foreign_key_sources).unwrap();
let error = foreign_key
.validate_database_settings(&settings)
.unwrap_err();
assert!(error.contains("crosses database connections"), "{error}");
let long_field =
"field_name_that_is_longer_than_postgresql_identifier_limit_of_sixty_three_bytes";
let mut identifier_sources = dever_core::source::SourceMap::default();
identifier_sources.add(
"report/model/entry.dever",
format!("database report\npublic type Entry {{ {long_field}: Text }}"),
);
let identifiers = dever_core::check(&identifier_sources).unwrap();
let error = identifiers
.validate_database_settings(&settings)
.unwrap_err();
assert!(error.contains("exceeds 63 bytes"), "{error}");
}
#[test]
fn settings_reject_unknown_fields_missing_default_and_escaping_paths() {
for (setting, expected) in [
(r#"{"linux_ci":{}}"#, "unknown field `linux_ci`"),
(
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","extra":true}}}"#,
"unknown field",
),
(r#"{"database":{}}"#, "database.default is required"),
(
r#"{"database":{"default":{"type":"sqlite","path":"../outside.db"}}}"#,
"must stay below",
),
(
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"prefer"}}}"#,
"unknown variant",
),
(
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","max_connections":0}}}"#,
"must be between",
),
(
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","wait_timeout_ms":0}}}"#,
"wait_timeout_ms must be between",
),
(
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","io_timeout_ms":300001}}}"#,
"io_timeout_ms must be between",
),
(
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","tenant_database_prefix":"tenant_database_prefix_that_exceeds_forty_three_bytes"}}}"#,
"at most 43 lower_snake_case bytes",
),
(
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
"require top-level tenant settings",
),
(
r#"{"tenant":{"database":"missing"},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
"unknown database",
),
(
r#"{"tenant":{"mode":"database","control_database":"default"},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
"unknown field",
),
(
r#"{"tenant":{"max_pools":8},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
"missing field `database`",
),
(
r#"{"tenant":{"database":"default","max_pools":0},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
"max_pools must be between",
),
] {
let root = project(setting);
let error = Settings::load_project(root.path())
.err()
.expect("invalid setting must fail");
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn sites_map_api_parent_directories_to_strict_auth_providers() {
let root = project(
r#"{
"auth": {
"providers": {
"session": {
"verify": "user.account.verify",
"jwtSecret": "0123456789abcdef0123456789abcdef",
"ttlSeconds": 3600,
"cookie": "cms_session"
}
}
},
"sites": {
"admin": {"path": "admin", "auth": "session", "hosts": ["ADMIN.EXAMPLE.COM:443"], "origin": "https://admin.example.com"},
"front": {"path": "front", "auth": "session", "origin": "https://front.example.com:8443"}
},
"log": {"level": "warn"}
}"#,
);
let settings = Settings::load_project(root.path()).unwrap();
let (key, site) = settings
.site_for_directory(&["admin", "article"])
.unwrap()
.unwrap();
assert_eq!(key, "admin");
assert_eq!(site.path(), "admin");
assert_eq!(site.auth(), "session");
assert_eq!(site.hosts(), ["admin.example.com"]);
let origin = site.origin().unwrap();
assert_eq!(
(origin.scheme(), origin.host(), origin.port()),
("https", "admin.example.com", 443)
);
let (_, front) = settings.site_for_directory(&["front"]).unwrap().unwrap();
assert_eq!(front.hosts(), ["front.example.com"]);
let origin = front.origin().unwrap();
assert_eq!(
(origin.scheme(), origin.host(), origin.port()),
("https", "front.example.com", 8443)
);
assert!(settings.site_for_directory(&[]).unwrap().is_none());
assert!(
settings
.site_for_directory(&["administrator"])
.unwrap()
.is_none()
);
assert_eq!(
settings.auth().provider("session").unwrap().verify(),
"user.account.verify"
);
assert_eq!(settings.log().level, LogLevel::Warn);
}
#[test]
fn sites_reject_ambiguous_paths_unknown_providers_and_removed_app_settings() {
for (setting, expected) in [
(r#"{"app":{"name":"cms"}}"#, "unknown field"),
(
r#"{"sites":{"admin":{"path":"admin","auth":"missing"}}}"#,
"unknown provider",
),
(
r#"{
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
"sites":{
"admin":{"path":"admin","auth":"session"},
"nested":{"path":"admin/reports","auth":"session"}
}
}"#,
"overlap",
),
(
r#"{
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
"sites":{"front":{"path":"front","auth":"session","origin":"ftp://cms.example.com"}}
}"#,
"scheme must be http or https",
),
(
r#"{
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
"sites":{"front":{"path":"front","auth":"session","hosts":["api.example.com"],"origin":"https://cms.example.com"}}
}"#,
"origin host must be present",
),
] {
let root = project(setting);
let error = Settings::load_project(root.path())
.err()
.expect("invalid setting must fail");
assert!(error.contains(expected), "{error}");
}
}