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,338 @@
|
||||
use dever_runtime::auth::store::{self, Permission, Role};
|
||||
use dever_runtime::{database, orm};
|
||||
|
||||
static PERMISSIONS: &[Permission] = &[
|
||||
Permission {
|
||||
key: "news.article.admin.publish",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
site: "admin",
|
||||
action: "publish",
|
||||
method: "POST",
|
||||
},
|
||||
Permission {
|
||||
key: "news.article.admin.delete",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
site: "admin",
|
||||
action: "delete",
|
||||
method: "DELETE",
|
||||
},
|
||||
Permission {
|
||||
key: "news.article.front.read",
|
||||
component: "news",
|
||||
domain: "article",
|
||||
site: "front",
|
||||
action: "read",
|
||||
method: "GET",
|
||||
},
|
||||
];
|
||||
|
||||
pub async fn assert_contract(catalog: database::Database, authorization: database::Database) {
|
||||
store::sync_catalog(catalog.clone(), PERMISSIONS)
|
||||
.await
|
||||
.unwrap();
|
||||
create_legacy_role_schema(&authorization).await;
|
||||
store::initialize_roles(authorization.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
store::authorize(
|
||||
authorization.clone(),
|
||||
9,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let version = authorization
|
||||
.query(
|
||||
database::Sql {
|
||||
sqlite: "SELECT version FROM _dever_auth_version",
|
||||
postgres: "SELECT version FROM _dever_auth_version",
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version[0].get(0).unwrap(), &orm::Value::Int(2));
|
||||
|
||||
let listed = store::list_permissions(catalog.clone()).await.unwrap();
|
||||
assert_eq!(listed.len(), 3);
|
||||
assert_eq!(listed[0].key, "news.article.admin.delete");
|
||||
assert_eq!(listed[0].method, "DELETE");
|
||||
assert_eq!(listed[2].key, "news.article.front.read");
|
||||
|
||||
let publisher = Role {
|
||||
id: "publisher".into(),
|
||||
site: "admin".into(),
|
||||
name: "Publisher".into(),
|
||||
all_permissions: false,
|
||||
};
|
||||
let deleter = Role {
|
||||
id: "deleter".into(),
|
||||
site: "admin".into(),
|
||||
name: "Deleter".into(),
|
||||
all_permissions: false,
|
||||
};
|
||||
let reader = Role {
|
||||
id: "publisher".into(),
|
||||
site: "front".into(),
|
||||
name: "Reader".into(),
|
||||
all_permissions: false,
|
||||
};
|
||||
save_role(
|
||||
&catalog,
|
||||
&authorization,
|
||||
&publisher,
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await;
|
||||
save_role(
|
||||
&catalog,
|
||||
&authorization,
|
||||
&deleter,
|
||||
"news.article.admin.delete",
|
||||
)
|
||||
.await;
|
||||
save_role(&catalog, &authorization, &reader, "news.article.front.read").await;
|
||||
assert!(
|
||||
store::grant_role(authorization.clone(), 4, "front", "deleter")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
!store::authorize(
|
||||
authorization.clone(),
|
||||
1,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
store::grant_role(authorization.clone(), 1, "admin", "publisher")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store::authorize(authorization.clone(), 1, "front", "news.article.front.read",)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
store::grant_role(authorization.clone(), 1, "front", "publisher")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
store::authorize(
|
||||
authorization.clone(),
|
||||
1,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store::authorize(
|
||||
authorization.clone(),
|
||||
1,
|
||||
"admin",
|
||||
"news.article.admin.delete",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store::authorize(authorization.clone(), 1, "front", "news.article.front.read",)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
store::grant_role(authorization.clone(), 2, "admin", "publisher")
|
||||
.await
|
||||
.unwrap();
|
||||
store::grant_role(authorization.clone(), 2, "admin", "deleter")
|
||||
.await
|
||||
.unwrap();
|
||||
for permission in ["news.article.admin.publish", "news.article.admin.delete"] {
|
||||
assert!(
|
||||
store::authorize(authorization.clone(), 2, "admin", permission)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
store::disable_role(authorization.clone(), "admin", "deleter")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
store::authorize(
|
||||
authorization.clone(),
|
||||
2,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store::authorize(
|
||||
authorization.clone(),
|
||||
2,
|
||||
"admin",
|
||||
"news.article.admin.delete",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let owner_role = store::provision_owner(authorization.clone(), 3, "admin")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(owner_role, "_dever_owner:admin");
|
||||
let reserved = Role {
|
||||
id: owner_role.clone(),
|
||||
site: "admin".into(),
|
||||
name: "Not Owner".into(),
|
||||
all_permissions: true,
|
||||
};
|
||||
assert!(
|
||||
store::save_role(catalog.clone(), authorization.clone(), &reserved, &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store::grant_role(authorization.clone(), 4, "admin", &owner_role)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store::revoke_role(authorization.clone(), 3, "admin", &owner_role)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store::disable_role(authorization.clone(), "admin", &owner_role)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store::authorize(
|
||||
authorization.clone(),
|
||||
3,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store::authorize(
|
||||
authorization.clone(),
|
||||
3,
|
||||
"admin",
|
||||
"news.article.admin.delete",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store::authorize(authorization.clone(), 3, "front", "news.article.front.read",)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
store::revoke_role(authorization.clone(), 1, "admin", "publisher")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store::authorize(
|
||||
authorization.clone(),
|
||||
1,
|
||||
"admin",
|
||||
"news.article.admin.publish",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store::authorize(authorization.clone(), 1, "front", "news.article.front.read",)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store::authorize(catalog.clone(), 1, "admin", "news.article.front.read",)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
store::sync_catalog(catalog.clone(), &[PERMISSIONS[0], PERMISSIONS[2]])
|
||||
.await
|
||||
.unwrap();
|
||||
let listed = store::list_permissions(catalog.clone()).await.unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
let inactive = catalog
|
||||
.query(
|
||||
database::Sql {
|
||||
sqlite: "SELECT active FROM _dever_permission WHERE key='news.article.admin.delete'",
|
||||
postgres: "SELECT active FROM _dever_permission WHERE key='news.article.admin.delete'",
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(inactive[0].get(0).unwrap(), &orm::Value::Int(0));
|
||||
assert!(
|
||||
store::save_role(
|
||||
catalog,
|
||||
authorization,
|
||||
&deleter,
|
||||
&["news.article.admin.delete".into()],
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
async fn create_legacy_role_schema(database: &database::Database) {
|
||||
for sql in [
|
||||
"CREATE TABLE _dever_auth_role (id TEXT PRIMARY KEY, site TEXT NOT NULL, name TEXT NOT NULL, all_permissions BIGINT NOT NULL CHECK(all_permissions IN (0,1)), enabled BIGINT NOT NULL CHECK(enabled IN (0,1)))",
|
||||
"CREATE TABLE _dever_auth_role_permission (role_id TEXT NOT NULL REFERENCES _dever_auth_role(id) ON DELETE CASCADE, permission_key TEXT NOT NULL, PRIMARY KEY(role_id,permission_key))",
|
||||
"CREATE TABLE _dever_auth_user_role (user_id BIGINT NOT NULL, role_id TEXT NOT NULL REFERENCES _dever_auth_role(id) ON DELETE CASCADE, PRIMARY KEY(user_id,role_id))",
|
||||
"CREATE INDEX _dever_auth_role_site ON _dever_auth_role(site,enabled)",
|
||||
"CREATE INDEX _dever_auth_user_role_user ON _dever_auth_user_role(user_id,role_id)",
|
||||
"INSERT INTO _dever_auth_role(id,site,name,all_permissions,enabled) VALUES ('publisher','admin','Legacy Publisher',0,1)",
|
||||
"INSERT INTO _dever_auth_role_permission(role_id,permission_key) VALUES ('publisher','news.article.admin.publish')",
|
||||
"INSERT INTO _dever_auth_user_role(user_id,role_id) VALUES (9,'publisher')",
|
||||
] {
|
||||
database
|
||||
.execute(
|
||||
database::Sql {
|
||||
sqlite: sql,
|
||||
postgres: sql,
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_role(
|
||||
catalog: &database::Database,
|
||||
authorization: &database::Database,
|
||||
role: &Role,
|
||||
permission: &str,
|
||||
) {
|
||||
store::save_role(
|
||||
catalog.clone(),
|
||||
authorization.clone(),
|
||||
role,
|
||||
&[permission.into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use dever_core::{check, llvm::emit_kernel, source::SourceMap};
|
||||
|
||||
/// Native acceptance always starts with checked source, not hand-written IR.
|
||||
pub fn lower(source: &str) -> String {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("main.dever", source);
|
||||
let program = check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
emit_kernel(&program, &sources, "main.main").unwrap()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::llvm;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
|
||||
#[path = "llvm_module.rs"]
|
||||
mod module;
|
||||
|
||||
pub fn execute(source: &str, output_type: &str, checks: &str) {
|
||||
execute_bounded(source, output_type, checks, 64, Duration::from_secs(5), &[]);
|
||||
}
|
||||
|
||||
/// Expensive protocol/crypto fixtures retain allocation checks with explicit bounds.
|
||||
pub fn execute_bounded(
|
||||
source: &str,
|
||||
output_type: &str,
|
||||
checks: &str,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
arguments: &[&str],
|
||||
) -> Output {
|
||||
let mut ir = llvm::lower(source);
|
||||
// The emitter supplies the actual nominal and fault layouts.
|
||||
ir.push_str(&format!(
|
||||
"\ndefine i32 @dever_test_run() {{\nentry:\n %out = alloca {output_type}\n %fault = alloca %dever.fault\n %status = call i32 @dever_entry(ptr %out, ptr %fault)\n{checks}\n call void @dever_outputs_release(ptr %out)\n call void @dever_fault_release(ptr %fault)\n %exit = select i1 %passed, i32 0, i32 1\n ret i32 %exit\n}}\n"
|
||||
));
|
||||
module::execute(&ir, source, iterations, timeout, arguments, None)
|
||||
}
|
||||
|
||||
pub fn boolean(source: &str) {
|
||||
boolean_bounded(source, 64, Duration::from_secs(5), &[]);
|
||||
}
|
||||
|
||||
pub fn boolean_bounded(
|
||||
source: &str,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
arguments: &[&str],
|
||||
) -> Output {
|
||||
// Dever equality requires T? on both sides, not implicit T-to-T? coercion.
|
||||
let source = format!(
|
||||
"maybe_int(value: Int) (answer: Int?) {{ answer = value }}
|
||||
maybe_text(value: Text) (answer: Text?) {{ answer = value }}
|
||||
maybe_decimal(value: Decimal) (answer: Decimal?) {{ answer = value }}
|
||||
maybe_float(value: Float) (answer: Float?) {{ answer = value }}
|
||||
maybe_list(value: List<Text>) (answer: List<Text>?) {{ answer = value }}
|
||||
{source}"
|
||||
);
|
||||
execute_bounded(
|
||||
&source,
|
||||
"{ i1 }",
|
||||
" %okay = icmp eq i32 %status, 0
|
||||
br i1 %okay, label %success, label %failed
|
||||
success:
|
||||
%answer = load i1, ptr %out
|
||||
br label %done
|
||||
failed:
|
||||
br label %done
|
||||
done:
|
||||
%passed = phi i1 [ %answer, %success ], [ false, %failed ]",
|
||||
iterations,
|
||||
timeout,
|
||||
arguments,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn runtime_fault(source: &str, message: &str) {
|
||||
execute(source, "{ i1 }", &module::fault_check(3, Some(message)));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use crate::{process, temp};
|
||||
use dever_backend_bridge::{BinaryResource, Target, emit_object_with_resources};
|
||||
use std::fmt::Write;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Kernel and application probes share the real archive and atomic ledger.
|
||||
/// Deployment settings belong only to this owned test directory.
|
||||
pub fn execute(
|
||||
ir: &str,
|
||||
fixture: &str,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
arguments: &[&str],
|
||||
setting: Option<&str>,
|
||||
) -> Output {
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
if let Some(setting) = setting {
|
||||
fs::create_dir(directory.path().join("config")).unwrap();
|
||||
fs::write(directory.path().join("config/setting.json"), setting).unwrap();
|
||||
}
|
||||
execute_in(&directory, ir, fixture, iterations, timeout, arguments)
|
||||
}
|
||||
|
||||
/// Successive schema revisions share only this owned executable/data directory.
|
||||
pub fn execute_in(
|
||||
directory: &temp::TemporaryDirectory,
|
||||
ir: &str,
|
||||
fixture: &str,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
arguments: &[&str],
|
||||
) -> Output {
|
||||
let program = link_in(directory, ir, fixture, iterations);
|
||||
execute_program(&program, fixture, timeout, arguments)
|
||||
}
|
||||
|
||||
/// Relocated executables use the same empty environment and bounded process owner.
|
||||
pub fn execute_program(
|
||||
program: &Path,
|
||||
fixture: &str,
|
||||
timeout: Duration,
|
||||
arguments: &[&str],
|
||||
) -> Output {
|
||||
let directory = program.parent().expect("fixture executable directory");
|
||||
let stdout = directory.join("stdout");
|
||||
let stderr = directory.join("stderr");
|
||||
let status = process::status(
|
||||
Command::new(program)
|
||||
.env_clear()
|
||||
.args(arguments)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(fs::File::create(&stdout).unwrap())
|
||||
.stderr(fs::File::create(&stderr).unwrap()),
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_else(|error| {
|
||||
let completed = fs::read_to_string(&stdout)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.count();
|
||||
panic!("{fixture}: {error}; completed output lines: {completed}");
|
||||
});
|
||||
let stdout = fs::read(stdout).unwrap();
|
||||
let stderr = fs::read(stderr).unwrap();
|
||||
assert!(
|
||||
status.success(),
|
||||
"LLVM fixture failed: {status}\n{}\n{fixture}",
|
||||
String::from_utf8_lossy(&stderr)
|
||||
);
|
||||
Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP probes control their own child after linking the same ledger driver.
|
||||
pub fn link_in(
|
||||
directory: &temp::TemporaryDirectory,
|
||||
ir: &str,
|
||||
fixture: &str,
|
||||
iterations: usize,
|
||||
) -> std::path::PathBuf {
|
||||
link_with_driver(directory, ir, fixture, iterations, "managed-driver.c")
|
||||
}
|
||||
|
||||
/// Suite dispatch needs a case-index driver; object/archive/ledger linkage stays shared.
|
||||
pub fn link_with_driver(
|
||||
directory: &temp::TemporaryDirectory,
|
||||
ir: &str,
|
||||
fixture: &str,
|
||||
iterations: usize,
|
||||
driver: &str,
|
||||
) -> std::path::PathBuf {
|
||||
link_with_resources(directory, ir, fixture, iterations, driver, &[])
|
||||
}
|
||||
|
||||
pub fn link_with_resources(
|
||||
directory: &temp::TemporaryDirectory,
|
||||
ir: &str,
|
||||
fixture: &str,
|
||||
iterations: usize,
|
||||
driver: &str,
|
||||
resources: &[BinaryResource<'_>],
|
||||
) -> std::path::PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let archive = root.join("target/native-runtime-abi/debug/libdever_backend_bridge.a");
|
||||
assert!(
|
||||
archive.is_file(),
|
||||
"missing explicit runtime ABI fixture archive"
|
||||
);
|
||||
let object = directory.path().join("entry.o");
|
||||
fs::write(directory.path().join("entry.ll"), ir).unwrap();
|
||||
fs::write(
|
||||
&object,
|
||||
emit_object_with_resources(ir, Target::LinuxX86_64, resources).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let program = directory.path().join("managed-kernel");
|
||||
let mut compiler = Command::new("/usr/bin/cc");
|
||||
compiler
|
||||
.env_clear()
|
||||
.args(["-std=c11", "-Wall", "-Wextra", "-Werror", "-O2"])
|
||||
.arg(format!("-DDEVER_TEST_ITERATIONS={iterations}"))
|
||||
.arg(root.join("test/native-runtime-abi").join(driver))
|
||||
.arg(object)
|
||||
.arg(archive)
|
||||
.args([
|
||||
"-Wl,--wrap=malloc",
|
||||
"-Wl,--wrap=calloc",
|
||||
"-Wl,--wrap=realloc",
|
||||
"-Wl,--wrap=free",
|
||||
"-Wl,--wrap=posix_memalign",
|
||||
"-Wl,--wrap=realpath",
|
||||
"-ldl",
|
||||
"-lpthread",
|
||||
"-lm",
|
||||
"-o",
|
||||
])
|
||||
.arg(&program)
|
||||
// Author tools are explicit, not a product PATH fallback.
|
||||
.arg("-B/usr/bin/")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit());
|
||||
assert!(
|
||||
process::status(&mut compiler, Duration::from_secs(30))
|
||||
.unwrap()
|
||||
.success(),
|
||||
"could not link LLVM fixture\n{fixture}"
|
||||
);
|
||||
program
|
||||
}
|
||||
|
||||
/// Exact status/kind/message oracle for the common generated fault layout.
|
||||
pub fn fault_check(code: u32, message: Option<&str>) -> String {
|
||||
let mut checks = format!(
|
||||
" %failed = icmp eq i32 %status, 1\n %code_ptr = getelementptr %dever.fault, ptr %fault, i32 0, i32 0\n %code = load i32, ptr %code_ptr\n %kind = icmp eq i32 %code, {code}\n %status_ok = and i1 %failed, %kind"
|
||||
);
|
||||
let Some(message) = message else {
|
||||
checks.push_str("\n %passed = and i1 %status_ok, true");
|
||||
return checks;
|
||||
};
|
||||
write!(checks, "\n %message_ptr = getelementptr %dever.fault, ptr %fault, i32 0, i32 4, i32 0\n %message = load ptr, ptr %message_ptr\n %length_ptr = getelementptr %dever.fault, ptr %fault, i32 0, i32 4, i32 1\n %length = load i64, ptr %length_ptr\n %length_ok = icmp eq i64 %length, {}\n %has_message = icmp ne ptr %message, null\n %buffer_ok = and i1 %length_ok, %has_message\n %header_ok = and i1 %status_ok, %buffer_ok\n br i1 %header_ok, label %content, label %wrong\ncontent:\n", message.len()).unwrap();
|
||||
let mut previous = "true".to_owned();
|
||||
for (index, byte) in message.bytes().enumerate() {
|
||||
writeln!(checks, " %byte_ptr{index} = getelementptr i8, ptr %message, i64 {index}\n %byte{index} = load i8, ptr %byte_ptr{index}\n %byte_ok{index} = icmp eq i8 %byte{index}, {byte}\n %content_ok{index} = and i1 {previous}, %byte_ok{index}").unwrap();
|
||||
previous = format!("%content_ok{index}");
|
||||
}
|
||||
write!(checks, " br label %done\nwrong:\n br label %done\ndone:\n %passed = phi i1 [ {previous}, %content ], [ false, %wrong ]").unwrap();
|
||||
checks
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Each independent test target imports only the helpers relevant to its layer.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
use dever_core::hir::Program;
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
pub mod temp;
|
||||
|
||||
#[cfg(all(feature = "api", any(feature = "sqlite", feature = "postgres")))]
|
||||
pub mod authorization;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
pub mod postgres;
|
||||
|
||||
pub fn sources(source: &str) -> SourceMap {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("main.dever", source);
|
||||
sources
|
||||
}
|
||||
|
||||
pub fn checked(sources: &SourceMap) -> Program {
|
||||
dever_core::check(sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rejected(source: &str, message: &str) {
|
||||
let sources = sources(source);
|
||||
let errors = match dever_core::check(&sources) {
|
||||
Err(errors) => errors,
|
||||
Ok(_) => panic!("source must fail semantic checking:\n{source}"),
|
||||
};
|
||||
assert!(
|
||||
errors.iter().any(|error| error.message.contains(message)),
|
||||
"expected {message:?}: {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run(source: &str) -> std::process::Output {
|
||||
let sources = sources(source);
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
||||
let native =
|
||||
dever_core::native::compile(&checked(&sources), &sources, "main.main", &rustc).unwrap();
|
||||
Command::new(native.executable()).output().unwrap()
|
||||
}
|
||||
|
||||
pub fn stdout(source: &str) -> String {
|
||||
let output = run(source);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::RuntimeProfile;
|
||||
use dever_runtime::orm::{Row, Value};
|
||||
|
||||
const MODEL: &str = r#"type Status {
|
||||
Active
|
||||
Disabled
|
||||
}
|
||||
|
||||
type User {
|
||||
uuid: Uuid generated unique
|
||||
email: Text(254) unique
|
||||
display_name: Text(1, 64)
|
||||
status: Status default Status.Active index
|
||||
age: Int default 0
|
||||
alias: Text?
|
||||
}
|
||||
|
||||
seed {
|
||||
{
|
||||
email = "seed@example.com"
|
||||
display_name = "Seed User"
|
||||
age = 10
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const APP: &str = r#"show(user: app.user.model.User) () {
|
||||
blocking(dever.io.println(user.email))
|
||||
}
|
||||
|
||||
transaction create_transaction_user() (saved: app.user.model.User) {
|
||||
created = model.create({
|
||||
email = "transaction@example.com"
|
||||
display_name = "Transaction User"
|
||||
age = 40
|
||||
})
|
||||
changed = model.update(created.id, {
|
||||
alias = "committed"
|
||||
})
|
||||
saved = model.get(created.id)
|
||||
}
|
||||
|
||||
transaction insert_then_fail() () {
|
||||
inserted = model.create({
|
||||
email = "rollback@example.com"
|
||||
display_name = "Rollback User"
|
||||
})
|
||||
duplicate = model.create({
|
||||
email = "seed@example.com"
|
||||
display_name = "Duplicate Seed"
|
||||
})
|
||||
}
|
||||
|
||||
main() () {
|
||||
transaction_user = create_transaction_user()
|
||||
blocking(dever.io.println(transaction_user.display_name))
|
||||
|
||||
created = model.create({
|
||||
email = "one@example.com"
|
||||
display_name = "One"
|
||||
age = 20
|
||||
})
|
||||
changed = model.update(created.id, {
|
||||
display_name = "Updated One"
|
||||
})
|
||||
loaded = model.get(created.id)
|
||||
blocking(dever.io.println(loaded.display_name))
|
||||
|
||||
inserted = model.create_many([
|
||||
{
|
||||
email = "two@example.com"
|
||||
display_name = "Two"
|
||||
age = 30
|
||||
},
|
||||
{
|
||||
email = "three@example.com"
|
||||
display_name = "Three"
|
||||
age = 30
|
||||
}
|
||||
])
|
||||
blocking(dever.io.println(int.to_text(inserted)))
|
||||
|
||||
saved = model.upsert(
|
||||
{ email = "two@example.com" },
|
||||
{
|
||||
display_name = "Ignored"
|
||||
age = 30
|
||||
},
|
||||
{ display_name = "Upserted Two" }
|
||||
)
|
||||
blocking(dever.io.println(saved.display_name))
|
||||
|
||||
total = model.count({
|
||||
where = not contains(display_name, "missing") and in(status, [app.user.model.Status.Active]) and between(age, 18, 50) and starts_with(email, "t") and ends_with(email, ".com") and alias == null
|
||||
})
|
||||
blocking(dever.io.println(int.to_text(total)))
|
||||
|
||||
page = model.list({
|
||||
order = id.asc
|
||||
page = 1
|
||||
size = 2
|
||||
})
|
||||
blocking(dever.io.println(int.to_text(page.total)))
|
||||
|
||||
cursor = model.cursor({
|
||||
order = [age.desc, id.desc]
|
||||
size = 2
|
||||
})
|
||||
blocking(dever.io.println(int.to_text(length(cursor.items))))
|
||||
each(show, cursor.items)
|
||||
next = model.cursor({
|
||||
after = cursor.next
|
||||
order = [age.desc, id.desc]
|
||||
size = 2
|
||||
})
|
||||
blocking(dever.io.println(int.to_text(length(next.items))))
|
||||
each(show, next.items)
|
||||
|
||||
users = model.stream({
|
||||
order = id.asc
|
||||
size = 1
|
||||
})
|
||||
each(show, users)
|
||||
|
||||
removed = model.delete(created.id)
|
||||
blocking(dever.io.println(int.to_text(removed)))
|
||||
}
|
||||
|
||||
rollback() () {
|
||||
insert_then_fail()
|
||||
}
|
||||
"#;
|
||||
|
||||
pub const MAIN_STDOUT: &str = concat!(
|
||||
"Transaction User\n",
|
||||
"Updated One\n",
|
||||
"2\n",
|
||||
"Upserted Two\n",
|
||||
"2\n",
|
||||
"5\n",
|
||||
"2\n",
|
||||
"transaction@example.com\n",
|
||||
"three@example.com\n",
|
||||
"2\n",
|
||||
"two@example.com\n",
|
||||
"one@example.com\n",
|
||||
"seed@example.com\n",
|
||||
"transaction@example.com\n",
|
||||
"one@example.com\n",
|
||||
"two@example.com\n",
|
||||
"three@example.com\n",
|
||||
"1\n",
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Revisions {
|
||||
pub schema: String,
|
||||
pub seed: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct UserRow {
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub status: String,
|
||||
pub age: i64,
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct DatabaseState {
|
||||
pub columns: Vec<String>,
|
||||
pub managed_index_count: usize,
|
||||
pub schema_revision: String,
|
||||
pub seed_revision: String,
|
||||
pub users: Vec<UserRow>,
|
||||
}
|
||||
|
||||
pub struct CompiledFixture {
|
||||
pub native: dever_core::native::NativeProgram,
|
||||
pub revisions: Revisions,
|
||||
}
|
||||
|
||||
pub fn compile(entry: &str, profile: RuntimeProfile) -> CompiledFixture {
|
||||
let sources = sources();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let revisions = revisions(&program.model_snapshot());
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
||||
let native =
|
||||
dever_core::native::compile_application(&program, &sources, entry, &rustc, profile)
|
||||
.unwrap();
|
||||
CompiledFixture { native, revisions }
|
||||
}
|
||||
|
||||
pub fn configure(executable: &Path, setting: &str) {
|
||||
let root = executable.parent().unwrap();
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
fs::write(root.join("config/setting.json"), setting).unwrap();
|
||||
}
|
||||
|
||||
pub fn run(executable: &Path) -> Output {
|
||||
Command::new(executable).output().unwrap()
|
||||
}
|
||||
|
||||
pub fn assert_main_output(output: Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), MAIN_STDOUT);
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
|
||||
pub fn assert_committed_state(state: &DatabaseState, revisions: &Revisions) {
|
||||
assert_eq!(
|
||||
state.columns,
|
||||
[
|
||||
"id",
|
||||
"created_at",
|
||||
"uuid",
|
||||
"email",
|
||||
"display_name",
|
||||
"status",
|
||||
"age",
|
||||
"alias"
|
||||
]
|
||||
);
|
||||
assert_eq!(state.managed_index_count, 3);
|
||||
assert_eq!(state.schema_revision, revisions.schema);
|
||||
assert_eq!(state.seed_revision, revisions.seed);
|
||||
assert_eq!(
|
||||
state.users,
|
||||
[
|
||||
UserRow {
|
||||
email: "seed@example.com".into(),
|
||||
display_name: "Seed User".into(),
|
||||
status: "Active".into(),
|
||||
age: 10,
|
||||
alias: None,
|
||||
},
|
||||
UserRow {
|
||||
email: "three@example.com".into(),
|
||||
display_name: "Three".into(),
|
||||
status: "Active".into(),
|
||||
age: 30,
|
||||
alias: None,
|
||||
},
|
||||
UserRow {
|
||||
email: "transaction@example.com".into(),
|
||||
display_name: "Transaction User".into(),
|
||||
status: "Active".into(),
|
||||
age: 40,
|
||||
alias: Some("committed".into()),
|
||||
},
|
||||
UserRow {
|
||||
email: "two@example.com".into(),
|
||||
display_name: "Upserted Two".into(),
|
||||
status: "Active".into(),
|
||||
age: 30,
|
||||
alias: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
pub fn database_state(
|
||||
columns: Vec<Row>,
|
||||
managed_index_count: Vec<Row>,
|
||||
history: Vec<Row>,
|
||||
users: Vec<Row>,
|
||||
) -> DatabaseState {
|
||||
DatabaseState {
|
||||
columns: columns.iter().map(|row| text(row, 0)).collect(),
|
||||
managed_index_count: integer(&managed_index_count[0], 0) as usize,
|
||||
schema_revision: text(&history[0], 1),
|
||||
seed_revision: text(&history[1], 1),
|
||||
users: users
|
||||
.iter()
|
||||
.map(|row| UserRow {
|
||||
email: text(row, 0),
|
||||
display_name: text(row, 1),
|
||||
status: text(row, 2),
|
||||
age: integer(row, 3),
|
||||
alias: nullable_text(row, 4),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sources() -> SourceMap {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("app/user/model.dever", MODEL);
|
||||
sources.add("app/user/app.dever", APP);
|
||||
sources
|
||||
}
|
||||
|
||||
fn revisions(snapshot: &str) -> Revisions {
|
||||
let model = snapshot
|
||||
.lines()
|
||||
.find(|line| line.starts_with("model app.user.model "))
|
||||
.expect("shared ORM fixture Model snapshot");
|
||||
Revisions {
|
||||
schema: snapshot_value(model, "revision"),
|
||||
seed: snapshot_value(model, "seed"),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_value(line: &str, name: &str) -> String {
|
||||
line.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix(&format!("{name}=")))
|
||||
.unwrap_or_else(|| panic!("missing {name} in Model snapshot: {line}"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn text(row: &Row, index: usize) -> String {
|
||||
match row.get(index).unwrap() {
|
||||
Value::Text(value) => value.clone(),
|
||||
value => panic!("expected Text at column {index}, got {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn integer(row: &Row, index: usize) -> i64 {
|
||||
match row.get(index).unwrap() {
|
||||
Value::Int(value) => *value,
|
||||
value => panic!("expected Int at column {index}, got {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn nullable_text(row: &Row, index: usize) -> Option<String> {
|
||||
match row.get(index).unwrap() {
|
||||
Value::Null => None,
|
||||
Value::Text(value) => Some(value.clone()),
|
||||
value => panic!("expected nullable Text at column {index}, got {value:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use dever_runtime::database::{
|
||||
DataMigration, Field, Migration, MigrationPhase, Model, PostgresColumn, Schema, SchemaSql, Sql,
|
||||
};
|
||||
use dever_runtime::orm::{ErrorKind, Value};
|
||||
use dever_runtime::postgres::Database;
|
||||
|
||||
const MODEL: &str = "app.model.migration_probe";
|
||||
const TABLE: &str = "migration_probe";
|
||||
const BEFORE: &str = "UPDATE migration_probe SET name = $1 WHERE name = $2";
|
||||
const AFTER: &str = "UPDATE migration_probe SET tag = $1";
|
||||
|
||||
fn field(name: &str, nullable: bool) -> Field {
|
||||
Field {
|
||||
name: name.into(),
|
||||
ty: "Text".into(),
|
||||
nullable,
|
||||
generated: false,
|
||||
default: None,
|
||||
rename_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn column(name: &str, nullable: bool) -> PostgresColumn {
|
||||
PostgresColumn {
|
||||
name: name.into(),
|
||||
definition: format!("\"{name}\" TEXT{}", if nullable { "" } else { " NOT NULL" }),
|
||||
sql_type: "TEXT".into(),
|
||||
default: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn statement(phase: MigrationPhase, sql: &'static str, parameters: Vec<Value>) -> DataMigration {
|
||||
DataMigration {
|
||||
name: "backfill_tag".into(),
|
||||
phase,
|
||||
sql: Sql {
|
||||
sqlite: "",
|
||||
postgres: sql,
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
|
||||
fn model(upgraded: bool, after_sql: &'static str) -> Model {
|
||||
let mut fields = vec![field("name", false)];
|
||||
let mut columns = vec![column("name", false)];
|
||||
if upgraded {
|
||||
fields.push(field("tag", true));
|
||||
columns.push(column("tag", true));
|
||||
}
|
||||
let data_migrations = if upgraded {
|
||||
vec![
|
||||
statement(
|
||||
MigrationPhase::Before,
|
||||
BEFORE,
|
||||
vec![
|
||||
Value::Text("short".into()),
|
||||
Value::Text("legacy-name".into()),
|
||||
],
|
||||
),
|
||||
statement(
|
||||
MigrationPhase::After,
|
||||
after_sql,
|
||||
vec![Value::Text("tagged".into())],
|
||||
),
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
Model {
|
||||
schema: Schema {
|
||||
model: MODEL.into(),
|
||||
table: TABLE.into(),
|
||||
revision: if upgraded { "probe-v2" } else { "probe-v1" }.into(),
|
||||
seed_revision: "probe-seed".into(),
|
||||
fields,
|
||||
indexes: vec![],
|
||||
migrations: if upgraded {
|
||||
vec![Migration {
|
||||
name: "backfill_tag".into(),
|
||||
revision: "probe-v1".into(),
|
||||
drops: vec![],
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
},
|
||||
create_table: SchemaSql {
|
||||
sqlite: String::new(),
|
||||
postgres: if upgraded {
|
||||
"CREATE TABLE migration_probe (\"name\" TEXT NOT NULL, \"tag\" TEXT)"
|
||||
} else {
|
||||
"CREATE TABLE migration_probe (\"name\" TEXT NOT NULL)"
|
||||
}
|
||||
.into(),
|
||||
},
|
||||
create_temporary_table: String::new(),
|
||||
create_indexes: vec![],
|
||||
postgres_columns: columns,
|
||||
postgres_constraints: vec![],
|
||||
seeds: vec![],
|
||||
data_migrations,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn assert_named_data_migration(database: &Database) {
|
||||
database.migrate(model(false, AFTER)).await.unwrap();
|
||||
database
|
||||
.execute(
|
||||
"INSERT INTO migration_probe (name) VALUES ($1)",
|
||||
vec![Value::Text("legacy-name".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let failure = database
|
||||
.migrate(model(true, "UPDATE missing_probe SET tag = $1"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.kind(), ErrorKind::Database, "{failure}");
|
||||
let names = database
|
||||
.query("SELECT name FROM migration_probe", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(names[0].get(0).unwrap(), &Value::Text("legacy-name".into()));
|
||||
let columns = database
|
||||
.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'migration_probe' ORDER BY ordinal_position",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(columns.len(), 1);
|
||||
|
||||
database.migrate(model(true, AFTER)).await.unwrap();
|
||||
let rows = database
|
||||
.query("SELECT name, tag FROM migration_probe", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].get(0).unwrap(), &Value::Text("short".into()));
|
||||
assert_eq!(rows[0].get(1).unwrap(), &Value::Text("tagged".into()));
|
||||
database
|
||||
.execute(
|
||||
"UPDATE migration_probe SET tag = $1",
|
||||
vec![Value::Text("hand-edited".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
database.migrate(model(true, AFTER)).await.unwrap();
|
||||
let rows = database
|
||||
.query("SELECT tag FROM migration_probe", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].get(0).unwrap(), &Value::Text("hand-edited".into()));
|
||||
let history = database
|
||||
.query(
|
||||
"SELECT COUNT(*) FROM _dever_history WHERE model = $1 AND kind = 'migration' AND name = 'backfill_tag'",
|
||||
vec![Value::Text(MODEL.into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history[0].get(0).unwrap(), &Value::Int(1));
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use dever_runtime::database::{Field, Index, Migration, Model, Schema, SchemaSql, Seed, Sql};
|
||||
use dever_runtime::orm::{Row, Value};
|
||||
use dever_runtime::sqlite::Database;
|
||||
|
||||
pub const MODEL_NAME: &str = "app.model.user";
|
||||
pub const TABLE_NAME: &str = "user";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TestField {
|
||||
schema: Field,
|
||||
sqlite: String,
|
||||
}
|
||||
|
||||
impl TestField {
|
||||
pub fn new(name: &str, ty: &str, nullable: bool, sqlite: &str) -> Self {
|
||||
Self {
|
||||
schema: Field {
|
||||
name: name.into(),
|
||||
ty: ty.into(),
|
||||
nullable,
|
||||
generated: name == "id" || name == "created_at",
|
||||
default: (name == "created_at").then(|| "generated:created_at".into()),
|
||||
rename_from: None,
|
||||
},
|
||||
sqlite: sqlite.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn renamed_from(mut self, source: &str) -> Self {
|
||||
self.schema.rename_from = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn default(mut self, value: &str) -> Self {
|
||||
self.schema.default = Some(value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id() -> TestField {
|
||||
TestField::new(
|
||||
"id",
|
||||
"Int",
|
||||
false,
|
||||
"\"id\" INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn created_at() -> TestField {
|
||||
TestField::new(
|
||||
"created_at",
|
||||
"DateTime",
|
||||
false,
|
||||
"\"created_at\" INTEGER NOT NULL DEFAULT (unixepoch() * 1000)",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn text_field(name: &str, maximum: usize, nullable: bool) -> TestField {
|
||||
TestField::new(
|
||||
name,
|
||||
&format!("Text({maximum})"),
|
||||
nullable,
|
||||
&format!(
|
||||
"\"{name}\" TEXT {}CHECK (length(\"{name}\") <= {maximum})",
|
||||
if nullable { "" } else { "NOT NULL " }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bytes_field(name: &str, maximum: usize, nullable: bool) -> TestField {
|
||||
TestField::new(
|
||||
name,
|
||||
&format!("Bytes({maximum})"),
|
||||
nullable,
|
||||
&format!(
|
||||
"\"{name}\" BLOB {}CHECK (length(\"{name}\") <= {maximum})",
|
||||
if nullable { "" } else { "NOT NULL " }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bool_default(name: &str) -> TestField {
|
||||
TestField::new(
|
||||
name,
|
||||
"Bool",
|
||||
false,
|
||||
&format!("\"{name}\" INTEGER NOT NULL DEFAULT 1 CHECK (\"{name}\" IN (0, 1))"),
|
||||
)
|
||||
.default("true")
|
||||
}
|
||||
|
||||
pub fn index(name: &str, fields: &[&str], unique: bool) -> Index {
|
||||
Index {
|
||||
name: name.into(),
|
||||
fields: fields.iter().map(|field| (*field).into()).collect(),
|
||||
unique,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn migration(name: &str, revision: &str, drops: &[&str]) -> Migration {
|
||||
Migration {
|
||||
name: name.into(),
|
||||
revision: revision.into(),
|
||||
drops: drops.iter().map(|field| (*field).into()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed(sqlite: &'static str, parameters: Vec<Value>) -> Seed {
|
||||
Seed {
|
||||
sql: Sql {
|
||||
sqlite,
|
||||
postgres: "",
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(
|
||||
revision: &str,
|
||||
seed_revision: &str,
|
||||
fields: Vec<TestField>,
|
||||
indexes: Vec<Index>,
|
||||
migrations: Vec<Migration>,
|
||||
seeds: Vec<Seed>,
|
||||
) -> Model {
|
||||
let temporary_table = format!("_dever_user_{}", &revision[..8]);
|
||||
let column_sql = fields
|
||||
.iter()
|
||||
.map(|field| field.sqlite.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let create_indexes = indexes
|
||||
.iter()
|
||||
.map(|index| {
|
||||
let fields = index
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| quote(field))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
SchemaSql {
|
||||
sqlite: format!(
|
||||
"CREATE {}INDEX IF NOT EXISTS {} ON {} ({fields})",
|
||||
if index.unique { "UNIQUE " } else { "" },
|
||||
quote(&index.name),
|
||||
quote(TABLE_NAME),
|
||||
),
|
||||
postgres: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Model {
|
||||
schema: Schema {
|
||||
model: MODEL_NAME.into(),
|
||||
table: TABLE_NAME.into(),
|
||||
revision: revision.into(),
|
||||
seed_revision: seed_revision.into(),
|
||||
fields: fields.into_iter().map(|field| field.schema).collect(),
|
||||
indexes,
|
||||
migrations,
|
||||
},
|
||||
create_table: SchemaSql {
|
||||
sqlite: format!("CREATE TABLE {} ({column_sql})", quote(TABLE_NAME)),
|
||||
postgres: String::new(),
|
||||
},
|
||||
create_temporary_table: format!("CREATE TABLE {} ({column_sql})", quote(&temporary_table)),
|
||||
create_indexes,
|
||||
postgres_columns: vec![],
|
||||
postgres_constraints: vec![],
|
||||
seeds,
|
||||
data_migrations: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct DatabaseState {
|
||||
pub catalog: Vec<Row>,
|
||||
pub columns: Vec<Row>,
|
||||
pub history: Vec<Row>,
|
||||
pub data: Vec<Row>,
|
||||
}
|
||||
|
||||
pub async fn database_state(database: &Database) -> DatabaseState {
|
||||
DatabaseState {
|
||||
catalog: database
|
||||
.query(
|
||||
"SELECT type, name, sql FROM sqlite_schema WHERE (type = 'table' AND name = 'user') OR (type = 'index' AND tbl_name = 'user') ORDER BY type, name",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
columns: database
|
||||
.query(
|
||||
"SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('user') ORDER BY cid",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
history: database
|
||||
.query(
|
||||
"SELECT kind, name, revision, definition FROM _dever_history WHERE model = 'app.model.user' ORDER BY kind, name",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
data: database
|
||||
.query("SELECT * FROM user ORDER BY id", vec![])
|
||||
.await
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(row: &Row, index: usize) -> &str {
|
||||
match row.get(index).unwrap() {
|
||||
Value::Text(value) => value,
|
||||
value => panic!("expected Text at column {index}, got {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn integer(row: &Row, index: usize) -> i64 {
|
||||
match row.get(index).unwrap() {
|
||||
Value::Int(value) => *value,
|
||||
value => panic!("expected Int at column {index}, got {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(identifier: &str) -> String {
|
||||
format!("\"{}\"", identifier.replace('"', "\"\""))
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
use std::future::Future;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use dever_runtime::config::{Database as DatabaseSetting, PostgresTls, Settings};
|
||||
use dever_runtime::orm::Value;
|
||||
use dever_runtime::postgres::{ConnectionOptions, Database};
|
||||
|
||||
const POSTGRES_TEST_CONNECTION: &str = "postgres_test";
|
||||
const MAX_PAGE_SIZE: usize = 100;
|
||||
|
||||
static NEXT_SCHEMA: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub struct IsolatedDatabase {
|
||||
document: serde_json::Value,
|
||||
url: String,
|
||||
tls: PostgresTls,
|
||||
}
|
||||
|
||||
impl IsolatedDatabase {
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
pub fn tls(&self) -> PostgresTls {
|
||||
self.tls
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
&self,
|
||||
max_connections: usize,
|
||||
wait_timeout: Duration,
|
||||
io_timeout: Duration,
|
||||
) -> Database {
|
||||
open_database(
|
||||
&self.url,
|
||||
self.tls,
|
||||
max_connections,
|
||||
wait_timeout,
|
||||
io_timeout,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn settings_json(&self) -> String {
|
||||
let mut connection = self.document["database"][POSTGRES_TEST_CONNECTION].clone();
|
||||
connection["url"] = serde_json::Value::String(self.url.clone());
|
||||
let mut document = serde_json::Map::new();
|
||||
for name in ["http", "log"] {
|
||||
if let Some(value) = self.document.get(name) {
|
||||
document.insert(name.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(value) = self.document.get("tenant") {
|
||||
let mut tenant = value.clone();
|
||||
tenant["database"] = serde_json::Value::String("default".into());
|
||||
document.insert("tenant".into(), tenant);
|
||||
}
|
||||
document.insert(
|
||||
"database".into(),
|
||||
serde_json::json!({"default": connection}),
|
||||
);
|
||||
serde_json::to_string_pretty(&document).unwrap() + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IsolatedTenantDatabases {
|
||||
database_setting: serde_json::Value,
|
||||
}
|
||||
|
||||
impl IsolatedTenantDatabases {
|
||||
pub fn database_setting(&self) -> serde_json::Value {
|
||||
self.database_setting.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_in_isolated_databases<F, T>(case_name: &str, test: F) -> T
|
||||
where
|
||||
F: FnOnce(IsolatedTenantDatabases) -> Result<T, String>,
|
||||
{
|
||||
let setting = load_setting(case_name);
|
||||
let suffix = unique_namespace();
|
||||
let control_case = format!("{case_name}_{suffix}");
|
||||
let template = setting.document["database"][POSTGRES_TEST_CONNECTION]["url"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let control_url = case_url(template, &control_case);
|
||||
let control_name = database_name(&control_url);
|
||||
let tenant_prefix = format!("dever_t_{suffix}");
|
||||
assert!(control_name.len() <= 63 && valid_case_name(&control_name));
|
||||
assert!(tenant_prefix.len() <= 43 && valid_case_name(&tenant_prefix));
|
||||
|
||||
let mut cleanup_required = false;
|
||||
let setup = catch_unwind(AssertUnwindSafe(|| {
|
||||
dever_runtime::task::run_entry(async {
|
||||
let administrator = open_database(
|
||||
&setting.url,
|
||||
setting.tls,
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let result = async {
|
||||
let existing = database_names(&administrator).await?;
|
||||
if existing.iter().any(|name| {
|
||||
name == &control_name || name.starts_with(&format!("{tenant_prefix}_"))
|
||||
}) {
|
||||
return Err("isolated PostgreSQL database names already exist".into());
|
||||
}
|
||||
cleanup_required = true;
|
||||
administrator
|
||||
.query_owned(format!("CREATE DATABASE \"{control_name}\""), vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let close = administrator
|
||||
.close()
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
result.and(close)
|
||||
})
|
||||
}));
|
||||
|
||||
let mut database_setting = setting.document["database"][POSTGRES_TEST_CONNECTION].clone();
|
||||
database_setting["url"] = serde_json::Value::String(control_url);
|
||||
database_setting["tenant_database_prefix"] = serde_json::Value::String(tenant_prefix.clone());
|
||||
let result = match setup {
|
||||
Ok(Ok(())) => catch_unwind(AssertUnwindSafe(|| {
|
||||
test(IsolatedTenantDatabases { database_setting })
|
||||
})),
|
||||
Ok(Err(error)) => Ok(Err(error)),
|
||||
Err(panic) => Err(panic),
|
||||
};
|
||||
let cleanup = cleanup_required.then(|| {
|
||||
catch_unwind(AssertUnwindSafe(|| {
|
||||
dever_runtime::task::run_entry(cleanup_databases(
|
||||
&setting.url,
|
||||
setting.tls,
|
||||
&control_name,
|
||||
&tenant_prefix,
|
||||
))
|
||||
}))
|
||||
});
|
||||
let cleanup_error = match cleanup {
|
||||
None | Some(Ok(Ok(()))) => None,
|
||||
Some(Ok(Err(error))) => Some(error),
|
||||
Some(Err(_)) => Some("PostgreSQL database cleanup panicked".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(value)) => {
|
||||
if let Some(error) = cleanup_error {
|
||||
panic!("PostgreSQL database cleanup failed: {error}");
|
||||
}
|
||||
value
|
||||
}
|
||||
Ok(Err(error)) => match cleanup_error {
|
||||
Some(cleanup_error) => {
|
||||
panic!("{error}; PostgreSQL database cleanup failed: {cleanup_error}")
|
||||
}
|
||||
None => panic!("{error}"),
|
||||
},
|
||||
Err(panic) => {
|
||||
if let Some(error) = cleanup_error {
|
||||
eprintln!("PostgreSQL database cleanup failed after panic: {error}");
|
||||
}
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn database_names(administrator: &Database) -> Result<Vec<String>, String> {
|
||||
let rows = administrator
|
||||
.query("SELECT datname::text FROM pg_database", vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
rows.into_iter()
|
||||
.map(|row| match row.get(0).map_err(|error| error.to_string())? {
|
||||
Value::Text(name) => Ok(name.clone()),
|
||||
_ => Err("PostgreSQL database name was not Text".into()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn cleanup_databases(
|
||||
administrator_url: &str,
|
||||
tls: PostgresTls,
|
||||
control_name: &str,
|
||||
tenant_prefix: &str,
|
||||
) -> Result<(), String> {
|
||||
let administrator = open_database(
|
||||
administrator_url,
|
||||
tls,
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let result = async {
|
||||
let mut failures = Vec::new();
|
||||
match database_names(&administrator).await {
|
||||
Ok(names) => {
|
||||
for name in owned_tenant_database_names(names, tenant_prefix) {
|
||||
if let Err(error) = administrator
|
||||
.query_owned(format!("DROP DATABASE \"{name}\""), vec![])
|
||||
.await
|
||||
{
|
||||
failures.push(format!("cannot drop owned tenant database {name}: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => failures.push(format!("cannot list owned tenant databases: {error}")),
|
||||
}
|
||||
if let Err(error) = administrator
|
||||
.query_owned(
|
||||
format!("DROP DATABASE IF EXISTS \"{control_name}\""),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
{
|
||||
failures.push(format!(
|
||||
"cannot drop owned control database {control_name}: {error}"
|
||||
));
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(failures.join("; "))
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let close = administrator
|
||||
.close()
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
result.and(close)
|
||||
}
|
||||
|
||||
fn owned_tenant_database_names(names: Vec<String>, prefix: &str) -> Vec<String> {
|
||||
let marker = format!("{prefix}_");
|
||||
names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
name.strip_prefix(&marker)
|
||||
.and_then(|id| {
|
||||
id.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| value.to_string() == id)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_in_isolated_schema<F, Fut>(case_name: &str, test: F)
|
||||
where
|
||||
F: FnOnce(IsolatedDatabase) -> Fut,
|
||||
Fut: Future<Output = Result<(), String>>,
|
||||
{
|
||||
let setting = load_setting(case_name);
|
||||
let schema = unique_schema();
|
||||
let cleanup_url = setting.url.clone();
|
||||
let cleanup_schema = schema.clone();
|
||||
let tls = setting.tls;
|
||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||
dever_runtime::task::run_entry(async move {
|
||||
let administrator = open_database(
|
||||
&setting.url,
|
||||
tls,
|
||||
2,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
administrator
|
||||
.query_owned(format!("CREATE SCHEMA \"{schema}\""), vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
administrator
|
||||
.close()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
test(IsolatedDatabase {
|
||||
document: setting.document,
|
||||
url: scoped_url(&setting.url, &schema),
|
||||
tls,
|
||||
})
|
||||
.await
|
||||
})
|
||||
}));
|
||||
let cleanup = catch_unwind(AssertUnwindSafe(|| {
|
||||
dever_runtime::task::run_entry(async move {
|
||||
let administrator = open_database(
|
||||
&cleanup_url,
|
||||
tls,
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let drop_result = administrator
|
||||
.query_owned(
|
||||
format!("DROP SCHEMA IF EXISTS \"{cleanup_schema}\" CASCADE"),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let close_result = administrator.close().await;
|
||||
drop_result.map_err(|error| error.to_string())?;
|
||||
close_result.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
}));
|
||||
match result {
|
||||
Ok(result) => {
|
||||
cleanup.unwrap().unwrap();
|
||||
result.unwrap();
|
||||
}
|
||||
Err(panic) => {
|
||||
let _ = cleanup;
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostgresTestSetting {
|
||||
document: serde_json::Value,
|
||||
url: String,
|
||||
tls: PostgresTls,
|
||||
}
|
||||
|
||||
fn load_setting(case_name: &str) -> PostgresTestSetting {
|
||||
assert!(
|
||||
valid_case_name(case_name),
|
||||
"PostgreSQL acceptance case names must use lower_snake_case"
|
||||
);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let settings = Settings::load_project(&workspace).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"PostgreSQL acceptance requires '{}': {error}",
|
||||
workspace.join("config/setting.json").display()
|
||||
)
|
||||
});
|
||||
let (url, tls) = match settings.database(POSTGRES_TEST_CONNECTION) {
|
||||
Some(DatabaseSetting::Postgres { url, tls, .. }) => (case_url(url, case_name), *tls),
|
||||
Some(DatabaseSetting::Sqlite { .. }) => {
|
||||
panic!("database.{POSTGRES_TEST_CONNECTION} must use type 'postgres'")
|
||||
}
|
||||
None => panic!("database.{POSTGRES_TEST_CONNECTION} is required"),
|
||||
};
|
||||
let document = serde_json::from_str(
|
||||
&std::fs::read_to_string(workspace.join("config/setting.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
PostgresTestSetting { document, url, tls }
|
||||
}
|
||||
|
||||
fn case_url(template: &str, case_name: &str) -> String {
|
||||
let (url, query) = template
|
||||
.split_once('?')
|
||||
.map_or((template, ""), |(url, query)| (url, query));
|
||||
let database = url.rsplit_once('/').map_or("", |(_, database)| database);
|
||||
assert!(
|
||||
matches!(
|
||||
template.split_once("://"),
|
||||
Some(("postgres" | "postgresql", _))
|
||||
) && template.matches("{case}").count() == 1
|
||||
&& database.matches("{case}").count() == 1
|
||||
&& !query_overrides_database(query),
|
||||
concat!(
|
||||
"config/setting.json database.postgres_test.url must contain exactly one ",
|
||||
"{{case}} placeholder in its database name and must not override it with dbname"
|
||||
)
|
||||
);
|
||||
template.replace("{case}", case_name)
|
||||
}
|
||||
|
||||
fn query_overrides_database(query: &str) -> bool {
|
||||
query.split('&').any(|parameter| {
|
||||
let key = parameter.split_once('=').map_or(parameter, |(key, _)| key);
|
||||
percent_decode(key).as_deref() == Some(b"dbname")
|
||||
})
|
||||
}
|
||||
|
||||
fn percent_decode(value: &str) -> Option<Vec<u8>> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] != b'%' {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let high = hex_digit(*bytes.get(index + 1)?)?;
|
||||
let low = hex_digit(*bytes.get(index + 2)?)?;
|
||||
decoded.push(high * 16 + low);
|
||||
index += 3;
|
||||
}
|
||||
Some(decoded)
|
||||
}
|
||||
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_case_name(case_name: &str) -> bool {
|
||||
!case_name.is_empty()
|
||||
&& case_name.starts_with(|character: char| character.is_ascii_lowercase())
|
||||
&& case_name.split('_').all(|part| {
|
||||
!part.is_empty()
|
||||
&& part
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn unique_schema() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!(
|
||||
"dever_t_{:x}_{:x}_{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
NEXT_SCHEMA.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_namespace() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!(
|
||||
"{:x}_{:x}_{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
NEXT_SCHEMA.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
fn database_name(url: &str) -> String {
|
||||
let path = url.split_once('?').map_or(url, |(path, _)| path);
|
||||
path.rsplit_once('/').unwrap().1.to_owned()
|
||||
}
|
||||
|
||||
fn scoped_url(url: &str, schema: &str) -> String {
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
format!("{url}{separator}options=-csearch_path%3D{schema}")
|
||||
}
|
||||
|
||||
pub(crate) fn open_database(
|
||||
url: &str,
|
||||
tls: PostgresTls,
|
||||
max_connections: usize,
|
||||
wait_timeout: Duration,
|
||||
io_timeout: Duration,
|
||||
) -> Database {
|
||||
Database::open(ConnectionOptions {
|
||||
url,
|
||||
database_name: None,
|
||||
tls,
|
||||
min_connections: 0,
|
||||
max_connections,
|
||||
max_page_size: MAX_PAGE_SIZE,
|
||||
wait_timeout,
|
||||
io_timeout,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{case_url, owned_tenant_database_names};
|
||||
|
||||
#[test]
|
||||
fn case_placeholder_must_select_the_effective_database() {
|
||||
assert_eq!(
|
||||
case_url(
|
||||
"postgresql://dever@localhost/dever_{case}?sslmode=disable",
|
||||
"authorization_postgres",
|
||||
),
|
||||
"postgresql://dever@localhost/dever_authorization_postgres?sslmode=disable"
|
||||
);
|
||||
for url in [
|
||||
"postgresql://dever@localhost/dever_{case}?dbname=production",
|
||||
"postgresql://dever@localhost/dever_{case}?%64bname=production",
|
||||
"postgresql://dever@localhost/dever?application_name={case}",
|
||||
] {
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| case_url(url, "postgres_orm")).is_err(),
|
||||
"URL must be rejected: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_only_selects_this_run_and_canonical_positive_tenant_ids() {
|
||||
let names = [
|
||||
"dever_t_run_1",
|
||||
"dever_t_run_2",
|
||||
"dever_t_run_0",
|
||||
"dever_t_run_01",
|
||||
"dever_t_run_-1",
|
||||
"dever_t_run_1_extra",
|
||||
"dever_t_run2_1",
|
||||
"dever_t_run",
|
||||
];
|
||||
assert_eq!(
|
||||
owned_tenant_database_names(names.map(str::to_owned).to_vec(), "dever_t_run"),
|
||||
["dever_t_run_1", "dever_t_run_2"],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::io;
|
||||
use std::process::{Child, Command, ExitStatus};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Run only a test-owned process, and reap it on success, failure or timeout.
|
||||
pub fn status(command: &mut Command, timeout: Duration) -> io::Result<ExitStatus> {
|
||||
let mut child = command.spawn()?;
|
||||
wait(&mut child, timeout)
|
||||
}
|
||||
|
||||
/// Wait for a child whose caller owns concurrent interaction and panic cleanup.
|
||||
pub fn wait(child: &mut Child, timeout: Duration) -> io::Result<ExitStatus> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
terminate(child)?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
terminate(child)?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"test-owned process exceeded its deadline",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminate(child: &mut Child) -> io::Result<()> {
|
||||
let killed = child.kill();
|
||||
let reaped = child.wait();
|
||||
// Reap even if kill failed (for example, it raced with normal exit).
|
||||
reaped?;
|
||||
killed
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Explicit private fixture assets; never consult PATH or download a tool.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Asset {
|
||||
pub path: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub executable: bool,
|
||||
}
|
||||
|
||||
pub fn assets(workspace: &Path) -> Vec<Asset> {
|
||||
let prepared = workspace.join("target/sandbox-inputs/assets");
|
||||
let mut assets = Vec::new();
|
||||
for directory in ["bin", "lib"] {
|
||||
for entry in fs::read_dir(prepared.join(directory))
|
||||
.expect("prepare target/sandbox-inputs assets explicitly")
|
||||
{
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
if directory == "bin" && name == "guard" {
|
||||
continue;
|
||||
}
|
||||
assets.push(Asset {
|
||||
path: format!("sandbox/{directory}/{name}"),
|
||||
bytes: fs::read(entry.path()).unwrap(),
|
||||
executable: directory == "bin" || name.starts_with("ld-linux"),
|
||||
});
|
||||
}
|
||||
}
|
||||
assets.push(Asset {
|
||||
path: "sandbox/bin/guard".into(),
|
||||
bytes: fs::read(workspace.join("target/debug/dever-sandbox-guard"))
|
||||
.expect("build the current dever-sandbox-guard fixture explicitly"),
|
||||
executable: true,
|
||||
});
|
||||
assets.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
assets
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Inspect only an explicitly supplied, test-owned SQLite file between roots.
|
||||
use dever_runtime::orm::Value;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn query(path: &Path, sql: &'static str) -> Vec<Vec<Value>> {
|
||||
dever_runtime::task::run_entry(async {
|
||||
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let rows = database
|
||||
.query(sql, vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| row.into_values().collect())
|
||||
.collect())
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn execute(path: &Path, sql: &'static str) {
|
||||
dever_runtime::task::run_entry(async {
|
||||
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
|
||||
.map_err(|error| error.to_string())?;
|
||||
database
|
||||
.execute(sql, vec![])
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub struct TemporaryDirectory(PathBuf);
|
||||
|
||||
impl TemporaryDirectory {
|
||||
pub fn new() -> Self {
|
||||
Self::new_in(&std::env::temp_dir())
|
||||
}
|
||||
|
||||
pub fn new_in(parent: &Path) -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
loop {
|
||||
let path = parent.join(format!(
|
||||
"dever-core-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Self(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("cannot create test directory: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryDirectory {
|
||||
fn drop(&mut self) {
|
||||
// This object owns only the directory it exclusively created above.
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Independent test targets use different subsets of the shared HTTP/WS wire tools.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use dever_runtime::{bytes::Bytes, net};
|
||||
|
||||
// 测试独立观察 HTTP 分块和 WebSocket 帧,不使用被测协议引擎。
|
||||
pub struct Peer {
|
||||
pub socket: net::Socket,
|
||||
buffered: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
pub async fn connect(port: i64) -> Result<Self, String> {
|
||||
Self::new(net::connect_timeout("127.0.0.1", port, 1000).await?)
|
||||
}
|
||||
|
||||
pub fn new(socket: net::Socket) -> Result<Self, String> {
|
||||
net::timeout(&socket, 1500)?;
|
||||
Ok(Self {
|
||||
socket,
|
||||
buffered: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
net::write(&self.socket, &Bytes::new(bytes.to_vec())).await
|
||||
}
|
||||
|
||||
async fn more(&mut self) -> Result<(), String> {
|
||||
let bytes = net::read(&self.socket, 4096)
|
||||
.await?
|
||||
.ok_or("unexpected peer EOF")?;
|
||||
self.buffered.extend_from_slice(bytes.values());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn take(&mut self, count: usize) -> Result<Vec<u8>, String> {
|
||||
while self.buffered.len() < count {
|
||||
self.more().await?;
|
||||
}
|
||||
Ok(self.buffered.drain(..count).collect())
|
||||
}
|
||||
|
||||
async fn through(&mut self, delimiter: &[u8]) -> Result<Vec<u8>, String> {
|
||||
loop {
|
||||
if let Some(index) = self
|
||||
.buffered
|
||||
.windows(delimiter.len())
|
||||
.position(|part| part == delimiter)
|
||||
{
|
||||
return self.take(index + delimiter.len()).await;
|
||||
}
|
||||
self.more().await?;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn head(&mut self) -> Result<String, String> {
|
||||
String::from_utf8(self.through(b"\r\n\r\n").await?).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn chunk(&mut self) -> Result<Vec<u8>, String> {
|
||||
let line = self.through(b"\r\n").await?;
|
||||
let length =
|
||||
usize::from_str_radix(std::str::from_utf8(&line[..line.len() - 2]).unwrap(), 16)
|
||||
.unwrap();
|
||||
let bytes = self.take(length).await?;
|
||||
assert_eq!(self.take(2).await?, b"\r\n");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub async fn frame(&mut self) -> Result<(u8, bool, Vec<u8>), String> {
|
||||
let head = self.take(2).await?;
|
||||
let masked = head[1] & 128 != 0;
|
||||
let length = match head[1] & 127 {
|
||||
126 => u16::from_be_bytes(self.take(2).await?.try_into().unwrap()) as usize,
|
||||
127 => u64::from_be_bytes(self.take(8).await?.try_into().unwrap()) as usize,
|
||||
value => value as usize,
|
||||
};
|
||||
assert!(length <= 8 * 1024 * 1024, "bounded test frame");
|
||||
let mask = if masked {
|
||||
self.take(4).await?
|
||||
} else {
|
||||
vec![0; 4]
|
||||
};
|
||||
let mut bytes = self.take(length).await?;
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte ^= mask[index % 4];
|
||||
}
|
||||
Ok((head[0], masked, bytes))
|
||||
}
|
||||
|
||||
pub async fn send_frame(&self, opcode: u8, masked: bool, payload: &[u8]) -> Result<(), String> {
|
||||
assert!(payload.len() < 126, "small independent test frame");
|
||||
let mask = [17, 23, 31, 47];
|
||||
let mut wire = vec![opcode, payload.len() as u8 | if masked { 128 } else { 0 }];
|
||||
if masked {
|
||||
wire.extend_from_slice(&mask);
|
||||
}
|
||||
wire.extend(
|
||||
payload
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, byte)| byte ^ if masked { mask[index % 4] } else { 0 }),
|
||||
);
|
||||
self.write(&wire).await
|
||||
}
|
||||
|
||||
pub async fn upgrade(&mut self) -> Result<(), String> {
|
||||
self.write(b"GET /ws HTTP/1.1\r\nHost: test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").await?;
|
||||
let headers = self.head().await?.to_ascii_lowercase();
|
||||
assert!(headers.starts_with("http/1.1 101"), "{headers}");
|
||||
assert!(headers.contains("sec-websocket-accept: s3pplmbitxaq9kygzzhzrbk+xoo="));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user