feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,426 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user