934 lines
31 KiB
Rust
934 lines
31 KiB
Rust
#[path = "support/temp.rs"]
|
|
mod temp;
|
|
|
|
use std::fs;
|
|
|
|
use dever_core::source::SourceMap;
|
|
use dever_runtime::config::Settings;
|
|
|
|
fn sources(files: &[(&str, &str)]) -> SourceMap {
|
|
let mut sources = SourceMap::default();
|
|
for (path, source) in files {
|
|
sources.add(*path, *source);
|
|
}
|
|
sources
|
|
}
|
|
|
|
fn check(files: &[(&str, &str)]) {
|
|
let sources = sources(files);
|
|
dever_core::check(&sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
}
|
|
|
|
fn reject(files: &[(&str, &str)], expected: &str) {
|
|
let sources = sources(files);
|
|
let errors = dever_core::check(&sources).expect_err("source must be rejected");
|
|
let rendered = errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>();
|
|
assert!(
|
|
rendered.contains(expected),
|
|
"expected {expected}: {rendered}"
|
|
);
|
|
}
|
|
|
|
fn auth_settings() -> (temp::TemporaryDirectory, Settings) {
|
|
let root = temp::TemporaryDirectory::new();
|
|
fs::create_dir(root.path().join("config")).unwrap();
|
|
fs::write(
|
|
root.path().join("config/setting.json"),
|
|
r#"{
|
|
"auth": {
|
|
"providers": {
|
|
"session": {
|
|
"verify": "user.account.verify",
|
|
"jwtSecret": "0123456789abcdef0123456789abcdef"
|
|
}
|
|
}
|
|
},
|
|
"sites": {
|
|
"admin": {"path": "admin", "auth": "session"}
|
|
},
|
|
"database": {
|
|
"default": {
|
|
"type": "sqlite",
|
|
"path": "data/control.db",
|
|
"tenant_directory": "data/tenants"
|
|
}
|
|
},
|
|
"tenant": {
|
|
"database": "default"
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let settings = Settings::load_project(root.path()).unwrap();
|
|
(root, settings)
|
|
}
|
|
|
|
fn multi_provider_settings() -> (temp::TemporaryDirectory, Settings) {
|
|
let (root, _) = auth_settings();
|
|
let setting_path = root.path().join("config/setting.json");
|
|
let mut settings: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(&setting_path).unwrap()).unwrap();
|
|
settings["auth"]["providers"]["other"] = serde_json::json!({
|
|
"verify": "admin.principal.verify",
|
|
"jwtSecret": "0123456789abcdef0123456789abcdef",
|
|
});
|
|
settings["sites"]["other"] = serde_json::json!({"path": "other", "auth": "other"});
|
|
fs::write(&setting_path, settings.to_string()).unwrap();
|
|
let settings = Settings::load_project(root.path()).unwrap();
|
|
(root, settings)
|
|
}
|
|
|
|
fn identity_sources() -> [(&'static str, &'static str); 3] {
|
|
[
|
|
(
|
|
"user/account/model.dever",
|
|
"global type Account { email: Text(1, 254) }",
|
|
),
|
|
(
|
|
"platform/tenant/model.dever",
|
|
"global type Tenant { name: Text(1, 64) }",
|
|
),
|
|
(
|
|
"user/account/app.dever",
|
|
r#"type Identity {
|
|
id: Text
|
|
user_id: user.account.model.id?
|
|
tenant_id: platform.tenant.model.id?
|
|
}
|
|
verify(claims: dever.auth.Claims) (identity: Identity) {
|
|
identity = Identity {
|
|
id = claims.subject
|
|
user_id = null
|
|
tenant_id = null
|
|
}
|
|
}
|
|
profile() (response: Text) {
|
|
response = dever.site.key()
|
|
}"#,
|
|
),
|
|
]
|
|
}
|
|
|
|
fn configured_check(
|
|
files: &[(&str, &str)],
|
|
settings: &Settings,
|
|
) -> Result<dever_core::hir::Program, String> {
|
|
let sources = sources(files);
|
|
dever_core::check_with_settings(&sources, settings).map_err(|errors| {
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn api_bindings_inherit_app_signatures_without_response_name_requirement() {
|
|
check(&[
|
|
(
|
|
"user/account/app.dever",
|
|
"greet(name: Text) (greeting: Text) { greeting = text.trim(name) }",
|
|
),
|
|
(
|
|
"user/account/api.dever",
|
|
"get greeting = app.greet\npost greeting = app.greet\nput greeting = app.greet\ndelete greeting = app.greet\ncmd greeting = app.greet",
|
|
),
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_sites_bind_nested_api_directories_to_checked_auth_hooks() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.push((
|
|
"user/account/api/admin/profile.dever",
|
|
"public get profile = app.profile",
|
|
));
|
|
|
|
let source_map = sources(&files);
|
|
let program =
|
|
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&source_map))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
let generated = dever_core::native::emit_project(
|
|
&program,
|
|
&source_map,
|
|
dever_runtime::config::RuntimeProfile::default(),
|
|
)
|
|
.unwrap();
|
|
assert!(generated.contains("dever_runtime::auth::prepare(&[\"admin\"]"));
|
|
assert!(generated.contains("dever_runtime::auth::scope(auth_prepared, auth_identity"));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_sites_reject_unmapped_api_directories_and_invalid_verify_records() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut unmapped = identity_sources().to_vec();
|
|
unmapped.push((
|
|
"user/account/api/front/profile.dever",
|
|
"get profile = app.profile",
|
|
));
|
|
let error = configured_check(&unmapped, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("not assigned to a configured site"),
|
|
"{error}"
|
|
);
|
|
|
|
let invalid_app = identity_sources()[2]
|
|
.1
|
|
.replace("tenant_id: platform.tenant.model.id?", "tenant_id: Int?");
|
|
let mut invalid = identity_sources().to_vec();
|
|
invalid[2] = ("user/account/app.dever", &invalid_app);
|
|
invalid.push((
|
|
"user/account/api/admin/profile.dever",
|
|
"get profile = app.profile",
|
|
));
|
|
let error = configured_check(&invalid, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("identity must be an App-owned public record"),
|
|
"{error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_is_read_only() {
|
|
let (_root, settings) = auth_settings();
|
|
let writing_verify = identity_sources()[2].1.replace(
|
|
"identity = Identity {",
|
|
"account = model.create({ email = claims.subject })\n identity = Identity {",
|
|
);
|
|
let mut writing = identity_sources().to_vec();
|
|
writing[2] = ("user/account/app.dever", &writing_verify);
|
|
writing.push((
|
|
"user/account/api/admin/profile.dever",
|
|
"get profile = app.profile",
|
|
));
|
|
let error = configured_check(&writing, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("authentication verify cannot write"),
|
|
"{error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_identity_getters_preserve_nominal_ids_and_nullable_representation() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
(
|
|
"news/reader/app.dever",
|
|
r#"
|
|
type Profile {
|
|
user: user.account.model.id?
|
|
tenant: platform.tenant.model.id?
|
|
raw: Int?
|
|
}
|
|
profile() (profile: Profile) {
|
|
profile = Profile {
|
|
user = dever.auth.user_id()
|
|
tenant = dever.auth.tenant_id()
|
|
raw = dever.auth.user_id()
|
|
}
|
|
}
|
|
current_user() (user: user.account.model.id) {
|
|
user = require_user(dever.auth.user_id())
|
|
}
|
|
require_user(value: user.account.model.id) (user: user.account.model.id) {
|
|
user = value
|
|
}
|
|
require_user(value: null) (user: user.account.model.id) {
|
|
fail(dever.api.Error.Unauthorized)
|
|
}
|
|
owns(id: user.account.model.id) (owned: Bool) {
|
|
owned = dever.auth.owns_user(id)
|
|
}
|
|
"#,
|
|
),
|
|
(
|
|
"news/reader/api/admin/profile.dever",
|
|
"get profile = app.profile\nget current = app.current_user\nget owns = app.owns",
|
|
),
|
|
]);
|
|
let source_map = sources(&files);
|
|
dever_core::check(&source_map).expect("source-only checking defers provider binding");
|
|
let program =
|
|
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&source_map))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
let native = dever_core::native::emit_project(
|
|
&program,
|
|
&source_map,
|
|
dever_runtime::config::RuntimeProfile {
|
|
sqlite: true,
|
|
postgres: false,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
|
|
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
|
|
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
|
|
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
|
|
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_identity_getters_reject_wrong_ids_and_implicit_casts() {
|
|
let (_root, settings) = auth_settings();
|
|
for (app, message) in [
|
|
(
|
|
"profile() (value: platform.tenant.model.id?) { value = dever.auth.user_id() }",
|
|
"nominal ID must match provider",
|
|
),
|
|
(
|
|
"profile() (value: user.account.model.id?) { value = dever.auth.tenant_id() }",
|
|
"nominal ID must match provider",
|
|
),
|
|
(
|
|
"profile(id: platform.tenant.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
|
|
"nominal ID must match provider",
|
|
),
|
|
(
|
|
"profile() (value: user.account.model.id) { value = dever.auth.user_id() }",
|
|
"C005",
|
|
),
|
|
(
|
|
"profile(raw: Int?) (value: user.account.model.id?) { value = raw }",
|
|
"C005",
|
|
),
|
|
(
|
|
"profile() (value: user.account.model.id?) { value = 1 }",
|
|
"C005",
|
|
),
|
|
(
|
|
"profile() (value: user.account.model.id?) { raw = dever.auth.user_id()\nvalue = raw }",
|
|
"C005",
|
|
),
|
|
] {
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
("news/reader/app.dever", app),
|
|
(
|
|
"news/reader/api/admin/profile.dever",
|
|
"get profile = app.profile",
|
|
),
|
|
]);
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(error.contains(message), "{app}: {error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_identity_getters_require_matching_providers_at_every_route() {
|
|
let (_root, settings) = multi_provider_settings();
|
|
let other_provider = identity_sources()[2]
|
|
.1
|
|
.replace("user.account.model.id?", "admin.principal.model.id?");
|
|
for app in [
|
|
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
|
|
"profile(id: user.account.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
|
|
] {
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
(
|
|
"admin/principal/model.dever",
|
|
"global type Principal { name: Text }",
|
|
),
|
|
("admin/principal/app.dever", other_provider.as_str()),
|
|
("news/reader/app.dever", app),
|
|
(
|
|
"news/reader/api/admin/profile.dever",
|
|
"get profile = app.profile",
|
|
),
|
|
(
|
|
"news/reader/api/other/profile.dever",
|
|
"get profile = app.profile",
|
|
),
|
|
]);
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("C006") && error.contains("provider 'other'"),
|
|
"{error}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rest_identity_bindings_preserve_matching_nominal_ids() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
(
|
|
"news/article/model.dever",
|
|
r#"type Article {
|
|
private owner author: user.account.model.id = dever.auth.user_id()
|
|
title: Text
|
|
linked_user: user.account.model.id? create = dever.auth.user_id() replace = dever.auth.user_id()
|
|
linked_tenant: platform.tenant.model.id? create = dever.auth.tenant_id() replace = dever.auth.tenant_id()
|
|
}"#,
|
|
),
|
|
("news/article/api/admin/manage.dever", "rest model"),
|
|
]);
|
|
check(&files);
|
|
let program = configured_check(&files, &settings).unwrap();
|
|
let source_map = sources(&files);
|
|
let native = dever_core::native::emit_project(
|
|
&program,
|
|
&source_map,
|
|
dever_runtime::config::RuntimeProfile {
|
|
sqlite: true,
|
|
postgres: false,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
|
|
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
|
|
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
|
|
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
|
|
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
|
|
}
|
|
|
|
#[test]
|
|
fn rest_identity_bindings_reject_wrong_provider_ids() {
|
|
let (_root, settings) = auth_settings();
|
|
for field in [
|
|
"linked: platform.tenant.model.id? create = dever.auth.user_id()",
|
|
"linked: platform.tenant.model.id? replace = dever.auth.user_id()",
|
|
"linked: user.account.model.id? create = dever.auth.tenant_id()",
|
|
"linked: user.account.model.id? replace = dever.auth.tenant_id()",
|
|
"private owner linked: platform.tenant.model.id = dever.auth.user_id()",
|
|
"private owner linked: user.account.model.id = dever.auth.tenant_id()",
|
|
] {
|
|
let model = format!("type Article {{\n title: Text\n {field}\n}}");
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
("news/article/model.dever", model.as_str()),
|
|
("news/article/api/admin/manage.dever", "rest model"),
|
|
]);
|
|
check(&files);
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("C006") && error.contains("provider 'session'"),
|
|
"{field}: {error}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rest_identity_bindings_match_each_sites_provider() {
|
|
let (_root, settings) = multi_provider_settings();
|
|
let other_provider = identity_sources()[2]
|
|
.1
|
|
.replace("user.account.model.id?", "admin.principal.model.id?");
|
|
for field in [
|
|
"linked: user.account.model.id? create = dever.auth.user_id()",
|
|
"linked: user.account.model.id? replace = dever.auth.user_id()",
|
|
"private owner linked: user.account.model.id = dever.auth.user_id()",
|
|
] {
|
|
let model = format!("type Article {{\n title: Text\n {field}\n}}");
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
(
|
|
"admin/principal/model.dever",
|
|
"global type Principal { name: Text }",
|
|
),
|
|
("admin/principal/app.dever", other_provider.as_str()),
|
|
("news/article/model.dever", model.as_str()),
|
|
("news/article/api/admin/manage.dever", "rest model"),
|
|
]);
|
|
configured_check(&files, &settings).expect("first site's provider matches");
|
|
files.push(("news/article/api/other/manage.dever", "rest model"));
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("C006") && error.contains("provider 'other'"),
|
|
"{field}: {error}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nominal_identity_getters_do_not_gain_public_cmd_or_job_context() {
|
|
let (_root, settings) = auth_settings();
|
|
let app = "profile() (value: user.account.model.id?) { value = dever.auth.user_id() }";
|
|
for (path, declaration, message) in [
|
|
(
|
|
"news/reader/api/admin/profile.dever",
|
|
"public get profile = app.profile",
|
|
"requires a protected API",
|
|
),
|
|
(
|
|
"news/reader/api.dever",
|
|
"cmd profile = app.profile",
|
|
"not available from CMD or Job",
|
|
),
|
|
(
|
|
"news/reader/job.dever",
|
|
"database default\njob sample() () retry(3) timeout(1000) { value = app.profile() }",
|
|
"not available from CMD or Job",
|
|
),
|
|
] {
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([("news/reader/app.dever", app), (path, declaration)]);
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(error.contains("C006") && error.contains(message), "{error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nominal_identity_getters_remain_unavailable_to_ordinary_tests() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.push((
|
|
"news/reader/app.dever",
|
|
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
|
|
));
|
|
let mut inputs = sources(&files).compilation_sources().unwrap();
|
|
inputs.push(dever_core::source::CompilationSource {
|
|
logical_path: "news/reader/probe.dever".into(),
|
|
is_test: true,
|
|
text: "probe() () { value = app.profile() }".into(),
|
|
});
|
|
let source_map = SourceMap::from_compilation_sources(&inputs).unwrap();
|
|
let errors = dever_core::check_with_settings(&source_map, &settings).unwrap_err();
|
|
let rendered = errors
|
|
.iter()
|
|
.map(|error| error.render(&source_map))
|
|
.collect::<String>();
|
|
assert!(
|
|
rendered.contains("C006") && rendered.contains("not available from tests"),
|
|
"{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn protected_api_permissions_are_stable_and_unique_across_paths_and_methods() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.push((
|
|
"user/account/api/admin/profile.dever",
|
|
"get profile = app.profile",
|
|
));
|
|
let program = configured_check(&files, &settings).unwrap();
|
|
assert!(program.api_snapshot().contains(
|
|
"permission user.account.admin.profile method=GET component=user domain=account site=admin action=profile"
|
|
));
|
|
|
|
files.push((
|
|
"user/account/api/admin/alternate.dever",
|
|
"post profile = app.profile",
|
|
));
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("duplicate API permission 'user.account.admin.profile'"),
|
|
"{error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn public_visibility_and_identity_stay_on_http_app_bindings() {
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/app.dever",
|
|
"profile() (response: Text) { response = \"ok\" }",
|
|
),
|
|
(
|
|
"user/account/api/admin/profile.dever",
|
|
"public cmd profile = app.profile",
|
|
),
|
|
],
|
|
"application visibility is defined by the source role",
|
|
);
|
|
reject(
|
|
&[(
|
|
"user/account/domain.dever",
|
|
"profile() (response: Text) { response = dever.auth.id() }",
|
|
)],
|
|
"available only from App functions",
|
|
);
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/app.dever",
|
|
"manage() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
|
|
),
|
|
(
|
|
"user/account/api/admin/manage.dever",
|
|
"public post manage = app.manage",
|
|
),
|
|
],
|
|
"public API call chains cannot administer authorization",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn public_api_cannot_reach_a_tenant_component() {
|
|
let (_root, settings) = auth_settings();
|
|
let mut files = identity_sources().to_vec();
|
|
files.extend([
|
|
(
|
|
"news/article/model.dever",
|
|
"type Article { title: Text(1, 160) }",
|
|
),
|
|
(
|
|
"news/article/app.dever",
|
|
"count() (value: Int) { value = model.count() }",
|
|
),
|
|
(
|
|
"news/article/api/admin/browse.dever",
|
|
"public get count = app.count",
|
|
),
|
|
]);
|
|
let error = configured_check(&files, &settings).unwrap_err();
|
|
assert!(
|
|
error.contains("public API cannot reach tenant components [news]"),
|
|
"{error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_bindings_accept_wire_records_but_query_bindings_require_scalars() {
|
|
let app = "type Input { name: Text }\nupdate(input: Input) (value: Text) { value = text.trim(input.name) }";
|
|
check(&[
|
|
("user/account/app.dever", app),
|
|
(
|
|
"user/account/api.dever",
|
|
"put profile = app.update\ncmd update = app.update",
|
|
),
|
|
]);
|
|
reject(
|
|
&[
|
|
("user/account/app.dever", app),
|
|
("user/account/api.dever", "get profile = app.update"),
|
|
],
|
|
"wire-safe scalars",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_bindings_reject_legacy_bodies_cross_domain_and_ambiguous_targets() {
|
|
reject(
|
|
&[(
|
|
"user/account/api.dever",
|
|
"get_name() (response: Text) { response = \"x\" }",
|
|
)],
|
|
"handler function bodies and legacy prefixes",
|
|
);
|
|
reject(
|
|
&[("user/account/api.dever", "post save = user.profile.save")],
|
|
"same domain",
|
|
);
|
|
reject(
|
|
&[("user/account/api.dever", "post save = app.missing")],
|
|
"unambiguous same-domain",
|
|
);
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/app.dever",
|
|
"save() (value: Text) { value = \"x\" }\nsave(name: Text) (value: Text) { value = text.trim(name) }",
|
|
),
|
|
("user/account/api.dever", "post save = app.save"),
|
|
],
|
|
"unambiguous same-domain",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_routes_and_commands_are_rejected() {
|
|
let app = "greet() (value: Text) { value = \"hello\" }";
|
|
for (declaration, diagnostic) in [
|
|
("get hello = app.greet", "duplicate HTTP route"),
|
|
("cmd hello = app.greet", "duplicate command"),
|
|
] {
|
|
let api = format!("{declaration}\n{declaration}");
|
|
reject(
|
|
&[
|
|
("user/account/app.dever", app),
|
|
("user/account/api.dever", &api),
|
|
],
|
|
diagnostic,
|
|
);
|
|
}
|
|
reject(
|
|
&[
|
|
("user/account/app.dever", app),
|
|
("user/account/api.dever", "cmd hello = app.greet"),
|
|
(
|
|
"user/account/api/maintenance.dever",
|
|
"cmd hello = app.greet",
|
|
),
|
|
],
|
|
"duplicate command",
|
|
);
|
|
}
|
|
|
|
fn sql_projection_files(sqlite: &str, postgres: &str, method: &str) -> [(&'static str, String); 3] {
|
|
[
|
|
(
|
|
"news/article/model.dever",
|
|
format!(
|
|
"type Article {{\n name: Text\n private draft: Bool default true\n}}\n\
|
|
sql lookup(name: Text, size: Int) (rows: List<app.View>) {{\n\
|
|
sqlite = {sqlite:?}\n postgres = {postgres:?}\n}}\n"
|
|
),
|
|
),
|
|
(
|
|
"news/article/app.dever",
|
|
"type View { name: Text }\n\
|
|
read(name: Text, size: Int) (rows: List<View>) {\n\
|
|
rows = model.lookup(name, size)\n}\n"
|
|
.into(),
|
|
),
|
|
(
|
|
"news/article/api.dever",
|
|
format!("{method} read = app.read\n"),
|
|
),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn get_accepts_only_proven_single_model_sql_projections() {
|
|
for sqlite in [
|
|
"SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
|
"select \"name\" from \"article\" where \"name\" = ?1 AND name = ?1 order by id desc, name asc limit ?2;",
|
|
] {
|
|
let postgres = sqlite.replace('?', "$");
|
|
let files = sql_projection_files(sqlite, &postgres, "get");
|
|
check(
|
|
&files
|
|
.iter()
|
|
.map(|(path, source)| (*path, source.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unproven_sql_keeps_its_write_effect_in_each_dialect() {
|
|
let plain = "SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2";
|
|
for unproven in [
|
|
"SELECT name FROM other_article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
|
"SELECT missing FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
|
"SELECT lower(name) FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
|
"WITH changed AS (UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name) SELECT name FROM changed",
|
|
"SELECT name FROM article WHERE name = ?1 LIMIT ?2; DELETE FROM article",
|
|
"UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name",
|
|
"SELECT name INTO copied_article FROM article WHERE name = ?1 LIMIT ?2",
|
|
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 FOR UPDATE",
|
|
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 -- unproved comment",
|
|
] {
|
|
let plain_postgres = plain.replace('?', "$");
|
|
let unproven_postgres = unproven.replace('?', "$");
|
|
for (sqlite, postgres) in [
|
|
(unproven, plain_postgres.as_str()),
|
|
(plain, unproven_postgres.as_str()),
|
|
] {
|
|
let files = sql_projection_files(sqlite, postgres, "get");
|
|
reject(
|
|
&files
|
|
.iter()
|
|
.map(|(path, source)| (*path, source.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
"GET App call chains cannot write",
|
|
);
|
|
let files = sql_projection_files(sqlite, postgres, "post");
|
|
check(
|
|
&files
|
|
.iter()
|
|
.map(|(path, source)| (*path, source.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn get_rejects_writes_reached_through_cross_domain_app_calls() {
|
|
let writer = "create(name: Text) (value: Text) { account = model.create({ name = text.trim(name) })\nvalue = account.name }";
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/model.dever",
|
|
"type Account { name: Text(1, 64) }",
|
|
),
|
|
("user/account/app.dever", writer),
|
|
(
|
|
"user/report/app.dever",
|
|
"greet(name: Text) (value: Text) { value = \"hello \" + user.account.create(name) }",
|
|
),
|
|
("user/report/api.dever", "get greeting = app.greet"),
|
|
],
|
|
"GET App call chains cannot write",
|
|
);
|
|
check(&[
|
|
(
|
|
"user/account/model.dever",
|
|
"type Account { name: Text(1, 64) }",
|
|
),
|
|
("user/account/app.dever", writer),
|
|
("user/account/api.dever", "post create = app.create"),
|
|
]);
|
|
check(&[
|
|
(
|
|
"user/account/model.dever",
|
|
"type Account { name: Text(1, 64) }",
|
|
),
|
|
(
|
|
"user/account/app.dever",
|
|
"count() (value: Text) { value = int.to_text(model.count()) }",
|
|
),
|
|
("user/account/api.dever", "get count = app.count"),
|
|
]);
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/app.dever",
|
|
"disable() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
|
|
),
|
|
("user/account/api.dever", "get disable = app.disable"),
|
|
],
|
|
"GET App call chains cannot write database or authorization state",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rest_requires_unambiguous_same_domain_model_and_rejects_duplicate_registration() {
|
|
let model = "type Account { name: Text(1, 64) }";
|
|
check(&[
|
|
("user/account/model.dever", model),
|
|
("user/account/api.dever", "rest"),
|
|
]);
|
|
check(&[
|
|
("user/account/model.dever", model),
|
|
("user/account/api.dever", "rest model"),
|
|
]);
|
|
reject(
|
|
&[("user/account/api.dever", "rest")],
|
|
"requires one same-domain Model",
|
|
);
|
|
reject(
|
|
&[
|
|
("user/account/model.dever", model),
|
|
("user/account/api.dever", "rest\nrest"),
|
|
],
|
|
"duplicate HTTP route",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rest_selector_disambiguates_main_and_topic_models() {
|
|
let main = (
|
|
"user/account/model.dever",
|
|
"type Account { name: Text(1, 64) }",
|
|
);
|
|
let detail = (
|
|
"user/account/model/detail.dever",
|
|
"type Detail { title: Text(1, 64) }",
|
|
);
|
|
reject(
|
|
&[main, detail, ("user/account/api.dever", "rest")],
|
|
"requires one same-domain Model",
|
|
);
|
|
check(&[
|
|
main,
|
|
detail,
|
|
("user/account/api.dever", "rest model.detail"),
|
|
]);
|
|
let alternate = (
|
|
"user/account/model/archive.dever",
|
|
"type Archive { name: Text(1, 64) }",
|
|
);
|
|
reject(
|
|
&[detail, alternate, ("user/account/api.dever", "rest")],
|
|
"requires one same-domain Model",
|
|
);
|
|
reject(
|
|
&[main, ("user/account/api.dever", "rest user.profile.model")],
|
|
"requires one same-domain Model",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_rest_and_custom_bindings_cannot_register_the_same_route() {
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/model.dever",
|
|
"type Account { name: Text(1, 64) }",
|
|
),
|
|
(
|
|
"user/account/app.dever",
|
|
"details() (value: Text) { value = \"details\" }",
|
|
),
|
|
("user/account/api.dever", "get details = app.details"),
|
|
("user/account/api/details.dever", "rest model"),
|
|
],
|
|
"duplicate HTTP route",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_declarations_format_idempotently() {
|
|
let mut sources = SourceMap::default();
|
|
sources.add("user/account/api.dever", "# Actions\npost publish=app.publish # publish article\ncmd rebuild =app.rebuild\nrest model\n");
|
|
let source = sources.files().first().unwrap();
|
|
let formatted = dever_core::format::format(source).unwrap();
|
|
assert!(formatted.contains("post publish = app.publish"));
|
|
assert!(formatted.contains("cmd rebuild = app.rebuild"));
|
|
assert!(formatted.contains("rest model"));
|
|
assert!(formatted.contains("# Actions"));
|
|
assert!(formatted.contains("# publish article"));
|
|
let mut roundtrip = SourceMap::default();
|
|
roundtrip.add("user/account/api.dever", formatted.clone());
|
|
assert_eq!(
|
|
dever_core::format::format(roundtrip.files().first().unwrap()).unwrap(),
|
|
formatted
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_api_declarations_share_plain_source_contracts() {
|
|
let markdown = "# 账户接口\n\n绑定账户业务能力。\n\n- 包:`user.account.api`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 问候\n\n提供账户问候接口。\n\n- 声明:`get greeting`\n\n```dever\nget greeting = app.greet\n```\n";
|
|
check(&[
|
|
(
|
|
"user/account/app.dever",
|
|
"greet() (value: Text) { value = \"hello\" }",
|
|
),
|
|
("user/account/api.dever.md", markdown),
|
|
]);
|
|
let invalid = markdown.replace("`get greeting`", "`post greeting`");
|
|
reject(
|
|
&[
|
|
(
|
|
"user/account/app.dever",
|
|
"greet() (value: Text) { value = \"hello\" }",
|
|
),
|
|
("user/account/api.dever.md", &invalid),
|
|
],
|
|
"documented Model declaration",
|
|
);
|
|
}
|