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

481 lines
15 KiB
Rust

use dever_core::source::SourceMap;
#[path = "support/temp.rs"]
mod temp;
use temp::TemporaryDirectory;
fn sources(model: &str) -> SourceMap {
let mut sources = SourceMap::default();
sources.add("user/profile/model.dever", model);
sources.add("user/profile/api.dever", "rest\n");
sources
}
fn diagnostics(sources: &SourceMap) -> String {
dever_core::check(sources)
.expect_err("source must fail checking")
.iter()
.map(|error| error.render(sources))
.collect()
}
// Code-generation assertions may inspect unbound HIR; an executable API must
// use the same configured site/auth contract as the CLI.
fn compile_authenticated_rest(mut sources: SourceMap, user_id_type: &str) {
sources.add("access/user/model.dever", "global type User { name: Text }");
sources.add(
"access/tenant/model.dever",
"global type Tenant { name: Text }",
);
sources.add(
"access/session/app.dever",
format!(
r#"type Identity {{
id: Text
user_id: {user_id_type}?
tenant_id: access.tenant.model.id?
}}
verify(claims: dever.auth.Claims) (identity: Identity) {{
identity = Identity {{ id = claims.subject
user_id = null
tenant_id = null }}
}}"#
),
);
let directory = TemporaryDirectory::new();
std::fs::create_dir(directory.path().join("config")).unwrap();
std::fs::write(
directory.path().join("config/setting.json"),
r#"{
"auth": {"providers": {"session": {
"verify": "access.session.verify",
"jwtSecret": "0123456789abcdef0123456789abcdef"
}}},
"sites": {"admin": {"path": "", "auth": "session"}},
"database": {"default": {"type": "sqlite", "path": "data/rest.db"}}
}"#,
)
.unwrap();
let settings = dever_runtime::config::Settings::load_project(directory.path()).unwrap();
let program = dever_core::check_with_settings(&sources, &settings).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
let native = dever_core::native::compile_project(
&program,
&sources,
std::ffi::OsStr::new("rustc"),
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(native.executable().is_file());
}
#[test]
fn rest_derives_the_public_model_without_an_app_wrapper() {
let sources = sources(
"type Profile {\n name: Text(1, 64)\n bio: Text?\n score: Decimal(12, 2)?\n uid: Uuid generated\n private token: Text?\n}\n",
);
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
assert!(
program
.model_snapshot()
.contains("model user.profile.model")
);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(generated.contains("transaction.commit().await"));
assert!(generated.contains("rest_rollback(transaction).await"));
assert!(generated.contains("dever_runtime::api::text_bounds(\"name\""));
compile_authenticated_rest(sources, "access.user.model.id");
}
#[test]
fn rest_rejects_models_with_no_public_writable_field() {
let sources = sources("type Profile {\n private token: Text?\n}\n");
assert!(diagnostics(&sources).contains("no writable public fields"));
}
#[test]
fn rest_rejects_private_required_fields_without_a_source() {
let sources = sources("type Profile {\n name: Text\n private token: Text\n}\n");
assert!(diagnostics(&sources).contains("private and required"));
}
#[test]
fn rest_rejects_fields_without_a_shared_wire_codec() {
let sources = sources("type Profile {\n name: Text\n photo: Bytes\n}\n");
assert!(diagnostics(&sources).contains("cannot be serialized"));
}
#[test]
fn rest_owner_is_server_bound_and_scopes_every_database_statement() {
let mut sources = SourceMap::default();
sources.add(
"user/account/model.dever",
"global type Account { email: Text(1, 254) unique }",
);
sources.add(
"user/profile/model.dever",
r#"type Profile {
private owner user_id: user.account.model.id = dever.auth.user_id()
name: Text(1, 64)
bio: Text?
}
"#,
);
sources.add("user/profile/api.dever", "rest\n");
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
let snapshot = program.model_snapshot();
assert!(snapshot.contains("owner=auth.user_id"), "{snapshot}");
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(
generated.contains("dever_runtime::auth::user_id()"),
"{generated}"
);
assert!(
!generated.contains("inputs.raw_json(\"user_id\")"),
"{generated}"
);
for sql in [
"SELECT COUNT(*) FROM \\\"profile\\\" WHERE \\\"user_id\\\" = ?1",
"WHERE \\\"id\\\" = ?1 AND \\\"user_id\\\" = ?2 LIMIT 1",
"INSERT INTO \\\"profile\\\" (\\\"name\\\", \\\"bio\\\", \\\"user_id\\\")",
"AND \\\"user_id\\\" = ?4 RETURNING",
"DELETE FROM \\\"profile\\\" WHERE \\\"id\\\" = ?1 AND \\\"user_id\\\" = ?2",
] {
assert!(generated.contains(sql), "missing {sql}: {generated}");
}
compile_authenticated_rest(sources, "user.account.model.id");
}
#[test]
fn rest_owner_accepts_only_one_non_null_integer_identity_source() {
for (model, expected) in [
(
"type Profile { owner user_id: Int? = dever.auth.user_id()\n name: Text }",
"must be non-null",
),
(
"type Profile { owner user_id: Text = dever.auth.user_id()\n name: Text }",
"must use Int or a Model id type",
),
(
"type Profile { owner user_id: Int default 1 = dever.auth.user_id()\n name: Text }",
"cannot also be generated or declare a default",
),
(
"type Profile { owner user_id: Int = dever.api.request_id()\n name: Text }",
"must be dever.auth.user_id() or dever.auth.tenant_id()",
),
(
"type Profile { owner user_id: Int = dever.auth.user_id()\n owner tenant_id: Int = dever.auth.tenant_id()\n name: Text }",
"only one REST owner field",
),
] {
let sources = sources(model);
let rendered = diagnostics(&sources);
assert!(
rendered.contains(expected),
"expected {expected}: {rendered}"
);
}
}
#[test]
fn rest_owner_syntax_formats_idempotently() {
let mut sources = SourceMap::default();
sources.add(
"user/profile/model.dever",
"type Profile {\nprivate owner user_id:Int=dever.auth.user_id()\nname:Text index\n}\n",
);
let source = &sources.files()[0];
let formatted = dever_core::format::format(source).unwrap();
assert!(
formatted.contains("private owner user_id: Int = dever.auth.user_id()"),
"{formatted}"
);
let mut roundtrip = SourceMap::default();
roundtrip.add("user/profile/model.dever", formatted.clone());
assert_eq!(
dever_core::format::format(&roundtrip.files()[0]).unwrap(),
formatted
);
}
#[test]
fn authenticated_rest_uses_stable_operation_permissions() {
let directory = TemporaryDirectory::new();
std::fs::create_dir(directory.path().join("config")).unwrap();
std::fs::write(
directory.path().join("config/setting.json"),
r#"{
"auth": {"providers": {"session": {
"verify": "user.profile.verify",
"jwtSecret": "0123456789abcdef0123456789abcdef"
}}},
"sites": {"admin": {"path": "admin", "auth": "session"}}
}"#,
)
.unwrap();
let mut sources = SourceMap::default();
sources.add(
"platform/tenant/model.dever",
"global type Tenant { name: Text }",
);
sources.add(
"user/profile/model.dever",
"global type Profile { name: Text }",
);
sources.add(
"user/profile/app.dever",
r#"type Identity {
id: Text
user_id: 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
}
}"#,
);
sources.add("user/profile/api/admin/manage.dever", "rest model");
let settings = dever_runtime::config::Settings::load_project(directory.path()).unwrap();
let program = dever_core::check_with_settings(&sources, &settings).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
for permission in [
"user.profile.admin.read",
"user.profile.admin.create",
"user.profile.admin.replace",
"user.profile.admin.delete",
] {
assert!(
generated.contains(&format!("api_authorize(\"{permission}\", \"admin\")")),
"{generated}"
);
}
}
#[test]
fn rest_field_bindings_are_checked_and_emit_static_search_sql() {
let mut sources = SourceMap::default();
sources.add(
"user/account/model.dever",
"global type Account { email: Text(1, 254) unique }",
);
sources.add(
"user/profile/domain.dever",
"normalize(value: Text) (result: Text) pure { result = text.lower(text.trim(value)) }",
);
sources.add("user/profile/model.dever", r#"type Profile {
private owner user_id: user.account.model.id = dever.auth.user_id()
name: Text(1, 64) create = domain.normalize(input) replace = domain.normalize(input) search = domain.normalize(input)
private source: Text? create = dever.api.header("x-source") replace = dever.api.header("x-source")
private direct: Text? create = dever.system.api_header("x-direct") replace = dever.system.api_header("x-direct")
}"#);
sources.add("user/profile/api.dever", "rest model\n");
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
)
});
assert!(program.application_errors().unwrap().is_empty());
assert!(
program
.model_snapshot()
.contains("index plain (user_id,name) implicit=true"),
"{}",
program.model_snapshot()
);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(
generated.contains("inputs.raw_json(\"name\")"),
"{generated}"
);
assert!(
!generated.contains("inputs.raw_json(\"source\")"),
"{generated}"
);
assert!(
generated.contains("inputs.optional_raw_json(\"name\")"),
"{generated}"
);
assert!(
generated.contains("WHERE \\\"user_id\\\" = ?1 AND \\\"name\\\" = ?2"),
"{generated}"
);
assert!(
generated.contains("SELECT COUNT(*) FROM \\\"profile\\\" WHERE \\\"user_id\\\" = ?1"),
"{generated}"
);
compile_authenticated_rest(sources, "user.account.model.id");
}
#[test]
fn rest_field_bindings_reject_unsafe_or_ambiguous_sources() {
for (model, domain, expected) in [
(
"type Profile { private token: Text create = input\n name: Text }",
"",
"private REST create binding cannot use request input",
),
(
"type Profile { name: Text search = \"fixed\" }",
"",
"search binding must use input",
),
(
"type Profile { name: Text search = input\n status: Int search = input }",
"",
"at most one search field",
),
(
"type Profile { name: Text create = domain.normalize(input) }",
"normalize(value: Text) (result: Text) { result = value }",
"pure same-domain Domain",
),
(
"type Profile { created: DateTime create = dever.time.now() }",
"",
"pure same-domain Domain",
),
(
"type Profile { name: Text create = domain.mutate(input) }",
"mutate(value: Text) (result: Text) pure {\n dever.api.set_header(\"x-test\", value)\n result = value\n}",
"pure same-domain Domain",
),
(
"type Profile { count: Int create = model.count() }",
"",
"Model operations are private to their owning domain App",
),
] {
let mut sources = sources(model);
if !domain.is_empty() {
sources.add("user/profile/domain.dever", domain);
}
let rendered = diagnostics(&sources);
assert!(
rendered.contains(expected),
"expected {expected}: {rendered}"
);
}
}
#[test]
fn rest_binding_modifiers_do_not_change_app_model_operations() {
let mut sources = SourceMap::default();
sources.add(
"user/profile/model.dever",
"type Profile { name: Text create = text.trim(input) replace = text.lower(input) }",
);
sources.add(
"user/profile/app.dever",
"create(name: Text) (profile: model.Profile) { profile = model.create({ name = name }) }",
);
sources.add("user/profile/api.dever", "post create = app.create");
let program = dever_core::check(&sources).unwrap();
let native = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(!native.contains("text_trim"), "{native}");
assert!(!native.contains("text_lower"), "{native}");
}
#[test]
fn rest_binding_syntax_formats_idempotently() {
let mut sources = SourceMap::default();
sources.add("user/profile/model.dever", "type Profile {\nname:Text create=text.trim(input) replace=text.lower(input) search=input\n}\n");
let formatted = dever_core::format::format(&sources.files()[0]).unwrap();
assert!(
formatted.contains(
"name: Text create = text.trim(input) replace = text.lower(input) search = input"
),
"{formatted}"
);
let mut roundtrip = SourceMap::default();
roundtrip.add("user/profile/model.dever", formatted.clone());
assert_eq!(
dever_core::format::format(&roundtrip.files()[0]).unwrap(),
formatted
);
}