Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
use super::{
http::{Server, request, request_head},
*,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, Mac};
use sha2::Sha256;
mod jobs;
mod metadata;
mod rest;
mod rest_json;
mod roles;
mod tasks;
use std::{
net::{TcpListener, TcpStream},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
pub(super) const KEY: &str = "native-test-key-only-not-for-deployment";
fn token(subject: &str, session: &str) -> String {
tenant_token(subject, session, None)
}
pub(super) fn tenant_token(subject: &str, session: &str, tenant: Option<&str>) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let claims = serde_json::json!({"iss":"session","aud":"admin","sub":subject,"sid":session,"tenant":tenant,"iat":now-1,"exp":now+59});
let signed = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#),
URL_SAFE_NO_PAD.encode(claims.to_string())
);
let mut mac = Hmac::<Sha256>::new_from_slice(KEY.as_bytes()).unwrap();
mac.update(signed.as_bytes());
format!(
"{signed}.{}",
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
)
}
#[test]
fn authenticated_http_executes_dever_verify_and_observes_revocation() {
let directory = Directory::new();
verify_authentication(
&directory,
serde_json::json!({"type":"sqlite","path":"data/account.db","max_connections":4}),
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_authenticated_http_executes_dever_verify_and_observes_revocation() {
let directory = Directory::new();
let schema = super::database::postgres::Schema::new(
&directory.0,
"dever_native_auth",
"CREATE SCHEMA dever_native_auth",
"DROP SCHEMA dever_native_auth CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 4.into();
verify_authentication(&directory, connection);
}
// 相同业务源码覆盖两种驱动的登录、角色、REST 和入队后撤销会话。
fn verify_authentication(directory: &Directory, connection: serde_json::Value) {
let project = Project::load(&fixture().with_file_name("auth")).unwrap();
let program = project.check().unwrap();
assert_eq!(program.verifiers.len(), 1);
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("auth"),
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::fs::create_dir(directory.0.join("config")).unwrap();
let settings = serde_json::json!({
"http":{"host":"127.0.0.1","port":port},
"sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"],"origin":"https://fixture.test"}},
"database":{"default":connection},
"runtime":{"mode":"api"},
"auth":{"providers":{"session":{"verify":"demo.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"}}}
});
std::fs::write(
directory.0.join("config/setting.json"),
settings.to_string(),
)
.unwrap();
let seeded = execute(
&binary,
"demo.account.seed",
r#"{"subject":"user","session":"valid-session"}"#,
);
assert!(
seeded.status.success(),
"{}",
String::from_utf8_lossy(&seeded.stderr)
);
let mut server = Server(
Command::new(&binary)
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let path = "/demo/account/admin/session/me";
metadata::check(port);
let (status, body) = request(
port,
"POST",
"/demo/account/admin/secret/probe",
"fixture.test",
"Content-Type: application/json\r\n",
r#"{"input":{"key":"fixture-only-key"}}"#,
);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["data"],
32
);
let health = "/demo/account/admin/session/health";
let login = "/demo/account/admin/session/login";
let origin = "Origin: https://fixture.test\r\nContent-Type: application/json\r\n";
let credentials = r#"{"subject":"user","session":"valid-session"}"#;
assert_eq!(
request(
port,
"POST",
login,
"fixture.test",
"Content-Type: application/json\r\n",
credentials
)
.0,
403
);
let (status, headers, _) =
request_head(port, "POST", login, "fixture.test", origin, credentials);
assert_eq!(status, 200);
let set_cookie = headers
.lines()
.find_map(|line| line.strip_prefix("set-cookie: "))
.unwrap();
assert!(set_cookie.contains("; Secure; HttpOnly; SameSite=Lax; Max-Age=60"));
let issued = format!("Cookie: {}\r\n", set_cookie.split(';').next().unwrap());
assert_eq!(
request(port, "GET", path, "fixture.test", &issued, "").0,
200
);
let (status, headers, _) = request_head(
port,
"POST",
"/demo/account/admin/session/login_fail",
"fixture.test",
origin,
credentials,
);
assert_eq!(status, 409);
assert!(!headers.to_ascii_lowercase().contains("set-cookie"));
let (status, headers, _) = request_head(
port,
"POST",
"/demo/account/admin/session/logout",
"fixture.test",
origin,
"{}",
);
assert_eq!(status, 200);
assert!(headers.contains("session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0"));
assert_eq!(request(port, "GET", path, "fixture.test", "", "").0, 401);
assert_eq!(
request(
port,
"GET",
&format!("{path}?extra=1"),
"fixture.test",
"",
""
)
.0,
401
);
assert_eq!(
request(
port,
"GET",
health,
"fixture.test",
"Cookie: session=expired\r\n",
""
)
.0,
200
);
assert_eq!(
request(
port,
"GET",
health,
"fixture.test",
"Authorization: Bearer invalid\r\n",
""
)
.0,
401
);
let bearer = format!(
"Authorization: Bearer {}\r\n",
token("user", "valid-session")
);
let cookie = format!(
"Cookie: session=v1.{}\r\n",
URL_SAFE_NO_PAD.encode(token("user", "valid-session"))
);
for credentials in [&bearer, &cookie] {
let (status, body) = request(port, "GET", path, "fixture.test", credentials, "");
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["data"],
serde_json::json!({"id":"user", "session":"valid-session", "site":"admin", "user_id":1, "tenant_id":null})
);
}
assert_eq!(
request(
port,
"GET",
&format!("{path}?extra=1"),
"fixture.test",
&bearer,
""
)
.0,
400
);
assert_eq!(
request(
port,
"GET",
path,
"fixture.test",
&format!("{bearer}{cookie}"),
""
)
.0,
401
);
assert_eq!(
request(
port,
"GET",
path,
"fixture.test",
&format!(
"Authorization: Bearer {}\r\n",
token("user", "revoked-session")
),
""
)
.0,
401
);
assert_eq!(
request(
port,
"POST",
path,
"fixture.test",
&format!("{cookie}Content-Type: application/json\r\n"),
"{}"
)
.0,
403
);
assert_eq!(
request(
port,
"POST",
path,
"fixture.test",
&format!("{cookie}Origin: https://fixture.test\r\nContent-Type: application/json\r\n"),
"{}"
)
.0,
200
);
let expired = execute(
&binary,
"demo.account.set_deadline",
r#"{"id":1,"expires":0}"#,
);
assert!(
expired.status.success(),
"{}",
String::from_utf8_lossy(&expired.stderr)
);
assert_eq!(
request(port, "GET", path, "fixture.test", &bearer, "").0,
401
);
let renewed = execute(
&binary,
"demo.account.set_deadline",
r#"{"id":1,"expires":9223372036854775807}"#,
);
assert!(
renewed.status.success(),
"{}",
String::from_utf8_lossy(&renewed.stderr)
);
assert_eq!(
request(port, "GET", path, "fixture.test", &bearer, "").0,
200
);
roles::check(&binary, port, &bearer);
rest::check(&binary, port, &bearer);
rest_json::check(port, &bearer);
let job_count = jobs::before_revocation(&binary, port, &bearer, &settings);
let revoked = execute(&binary, "demo.account.revoke", r#"{"id":1}"#);
assert!(revoked.status.success());
jobs::after_revocation(&binary, &settings, job_count);
assert_eq!(
request(port, "GET", path, "fixture.test", &bearer, "").0,
401
);
assert_eq!(
request(port, "GET", health, "fixture.test", &cookie, "").0,
200
);
assert_eq!(
request(port, "GET", health, "fixture.test", &bearer, "").0,
401
);
assert!(
Command::new("sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test",
&server.0.id().to_string()
])
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = server.0.try_wait().unwrap() {
assert!(status.success());
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let mut cross_pool = settings.clone();
cross_pool["database"]["demo"] =
serde_json::json!({"type":"sqlite","path":"data/other.db","max_connections":4});
std::fs::write(
directory.0.join("config/setting.json"),
cross_pool.to_string(),
)
.unwrap();
let rejected = Command::new(&binary).output().unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("transaction cannot span configured database pools"),
"{}",
String::from_utf8_lossy(&rejected.stderr)
);
assert!(TcpStream::connect(("127.0.0.1", port)).is_err());
let mut invalid = settings;
invalid["auth"]["providers"]["session"]["verify"] = "demo.account.unknown".into();
std::fs::write(directory.0.join("config/setting.json"), invalid.to_string()).unwrap();
let invalid = Command::new(&binary).output().unwrap();
assert!(!invalid.status.success());
assert!(String::from_utf8_lossy(&invalid.stderr).contains("uncompiled verify"));
}
+84
View File
@@ -0,0 +1,84 @@
use super::*;
fn count(binary: &Path) -> i64 {
let result = execute(binary, "demo.account.job_count", "{}");
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"]
.as_i64()
.unwrap()
}
fn submit(port: u16, bearer: &str, subject: &str) {
let (status, body) = request(
port,
"POST",
"/demo/account/admin/jobs/job_submit",
"fixture.test",
&format!("{bearer}Content-Type: application/json\r\n"),
&serde_json::json!({"subject":subject,"key":subject}).to_string(),
);
assert_eq!(status, 200, "{body}");
}
fn worker(binary: &Path, settings: &serde_json::Value) -> Server {
let path = binary.parent().unwrap().join("worker.json");
let mut settings = settings.clone();
settings["runtime"] = serde_json::json!({"mode":"worker","shutdown_ms":1000});
settings["job"] = serde_json::json!({"workers":1,"poll_ms":10,"lease_ms":2000});
std::fs::write(&path, settings.to_string()).unwrap();
Server(
Command::new(binary)
.args(["--config"])
.arg(path)
.spawn()
.unwrap(),
)
}
fn until(binary: &Path, server: &mut Server, expected: i64) {
let deadline = Instant::now() + Duration::from_secs(10);
while count(binary) != expected {
assert!(server.0.try_wait().unwrap().is_none(), "worker exited");
assert!(Instant::now() < deadline, "Job result not committed");
std::thread::sleep(Duration::from_millis(10));
}
}
pub(super) fn before_revocation(
binary: &Path,
port: u16,
bearer: &str,
settings: &serde_json::Value,
) -> i64 {
let before = count(binary);
submit(port, bearer, "job-valid");
let mut server = worker(binary, settings);
until(binary, &mut server, before + 1);
assert!(
Command::new("sh")
.args(["-c", "kill -TERM \"$1\"", "--", &server.0.id().to_string()])
.status()
.unwrap()
.success()
);
assert!(server.0.wait().unwrap().success());
submit(port, bearer, "job-revoked");
before + 1
}
pub(super) fn after_revocation(binary: &Path, settings: &serde_json::Value, before: i64) {
let submitted = execute(
binary,
"demo.account.job_submit",
r#"{"subject":"job-system","key":"system"}"#,
);
assert!(submitted.status.success());
let mut server = worker(binary, settings);
until(binary, &mut server, before + 1);
std::thread::sleep(Duration::from_millis(60));
assert_eq!(count(binary), before + 1, "revoked User job executed");
}
@@ -0,0 +1,35 @@
use super::*;
pub(super) fn check(port: u16) {
let headers = "Origin: https://fixture.test\r\nContent-Type: application/json\r\nX-Test: request-value\r\nCookie: theme=light\r\nX-Forwarded-For: 203.0.113.9\r\n";
let path = "/demo/account/admin/metadata/metadata";
let mut previous = None;
for _ in 0..2 {
let (status, head, body) = request_head(port, "POST", path, "fixture.test", headers, "{}");
assert_eq!(status, 200, "{body}");
let value = serde_json::from_str::<serde_json::Value>(&body).unwrap()["data"].clone();
assert_eq!(value["method"], "POST");
assert_eq!(value["path"], path);
assert_eq!(value["address"], "127.0.0.1");
assert_eq!(value["header"], "request-value");
assert_eq!(value["cookie"], "light");
let id = value["request_id"].as_str().unwrap().to_owned();
assert!(head.contains(&format!("x-request-id: {id}")));
assert!(head.contains("set-cookie: theme=dark; Path=/; SameSite=Lax; Secure; HttpOnly"));
assert!(head.contains(
"set-cookie: strict=yes; Path=/; SameSite=Strict; Max-Age=60; Secure; HttpOnly"
));
assert_ne!(previous.as_ref(), Some(&id));
previous = Some(id);
}
let (status, head, _) = request_head(
port,
"POST",
"/demo/account/admin/metadata/metadata_fail",
"fixture.test",
headers,
"{}",
);
assert_eq!(status, 409);
assert!(!head.contains("x-request-id:") && !head.contains("set-cookie:"));
}
+184
View File
@@ -0,0 +1,184 @@
use super::*;
pub(super) fn check(binary: &std::path::Path, port: u16, bearer: &str) {
let path = "/demo/account/admin/profile";
let call = |method: &str, path: &str, body: &str| {
let (status, body) = request(
port,
method,
path,
"fixture.test",
&format!("{bearer}Content-Type: application/json\r\n"),
body,
);
(
status,
serde_json::from_str::<serde_json::Value>(&body).unwrap()["data"].clone(),
)
};
assert_eq!(request(port, "GET", path, "fixture.test", "", "").0, 401);
for (method, path, allowed) in [
("PUT", path.to_owned(), "GET, POST"),
("POST", format!("{path}/1"), "GET, PUT, DELETE"),
] {
let (status, head, _) = request_head(port, method, &path, "fixture.test", bearer, "");
assert_eq!(status, 405);
assert!(
head.to_ascii_lowercase()
.contains(&format!("\r\nallow: {}\r\n", allowed.to_ascii_lowercase()))
);
}
assert_eq!(call("POST", &format!("{path}/invalid"), "").0, 404);
assert_eq!(call("GET", &format!("{path}?size=101"), "").0, 400);
assert_eq!(call("POST", path, r#"{"name":"","bio":null}"#).0, 400);
for body in [
r#"{"name":"first","name":"second"}"#,
r#"{"name":"first","na\u006de":"second"}"#,
r#"{"name":"bad","rank":9223372036854775808}"#,
r#"{"name":"bad","status":{"Active":null}}"#,
r#"{"name":"bad","bio":"\ud800"}"#,
r#"["bad",null,7,null,"Active"]"#,
] {
assert_eq!(call("POST", path, body).0, 400, "{body}");
}
assert_eq!(
call("POST", path, r#"{"name":"bad","note":"private"}"#).0,
400
);
assert_eq!(call("POST", path, r#"{"name":"bad","user_id":1}"#).0, 400);
assert_eq!(
call("POST", path, r#"{"name":"bad","creation_key":"forged"}"#).0,
400
);
let (status, created) = call("POST", path, r#"{"name":" Dever 中文 "}"#);
assert_eq!(status, 200, "{created}");
assert_eq!(created["name"], "Dever 中文");
assert_eq!(created["rank"], 7);
assert_eq!(created["status"], "Active");
assert_eq!(created["tag"], "automatic");
assert_eq!(created["uid"].as_str().unwrap().len(), 36);
assert!(created.get("note").is_none());
assert!(created.get("user_id").is_none());
assert!(created.get("creation_key").is_none());
assert_eq!(
call("POST", path, r#"{"name":"invalid-null","rank":null}"#).0,
400
);
assert_eq!(
call(
"POST",
path,
r#"{"name":"invalid-status","status":"Unknown"}"#
)
.0,
400
);
assert_eq!(
call(
"POST",
path,
&serde_json::json!({"name":"invalid-generated","uid":created["uid"]}).to_string()
)
.0,
400
);
assert!(created["created_at"].as_str().unwrap().ends_with('Z'));
let id = created["id"].as_i64().unwrap();
let detail = format!("{path}/{id}");
assert_eq!(call("GET", &detail, "").1, created);
let bootstrap = execute(
binary,
"dever.auth.owner",
r#"{"site":"admin","user_id":2}"#,
);
assert!(
bootstrap.status.success(),
"{}",
String::from_utf8_lossy(&bootstrap.stderr)
);
let other = format!(
"Authorization: Bearer {}\r\nContent-Type: application/json\r\n",
token("reader", "reader-session")
);
for (method, body) in [("GET", ""), ("PUT", r#"{"name":"stolen"}"#), ("DELETE", "")] {
assert_eq!(
request(port, method, &detail, "fixture.test", &other, body).0,
404
);
}
let (status, body) = request(port, "GET", path, "fixture.test", &other, "");
assert_eq!(status, 200);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["data"]["total"],
0
);
// 不同身份的创建键不同,名称重复仍须由名称自身的唯一约束拒绝。
assert_eq!(
request(
port,
"POST",
path,
"fixture.test",
&other,
r#"{"name":"Dever 中文"}"#
)
.0,
409
);
assert_eq!(call("POST", path, r#"{"name":"Dever 中文"}"#).0, 409);
let (status, page) = call("GET", path, "");
assert_eq!(status, 200);
assert_eq!(page["total"], 1);
assert_eq!(page["items"][0], created);
assert_eq!(
call("GET", &format!("{path}?name=absent"), "").1["total"],
0
);
assert_eq!(
call(
"GET",
&format!("{path}?name=%20Dever%20%E4%B8%AD%E6%96%87%20"),
""
)
.1["total"],
1
);
assert_eq!(
call("GET", &format!("{path}?page=2&size=1"), "").1["items"],
serde_json::json!([])
);
assert_eq!(call("GET", &format!("{detail}?extra=1"), "").0, 400);
assert_eq!(
call("PUT", &detail, r#"{"name":"bad","creation_key":"forged"}"#).0,
400
);
assert_eq!(
call(
"PUT",
&detail,
r#"{"name":"new","bio":"updated","tag":null}"#
)
.1["bio"],
"updated"
);
assert_eq!(call("GET", &detail, "").1["tag"], serde_json::Value::Null);
assert_eq!(
call("PUT", &detail, r#"{"name":"new","status":"Disabled"}"#).1["status"],
"Disabled"
);
let (status, replaced) = call("PUT", &detail, r#"{"name":"REPLACED"}"#);
assert_eq!(status, 200);
assert_eq!(replaced["bio"], serde_json::Value::Null);
assert_eq!(replaced["name"], "replaced");
assert!(replaced.get("creation_key").is_none());
// POST:user 仍在数据库中;若 PUT 重算为 PUT:user 或清空,新名称的创建会错误成功。
assert_eq!(call("POST", path, r#"{"name":"another"}"#).0, 409);
assert_eq!(call("GET", &detail, "").1, replaced);
let (_, page) = call("GET", path, "");
assert_eq!(page["total"], 1);
assert_eq!(page["items"][0], replaced);
assert_eq!(call("DELETE", &detail, "").0, 200);
assert_eq!(call("DELETE", &detail, "").0, 404);
assert_eq!(call("GET", &detail, "").0, 404);
assert_eq!(call("GET", path, "").1["total"], 0);
}
@@ -0,0 +1,78 @@
use super::*;
pub(super) fn check(port: u16, bearer: &str) {
let path = "/demo/document/admin/content";
let call = |method: &str, path: &str, body: &str| {
request(
port,
method,
path,
"fixture.test",
&format!("{bearer}Content-Type: application/json\r\n"),
body,
)
};
let (status, body) = call("POST", path, r#"{"value": {"count":1.00,"text":"\u96ea"}}"#);
assert_eq!(status, 200, "{body}");
assert!(body.contains("1.00"));
assert!(body.contains(r"\u96ea"));
let created: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(created["data"]["extra"].is_null());
let detail = format!("{path}/{}", created["data"]["id"].as_i64().unwrap());
for target in [&detail, path] {
let (status, body) = call("GET", target, "");
assert_eq!(status, 200, "{body}");
assert!(body.contains("1.00"));
assert!(body.contains(r"\u96ea"));
let result: serde_json::Value = serde_json::from_str(&body).unwrap();
let row = if target == path {
assert_eq!(result["data"]["total"], 1);
&result["data"]["items"][0]
} else {
&result["data"]
};
assert_eq!(row, &created["data"]);
}
let (status, body) = call("PUT", &detail, r#"{"value":null,"extra":{"ready":true}}"#);
assert_eq!(status, 200, "{body}");
let updated: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(updated["data"]["value"].is_null());
assert_eq!(updated["data"]["extra"]["ready"], true);
let (status, body) = call("PUT", &detail, r#"{"value":1e9999,"extra":-0}"#);
assert_eq!(status, 200, "{body}");
for target in [&detail, path] {
let (status, body) = call("GET", target, "");
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"value\":1e9999"));
assert!(body.contains("\"extra\":-0"));
}
assert_eq!(call("PUT", &detail, r#"{"value":[],"extra":null}"#).0, 200);
for body in [
r#"{}"#.to_owned(),
r#"[{},null]"#.to_owned(),
r#"{"value":0} null"#.to_owned(),
r#"{"value":{"a":1,"\u0061":2}}"#.to_owned(),
r#"{"value":0,"extra":{"a":1,"a":2}}"#.to_owned(),
r#"{"value":0,"extra":"\ud800"}"#.to_owned(),
r#"{"value":0,"extra":null,"extra":null}"#.to_owned(),
r#"{"value":0,"unknown":null}"#.to_owned(),
format!("{{\"value\":{}{}}}", "[".repeat(64), "]".repeat(64)),
format!(
"{{\"value\":[{}],\"extra\":[0]}}",
vec!["0"; 65534].join(",")
),
] {
assert_eq!(call("POST", path, &body).0, 400);
assert_eq!(call("PUT", &detail, &body).0, 400);
}
assert_eq!(call("DELETE", &detail, "").0, 200);
let body = format!("{{\"value\":{}{}}}", "[".repeat(61), "]".repeat(61));
let (status, body) = call("POST", path, &body);
assert_eq!(status, 200, "{body}");
let created: serde_json::Value = serde_json::from_str(&body).unwrap();
let detail = format!("{path}/{}", created["data"]["id"].as_i64().unwrap());
assert_eq!(call("GET", &detail, "").0, 200);
// 分页比详情多两层容器,必须把响应自身计入深度预算。
assert_eq!(call("GET", path, "").0, 500);
assert_eq!(call("DELETE", &detail, "").0, 200);
}
+159
View File
@@ -0,0 +1,159 @@
use super::{execute, request, token};
use serde_json::{Value, json};
use std::path::Path;
fn get(port: u16, action: &str, credentials: &str) -> (u16, Value) {
let (status, body) = request(
port,
"GET",
&format!("/demo/account/admin/roles/{action}"),
"fixture.test",
credentials,
"",
);
(status, serde_json::from_str(&body).unwrap())
}
fn post(port: u16, action: &str, credentials: &str, input: Value) -> (u16, Value) {
let (status, body) = request(
port,
"POST",
&format!("/demo/account/admin/roles/{action}"),
"fixture.test",
&format!("{credentials}Content-Type: application/json\r\n"),
&input.to_string(),
);
(status, serde_json::from_str(&body).unwrap())
}
pub(super) fn check(binary: &Path, port: u16, owner: &str) {
assert_eq!(get(port, "protected_me?unexpected=1", owner).0, 403);
assert_eq!(
post(port, "save_role", owner, json!({"unexpected":1})).0,
403
);
assert_eq!(get(port, "current_permissions", owner).1["data"], json!([]));
let bootstrap = execute(
binary,
"dever.auth.owner",
r#"{"site":"admin","user_id":1}"#,
);
assert!(
bootstrap.status.success(),
"{}",
String::from_utf8_lossy(&bootstrap.stderr)
);
assert_eq!(get(port, "protected_me", owner).0, 200);
assert_eq!(get(port, "protected_me?unexpected=1", owner).0, 400);
assert_eq!(get(port, "is_owner?user_id=1", owner).1["data"], true);
let directory = get(port, "permissions", owner).1["data"]
.as_array()
.unwrap()
.clone();
assert!(
directory
.iter()
.any(|entry| entry["key"] == "demo.account.admin.protected_me")
);
assert!(
!directory
.iter()
.any(|entry| entry["key"] == "demo.account.admin.me")
);
assert_eq!(
get(port, "current_permissions", owner).1["data"],
json!(directory)
);
for (action, input) in [
("delete_role", json!({"role_id":"owner"})),
("disable_role", json!({"role_id":"owner"})),
("revoke_role", json!({"role_id":"owner","user_id":1})),
("grant_role", json!({"role_id":"owner","user_id":2})),
(
"save_role",
json!({"id":"owner","name":"changed","all_permissions":false,"permission_keys":[]}),
),
] {
assert_eq!(post(port, action, owner, input).0, 403, "{action}");
}
let seeded = execute(
binary,
"demo.account.seed",
r#"{"subject":"reader","session":"reader-session"}"#,
);
assert!(seeded.status.success());
let reader = format!(
"Authorization: Bearer {}\r\n",
token("reader", "reader-session")
);
assert_eq!(get(port, "protected_me", &reader).0, 403);
let role = json!({"id":"reader","name":"Reader","all_permissions":false,"permission_keys":["demo.account.admin.protected_me"]});
assert_eq!(post(port, "save_role", owner, role.clone()).0, 200);
assert_eq!(
post(
port,
"grant_role",
owner,
json!({"user_id":2,"role_id":"reader"})
)
.0,
200
);
assert_eq!(get(port, "protected_me", &reader).0, 200);
assert_eq!(
get(port, "user_roles?user_id=2", &reader).1["data"][0]["permission_keys"],
json!(["demo.account.admin.protected_me"])
);
assert_eq!(get(port, "is_owner?user_id=2", &reader).1["data"], false);
assert_eq!(post(port, "save_role", &reader, role.clone()).0, 403);
let mut invalid = role.clone();
invalid["permission_keys"] = json!(["demo.account.admin.unknown"]);
assert_eq!(post(port, "save_role", owner, invalid).0, 400);
assert_eq!(get(port, "protected_me", &reader).0, 200);
assert_eq!(
post(port, "role_fail", owner, json!({"id":"rolled_back"})).0,
409
);
assert!(get(port, "role?id=rolled_back", owner).1["data"].is_null());
assert_eq!(
post(port, "disable_role", owner, json!({"role_id":"reader"})).0,
200
);
assert_eq!(get(port, "protected_me", &reader).0, 403);
assert_eq!(
get(port, "role?id=reader", owner).1["data"]["enabled"],
false
);
assert_eq!(post(port, "save_role", owner, role).0, 200);
assert_eq!(get(port, "protected_me", &reader).0, 200);
assert_eq!(
post(
port,
"revoke_role",
owner,
json!({"user_id":2,"role_id":"reader"})
)
.0,
200
);
assert_eq!(get(port, "protected_me", &reader).0, 403);
assert_eq!(
post(
port,
"grant_role",
owner,
json!({"user_id":2,"role_id":"reader"})
)
.0,
200
);
assert_eq!(
post(port, "delete_role", owner, json!({"role_id":"reader"})).0,
200
);
assert_eq!(
get(port, "user_roles?user_id=2", owner).1["data"],
json!([])
);
assert_eq!(get(port, "protected_me", &reader).0, 403);
}
+148
View File
@@ -0,0 +1,148 @@
use super::*;
#[test]
fn concurrent_enqueues_preserve_system_and_revalidated_user_identity() {
let directory = Directory::new();
let mut project = Project::load(&fixture().with_file_name("auth")).unwrap();
for source in &mut project.sources {
let text = if source.path.ends_with("app/jobs.dever") {
include_str!("../../../fixtures/job-tasks.dever").into()
} else if source.path.ends_with("api/admin/jobs.dever") {
source
.text
.replace("post job_submit =", "long post job_submit =")
.replace("cmd job_submit", "long cmd job_submit")
} else {
continue;
};
*source = dever::syntax::source::Source::new(source.id, source.path.clone(), text);
}
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("tasks"),
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::fs::create_dir(directory.0.join("config")).unwrap();
let mut settings = serde_json::json!({
"http":{"host":"127.0.0.1","port":port},
"sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"],"origin":"https://fixture.test"}},
"database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":8}},
"runtime":{"mode":"api","shutdown_ms":1000},
"auth":{"providers":{"session":{"verify":"demo.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"}}},
"job":{"workers":1,"poll_ms":10,"lease_ms":2000}
});
std::fs::write(
directory.0.join("config/setting.json"),
settings.to_string(),
)
.unwrap();
let run = |command: &str, input: &str| {
let result = execute(&binary, command, input);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
run(
"demo.account.seed",
r#"{"subject":"user","session":"valid-session"}"#,
);
run("dever.auth.owner", r#"{"site":"admin","user_id":1}"#);
let mut server = Server(Command::new(&binary).spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let bearer = format!(
"Authorization: Bearer {}\r\nContent-Type: application/json\r\n",
token("user", "valid-session")
);
let forged = request(
port,
"POST",
"/demo/account/admin/jobs/job_submit",
"fixture.test",
&bearer,
r#"{"subject":"forged","key":"forged","execution":{"kind":"System"}}"#,
);
assert_eq!(forged.0, 400, "{}", forged.1);
let submit = |subject: &str| {
let (status, body) = request(
port,
"POST",
"/demo/account/admin/jobs/job_submit",
"fixture.test",
&bearer,
&serde_json::json!({"subject":subject,"key":subject}).to_string(),
);
assert_eq!(status, 200, "{body}");
};
// payload 的 subject 与登录用户不同,不能用它替换请求建立的可信执行者。
submit("other-person");
run(
"demo.account.job_submit",
r#"{"subject":"system","key":"system"}"#,
);
settings["runtime"]["mode"] = "worker".into();
let worker_config = directory.0.join("worker.json");
std::fs::write(&worker_config, settings.to_string()).unwrap();
let start = || {
Server(
Command::new(&binary)
.arg("--config")
.arg(&worker_config)
.spawn()
.unwrap(),
)
};
let until = |worker: &mut Server, expected: i64| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let actual = run("demo.account.job_count", "{}");
if actual == expected {
break;
}
assert!(worker.0.try_wait().unwrap().is_none());
assert!(
Instant::now() < deadline,
"concurrent Job did not complete: expected {expected}, got {actual}"
);
std::thread::sleep(Duration::from_millis(10));
}
};
let mut worker = start();
until(&mut worker, 13);
assert!(
Command::new("sh")
.args(["-c", "kill -TERM \"$1\"", "--", &worker.0.id().to_string()])
.status()
.unwrap()
.success()
);
assert!(worker.0.wait().unwrap().success());
submit("revoked");
run("demo.account.revoke", r#"{"id":1}"#);
run(
"demo.account.job_submit",
r#"{"subject":"system-next","key":"system-next"}"#,
);
let mut worker = start();
until(&mut worker, 19);
std::thread::sleep(Duration::from_millis(100));
assert_eq!(
run("demo.account.job_count", "{}"),
19,
"revoked User was upgraded to System"
);
}
+246
View File
@@ -0,0 +1,246 @@
use super::*;
mod defaults;
mod migration;
#[cfg(feature = "postgres")]
pub(super) mod postgres;
mod query;
mod read;
mod relation;
mod scalar;
mod text;
#[test]
fn database_source_compiles_and_persists_with_entry_rollback() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("database"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":1,"max_page_size":20}}}"#).unwrap();
let run = |name: &str, input: &str| {
let result = execute(&binary, &format!("demo.account.{name}"), input);
assert!(
result.status.success(),
"{name}: {}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
let created = run("create", r#"{"name":"Dever 中文"}"#);
assert_eq!(created["id"], 1);
assert_eq!(created["name"], "Dever 中文");
assert!(created["note"].is_null());
assert!(created["created_at"].as_str().unwrap().ends_with('Z'));
assert_eq!(run("get", r#"{"id":1}"#), created);
for input in [
r#"{"name":"Dever 中文"}"#.to_owned(),
r#"{"name":""}"#.to_owned(),
serde_json::json!({"name": "雪".repeat(65)}).to_string(),
] {
assert!(
!execute(&binary, "demo.account.create", &input)
.status
.success()
);
}
assert_eq!(
run("rename", r#"{"id":1,"name":"updated"}"#)["name"],
"updated"
);
let page = run("search", r#"{"name":"updated","page":1,"size":2}"#);
assert_eq!(page["items"][0]["id"], 1);
assert_eq!(page["total"], 1);
assert_eq!(page["pages"], 1);
assert_eq!(
run("search", r#"{"name":"updated","page":2,"size":2}"#)["items"],
serde_json::json!([])
);
assert_eq!(run("find", "{}")["id"], 1);
assert!(run("find", r#"{"note":"missing"}"#).is_null());
assert!(
!execute(
&binary,
"demo.account.search",
r#"{"name":"updated","page":1,"size":21}"#
)
.status
.success()
);
let failed = execute(
&binary,
"demo.account.explode",
r#"{"name":"rollback","value":9223372036854775807}"#,
);
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("integer arithmetic fault"));
assert_eq!(run("count", "{}"), 1);
assert_eq!(run("remove", r#"{"id":1}"#), 1);
assert_eq!(run("count", "{}"), 0);
let embedded_nul = format!("{}\0", "雪".repeat(63));
let nul_record = run(
"create",
&serde_json::json!({"name": embedded_nul}).to_string(),
);
assert_eq!(nul_record["name"], embedded_nul);
assert!(
!execute(
&binary,
"demo.account.create",
&serde_json::json!({"name": format!("{}\0", "雪".repeat(64))}).to_string()
)
.status
.success()
);
assert_eq!(
run(
"remove",
&serde_json::json!({"id": nul_record["id"]}).to_string()
),
1
);
let missing = execute(&binary, "demo.account.get", r#"{"id":1}"#);
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("database operation failed"));
assert!(
!execute(
&binary,
"demo.account.stage",
r#"{"name":"committed stage","value":9223372036854775807}"#
)
.status
.success()
);
assert_eq!(run("count", "{}"), 1);
let stage = run("find", "{}");
assert_eq!(stage["name"], "committed stage");
run(
"remove",
&serde_json::json!({"id": stage["id"]}).to_string(),
);
http_round_trip(&binary, &directory.0);
}
fn http_round_trip(binary: &Path, directory: &Path) {
use super::http::{Server, request};
use std::{
net::{TcpListener, TcpStream},
time::{Duration, Instant},
};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let config = directory.join("setting-http.json");
std::fs::write(&config, format!(r#"{{"http":{{"host":"127.0.0.1","port":{port}}},"sites":{{"public":{{"path":"","hosts":["fixture.test"]}}}},"database":{{"default":{{"type":"sqlite","path":"data/account.db","max_connections":1,"max_page_size":20}}}}}}"#)).unwrap();
let mut server = Server(
Command::new(binary)
.args(["--config", config.to_str().unwrap()])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let (status, body) = request(
port,
"POST",
"/demo/account/create",
"fixture.test",
"Content-Type: application/json\r\n",
r#"{"name":"HTTP 中文"}"#,
);
assert_eq!(status, 200, "{body}");
let created: serde_json::Value = serde_json::from_str(&body).unwrap();
let id = created["data"]["id"].as_i64().unwrap();
let (status, body) = request(
port,
"GET",
&format!("/demo/account/get?id={id}"),
"fixture.test",
"",
"",
);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
created
);
assert_eq!(
request(
port,
"POST",
"/demo/account/explode",
"fixture.test",
"Content-Type: application/json\r\n",
r#"{"name":"rollback","value":9223372036854775807}"#
)
.0,
500
);
assert_eq!(
request(
port,
"GET",
"/demo/account/get?id=99999",
"fixture.test",
"",
""
)
.0,
500
);
let count = execute(binary, "demo.account.count", "{}");
assert!(count.status.success());
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&count.stdout).unwrap()["data"],
1
);
assert_eq!(
request(
port,
"POST",
"/demo/account/stage",
"fixture.test",
"Content-Type: application/json\r\n",
r#"{"name":"HTTP committed stage","value":9223372036854775807}"#
)
.0,
500
);
let count = execute(binary, "demo.account.count", "{}");
assert!(count.status.success());
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&count.stdout).unwrap()["data"],
2
);
assert!(
Command::new("sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test",
&server.0.id().to_string()
])
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = server.0.try_wait().unwrap() {
assert!(status.success());
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
}
@@ -0,0 +1,208 @@
use super::*;
#[test]
fn generated_uuid_is_available_when_entries_only_return_counts() {
let directory = Directory::new();
fixture_defaults(&directory.0);
let source = directory.0.join("module/demo/account");
std::fs::write(
source.join("app.dever"),
r#"seed() (total: Int) {
total = model.createMany([{}, {}])
}
"#,
)
.unwrap();
std::fs::write(
source.join("api.dever"),
r#"cmd seed = app.seed
"#,
)
.unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("counts"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"counts.db"}}}"#,
)
.unwrap();
let output = execute(&binary, "demo.account.seed", "{}");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"],
2
);
}
#[test]
fn database_defaults_choice_and_generated_uuid_survive_native_roundtrips() {
let directory = Directory::new();
verify_defaults(
&directory,
serde_json::json!({"type":"sqlite","path":"data/defaults.db","max_connections":1,"max_page_size":20}),
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_dever_defaults_choice_and_generated_uuid_survive_native_roundtrips() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_defaults",
"CREATE SCHEMA dever_native_defaults",
"DROP SCHEMA dever_native_defaults CASCADE",
);
verify_defaults(&directory, schema.connection.clone());
}
fn verify_defaults(directory: &Directory, connection: serde_json::Value) {
fixture_defaults(&directory.0);
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("defaults"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::to_vec(&serde_json::json!({"database":{"default":connection}})).unwrap(),
)
.unwrap();
let run = |name: &str, input: &str| {
let output = execute(&binary, &format!("demo.account.{name}"), input);
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", "{}");
assert_eq!(first["name"], "O'Reilly");
assert_eq!(
dever_runtime::value::decimal::Decimal::parse(first["amount"].as_str().unwrap()).unwrap(),
dever_runtime::value::decimal::Decimal::parse("12.30").unwrap()
);
assert_eq!(first["active"], true);
assert_eq!(first["enabled"], true);
assert_eq!(first["note"], "default note");
assert_eq!(first["day"], "2000-02-29");
assert_eq!(first["time"], "23:59:59.999");
assert_eq!(first["elapsed"], -1);
assert_eq!(first["document"], serde_json::json!({"enabled":true}));
assert_eq!(first["empty"], serde_json::Value::Null);
let uuid = first["uid"].as_str().unwrap();
assert_eq!(&uuid[14..15], "7");
assert!(matches!(&uuid[19..20], "8" | "9" | "a" | "b"));
assert_eq!(run("get", r#"{"key":1}"#), first);
let second = run("override", r#"{"value":null}"#);
assert!(second["note"].is_null());
assert_eq!(second["active"], false);
assert_ne!(first["uid"], second["uid"]);
assert_eq!(run("get", r#"{"key":2}"#), second);
let failed = execute(
&binary,
"demo.account.explode",
r#"{"value":9223372036854775807}"#,
);
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("integer arithmetic fault"));
assert_eq!(run("count", "{}"), 2);
assert_eq!(run("option", "{}"), "启用");
}
pub(super) fn fixture_defaults(root: &Path) {
let root = root.join("module/demo/account");
std::fs::create_dir_all(&root).unwrap();
let fields = [
("uid", "Uuid", "generated unique"),
("name", "Text(1, 20)", "default \"O'Reilly\""),
("amount", "Decimal(5, 2)", "default 12.30"),
("status", "Status", "default Status.Active"),
("enabled", "Bool", "default true"),
("note", "Text?", "default \"default note\""),
("day", "Date", "default \"2000-02-29\""),
("time", "Time", "default \"23:59:59.999\""),
("elapsed", "Duration", "default -1"),
("document", "Json", "default \"{\\\"enabled\\\":true}\""),
("empty", "Int?", "default null"),
];
let fields = fields
.iter()
.map(|(name, kind, metadata)| format!("{name}: {kind} {metadata}\n"))
.collect::<String>();
std::fs::write(root.join("model.dever"), format!("type Status {{ Active = \"启用\"\nDisabled = \"禁用\" }}\n\ntype Account {{\n{fields}}}\n")).unwrap();
let outputs = "id: model.id, uid: Uuid, name: Text, amount: Decimal, active: Bool, enabled: Bool, note: Text?, day: Date, time: Time, elapsed: Duration, document: Json, empty: Int?";
let view = outputs
.split(", ")
.map(|output| {
let field = output.split_once(':').unwrap().0;
if field == "active" {
"active = row.status == model.Status.Active\n".into()
} else {
format!("{field} = row.{field}\n")
}
})
.collect::<String>();
let methods = [
(
"create",
"",
outputs,
format!("row = model.create({{}})\n{view}"),
),
(
"get",
"key: model.id",
outputs,
format!("row = model.get(key)\n{view}"),
),
(
"override",
"value: Text?",
outputs,
format!(
"row = model.create({{ note = value, status = model.Status.Disabled }})\n{view}"
),
),
(
"explode",
"value: Int",
"id: model.id, result: Int",
"row = model.create({})\nid = row.id\nresult = value + 1".into(),
),
("count", "", "total: Int", "total = model.count()".into()),
(
"option",
"",
"label: Text?",
"choices = model.Status.options\nlabel = get(choices, model.Status.Active)".into(),
),
];
let mut app = String::new();
let mut api = String::new();
for (name, inputs, outputs, body) in methods {
app.push_str(&format!("{name}({inputs}) ({outputs}) {{\n{body}\n}}\n"));
api.push_str(&format!("cmd {name} = app.{name}\n"));
}
std::fs::write(root.join("app.dever"), app).unwrap();
std::fs::write(root.join("api.dever"), api).unwrap();
}
@@ -0,0 +1,116 @@
use super::*;
#[test]
fn database_migrations_preserve_data_and_reject_loss_or_changed_history() {
let directory = Directory::new();
defaults::fixture_defaults(&directory.0);
let source = directory.0.join("module/demo/account");
let original_model = std::fs::read_to_string(source.join("model.dever"))
.unwrap()
.replace(
"type Account {\n",
"type Account {\nlegacy: Text default \"old\"\n",
);
std::fs::write(source.join("model.dever"), &original_model).unwrap();
let original = compile(&directory.0, "original");
settings(&directory.0, "data/account.db");
let run = |binary: &Path, name: &str, input: &str| {
let result = execute(binary, &format!("demo.account.{name}"), input);
assert!(
result.status.success(),
"{name}: {}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
let first = run(&original, "create", "{}");
assert_eq!(first["name"], "O'Reilly");
let target = original_model
.replace("legacy: Text default \"old\"\n", "")
.replace("name: Text(1, 20)", "display_name: Text(1, 8)")
.replace(
"default \"O'Reilly\"",
"default \"O'Reilly\" from name unique",
)
.replace("Decimal(5, 2)", "Decimal(7, 3)")
+ UPGRADE;
std::fs::write(source.join("model.dever"), &target).unwrap();
let app = std::fs::read_to_string(source.join("app.dever"))
.unwrap()
.replace("row.name", "row.display_name");
std::fs::write(source.join("app.dever"), app).unwrap();
let upgraded = compile(&directory.0, "upgraded");
let next = run(&upgraded, "get", r#"{"key":1}"#);
assert_eq!(next["name"], "Final");
assert_eq!(next["uid"], first["uid"]);
assert_eq!(next["amount"], first["amount"]);
assert_eq!(run(&upgraded, "count", "{}"), 2);
assert_eq!(run(&upgraded, "get", r#"{"key":2}"#)["name"], "Seed");
assert_eq!(run(&upgraded, "count", "{}"), 2);
assert!(
!execute(&original, "demo.account.count", "{}")
.status
.success()
);
std::fs::write(
source.join("model.dever"),
target.replace("[\"Final\",\"Editor\"]", "[\"Changed\",\"Editor\"]"),
)
.unwrap();
let changed = compile(&directory.0, "changed");
let rejected = execute(&changed, "demo.account.count", "{}");
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("applied migration content or order changed")
);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#), next);
let narrow = target
.replace("Text(1, 8)", "Text(1, 4)")
.replace("default \"O'Reilly\"", "default \"Ok\"");
std::fs::write(source.join("model.dever"), narrow).unwrap();
let narrowed = compile(&directory.0, "narrowed");
assert!(
!execute(&narrowed, "demo.account.count", "{}")
.status
.success()
);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#), next);
assert_eq!(run(&upgraded, "count", "{}"), 2);
settings(&directory.0, "data/fresh.db");
// 空库的最终表没有历史 name/legacy;执行旧数据 before 会失败,因此验证确实跳过。
assert_eq!(run(&upgraded, "count", "{}"), 1);
assert_eq!(run(&upgraded, "get", r#"{"key":1}"#)["name"], "Seed");
}
fn compile(root: &Path, name: &str) -> std::path::PathBuf {
let project = Project::load(root).unwrap();
let program = project.check().unwrap();
build::build(&program, &project.sources, "main", &root.join(name)).unwrap()
}
fn settings(root: &Path, path: &str) {
std::fs::create_dir_all(root.join("config")).unwrap();
std::fs::write(root.join("config/setting.json"),format!(r#"{{"database":{{"default":{{"type":"sqlite","path":"{path}","max_connections":1,"max_page_size":20}}}}}}"#)).unwrap();
}
const UPGRADE: &str = r#"index(display_name, created_at)
migrate normalize {
drop legacy
before {
sqlite = "UPDATE demo_account_model SET name = ?1 WHERE name = ?2"
postgres = "UPDATE demo_account_model SET name = $1 WHERE name = $2"
parameters = ["Editor","O'Reilly"]
}
after {
sqlite = "UPDATE demo_account_model SET display_name = ?1 WHERE display_name = ?2"
postgres = "UPDATE demo_account_model SET display_name = $1 WHERE display_name = $2"
parameters = ["Final","Editor"]
}
}
seed {
{ display_name = "Seed", amount = 1 }
}
"#;
@@ -0,0 +1,177 @@
use super::*;
use dever_runtime::{
database::{Database, Options, Sql},
task::{Limits, Runtime},
};
// 验收只读取显式测试连接,并独占一个新 schema;失败也清理本次创建的对象。
pub(in crate::application) struct Schema {
database: Database,
runtime: Runtime,
drop_sql: &'static str,
pub connection: serde_json::Value,
}
impl Drop for Schema {
fn drop(&mut self) {
self.runtime
.run(async |scope| {
self.database
.executor()
.await
.unwrap()
.initialize(scope, Sql::same(self.drop_sql))
.await
.unwrap();
})
.unwrap();
self.database.close();
}
}
impl Schema {
pub fn new(
directory: &Path,
name: &'static str,
create_sql: &'static str,
drop_sql: &'static str,
) -> Self {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let settings: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("config/setting.json")).unwrap())
.unwrap();
let mut connection = settings["database"]["postgres_test"].clone();
let options: Options = serde_json::from_value(connection.clone()).unwrap();
assert_eq!(options.driver, "postgres");
let runtime = Runtime::new(Limits::default()).unwrap();
let database = runtime
.run(async |scope| {
let database = Database::open(directory, options, scope).await.unwrap();
database
.executor()
.await
.unwrap()
.initialize(scope, Sql::same(create_sql))
.await
.unwrap();
database
})
.unwrap();
let mut schema = Schema {
database,
runtime,
drop_sql,
connection: serde_json::Value::Null,
};
let mut url = url::Url::parse(connection["url"].as_str().unwrap()).unwrap();
assert!(
!url.query_pairs().any(|(key, _)| key == "options"),
"test connection must not override search_path"
);
url.query_pairs_mut()
.append_pair("options", &format!("-csearch_path={name}"));
connection["url"] = serde_json::Value::String(url.into());
schema.connection = connection;
schema
}
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_dever_queries_roundtrip_and_rollback() {
let directory = Directory::new();
let schema = Schema::new(
&directory.0,
"dever_native_queries",
"CREATE SCHEMA dever_native_queries",
"DROP SCHEMA dever_native_queries CASCADE",
);
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("postgres"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::to_vec(&serde_json::json!({"database":{"default":schema.connection}})).unwrap(),
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", serde_json::json!({"name":"雪_%' OR 1=1 --"}));
assert_eq!(&first["uid"].as_str().unwrap()[14..15], "7");
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), first);
assert_eq!(
run("probe", serde_json::json!({"needle":"_%'"}))["contained"],
1
);
assert_eq!(
run("probe", serde_json::json!({"needle":""}))["prefixed"],
1
);
assert_eq!(
run("bulk", serde_json::json!({"first":"one","second":"two"})),
2
);
let recent = run("recent", serde_json::json!({}));
let items = recent["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["rank"], 9);
assert_eq!(items[1]["rank"], 7);
let unique: std::collections::HashSet<_> = items
.iter()
.map(|row| row["uid"].as_str().unwrap())
.collect();
assert_eq!(unique.len(), 3);
let created = run(
"save",
serde_json::json!({"name":"upsert","initial":"first","replacement":"ignored"}),
);
let updated = run(
"save",
serde_json::json!({"name":"upsert","initial":"ignored","replacement":"changed"}),
);
assert_eq!(updated["id"], created["id"]);
assert_eq!(updated["uid"], created["uid"]);
assert_eq!(updated["created_at"], created["created_at"]);
assert_eq!(updated["note"], "changed");
assert_eq!(run("retain", serde_json::json!({"name":"upsert"})), updated);
let page = run("scan", serde_json::json!({"after":null,"size":2}));
assert_eq!(page["has_more"], true);
let next = run("scan", serde_json::json!({"after":page["next"],"size":2}));
assert_eq!(next["has_more"], false);
assert_eq!(next["items"].as_array().unwrap().len(), 2);
assert_ne!(page["items"][0]["id"], next["items"][0]["id"]);
let count = run("count", serde_json::json!({}));
for (command, input) in [
(
"bulk",
serde_json::json!({"first":"rollback","second":"one"}),
),
(
"failed_bulk",
serde_json::json!({"first":"rollback1","second":"rollback2","value":i64::MAX}),
),
("create", serde_json::json!({"name":"embedded\0nul"})),
] {
let failed = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(!failed.status.success(), "{command}");
assert_eq!(run("count", serde_json::json!({})), count);
}
}
@@ -0,0 +1,246 @@
use super::*;
#[test]
fn conditional_writes_compare_and_modify_in_one_statement() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("conditional"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"conditional.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let first = run("create", serde_json::json!({"name":"original"}));
run("create", serde_json::json!({"name":"other"}));
let input =
serde_json::json!({"id":first["id"],"expected":"original","note":null,"next":"updated"});
assert_eq!(run("compare_name", input.clone()), 1);
assert_eq!(run("compare_name", input), 0);
let updated = run("get", serde_json::json!({"id":first["id"]}));
assert_eq!(updated["name"], "updated");
assert_eq!(updated["note"], "changed");
let invalid = serde_json::json!({"id":first["id"],"expected":"updated","note":"changed","next":"x".repeat(65)});
assert!(
!execute(&binary, "demo.account.compare_name", &invalid.to_string())
.status
.success()
);
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), updated);
assert_eq!(
run("remove_matching", serde_json::json!({"rank":7,"note":null})),
1
);
assert_eq!(
run("remove_matching", serde_json::json!({"rank":7,"note":null})),
0
);
assert_eq!(run("get", serde_json::json!({"id":first["id"]})), updated);
assert_eq!(
run(
"remove_matching",
serde_json::json!({"rank":7,"note":"changed"})
),
1
);
assert_eq!(run("count", serde_json::json!({})), 0);
}
#[test]
fn static_query_predicates_match_literal_text_and_inclusive_ranges() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database")).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("queries"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"query.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let names = [
"ab",
"Ab",
"a\0b",
"ab\0",
"前雪尾",
"雪_%' OR 1=1 --",
"excluded",
];
let created: Vec<_> = names
.into_iter()
.map(|name| run("create", serde_json::json!({"name":name})))
.collect();
let recent = run("recent", serde_json::json!({}));
assert_eq!(
recent["items"],
serde_json::json!(created.iter().rev().collect::<Vec<_>>())
);
assert_eq!(recent["has_more"], false);
assert!(recent["next"].is_null());
let mut ordered: Vec<_> = created
.iter()
.filter(|row| row["name"] != "excluded")
.cloned()
.collect();
ordered.sort_by(|left, right| {
left["created_at"]
.as_str()
.cmp(&right["created_at"].as_str())
.then_with(|| right["name"].as_str().cmp(&left["name"].as_str()))
.then_with(|| right["id"].as_i64().cmp(&left["id"].as_i64()))
});
for size in [1, 2, 4, 20] {
let mut after = serde_json::Value::Null;
let mut seen = Vec::new();
loop {
let page = run("scan", serde_json::json!({"after":after,"size":size}));
let items = page["items"].as_array().unwrap();
assert!(items.len() <= size);
seen.extend(items.iter().cloned());
assert!(seen.len() <= ordered.len(), "cursor must advance");
if page["has_more"] == false {
assert!(page["next"].is_null());
break;
}
assert_eq!(page["next"], *items.last().unwrap());
after = page["next"].clone();
}
assert_eq!(seen, ordered);
}
for size in [0, -1, 101] {
assert!(
!execute(
&binary,
"demo.account.scan",
&serde_json::json!({"after":null,"size":size}).to_string()
)
.status
.success()
);
}
for needle in [
"",
"a",
"A",
"ab",
"\0",
"a\0",
"b\0",
"雪",
"%",
"_",
"' OR 1=1 --",
"missing",
] {
let actual = run("probe", serde_json::json!({"needle":needle}));
assert_eq!(
actual,
serde_json::json!({
"contained":names.iter().filter(|name|name.contains(needle)).count(),
"prefixed":names.iter().filter(|name|name.starts_with(needle)).count(),
"suffixed":names.iter().filter(|name|name.ends_with(needle)).count(),
"notes":0
}),
"needle {needle:?}"
);
}
assert_eq!(
run("select", serde_json::json!({"first":"ab","second":"Ab"})),
2
);
assert_eq!(
run("select", serde_json::json!({"first":"ab","second":"ab"})),
1
);
for (lower, upper) in [("Ab", "ab"), ("ab", "ab"), ("ab", "Ab"), ("", "雪")] {
let expected = names
.iter()
.filter(|name| **name >= lower && **name <= upper && **name != "excluded")
.count();
assert_eq!(
run("range", serde_json::json!({"lower":lower,"upper":upper})),
expected
);
}
assert_eq!(
run(
"bulk",
serde_json::json!({"first":"bulk one","second":"bulk two"})
),
2
);
let bulk = run("recent", serde_json::json!({}));
assert_eq!(bulk["items"][0]["rank"], 9);
assert_eq!(bulk["items"][1]["rank"], 7);
let mut unique = std::collections::HashSet::new();
for row in bulk["items"].as_array().unwrap() {
let uid = row["uid"].as_str().unwrap();
assert_eq!(&uid[14..15], "7");
assert!(unique.insert(uid));
}
let before = run("count", serde_json::json!({}));
for (command, input) in [
(
"bulk",
serde_json::json!({"first":"must rollback","second":"ab"}),
),
(
"failed_bulk",
serde_json::json!({"first":"rollback one","second":"rollback two","value":i64::MAX}),
),
] {
let output = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(!output.status.success(), "{command}");
assert_eq!(run("count", serde_json::json!({})), before);
}
let created = run(
"save",
serde_json::json!({"name":"upsert","initial":"first","replacement":"unused"}),
);
assert_eq!(created["note"], "first");
let updated = run(
"save",
serde_json::json!({"name":"upsert","initial":"ignored","replacement":"changed"}),
);
assert_eq!(updated["id"], created["id"]);
assert_eq!(updated["created_at"], created["created_at"]);
assert_eq!(updated["uid"], created["uid"]);
assert_eq!(updated["note"], "changed");
assert_eq!(run("retain", serde_json::json!({"name":"upsert"})), updated);
}
@@ -0,0 +1,100 @@
use super::*;
fn acceptance(connection: serde_json::Value, name: &str) {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database-read")).unwrap();
let program = project.check().unwrap();
let binary = build::build(&program, &project.sources, "main", &directory.0.join(name)).unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":connection}}).to_string(),
)
.unwrap();
for (command, input, expected) in [
(
"lookup",
serde_json::json!({"value":"alpha"}),
serde_json::json!("alpha"),
),
(
"lookup",
serde_json::json!({"value":"雪' OR 1=1 --"}),
serde_json::json!("雪' OR 1=1 --"),
),
(
"lookup",
serde_json::json!({"value":"unknown"}),
serde_json::json!("missing"),
),
(
"names",
serde_json::json!({"limit":2}),
serde_json::json!([{"name":"alpha"},{"name":"beta"}]),
),
("count", serde_json::json!({}), serde_json::json!(3)),
("early", serde_json::json!({}), serde_json::json!(4)),
("closed", serde_json::json!({}), serde_json::json!(3)),
] {
for _ in 0..3 {
let result = execute(
&binary,
&format!("demo.account.{command}"),
&input.to_string(),
);
assert!(
result.status.success(),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
expected
);
}
}
for (command, input) in [("names", "{\"limit\":3}"), ("broken", "{}")] {
let result = execute(&binary, &format!("demo.account.{command}"), input);
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr).contains("database operation failed"),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
}
let result = execute(&binary, "demo.account.probe", "{}");
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
"InvalidData"
);
}
#[test]
fn typed_sql_and_row_stream_run_with_one_sqlite_connection() {
acceptance(
serde_json::json!({"type":"sqlite","path":"read.db","max_connections":1,"max_page_size":2}),
"read-sqlite",
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires isolated database.postgres_test"]
fn typed_sql_and_row_stream_run_with_one_postgres_connection() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_read",
"CREATE SCHEMA dever_native_read",
"DROP SCHEMA dever_native_read CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 1.into();
connection["max_page_size"] = 2.into();
acceptance(connection, "read-postgres");
}
@@ -0,0 +1,75 @@
use super::*;
fn acceptance(connection: serde_json::Value, name: &str) {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("database-relation")).unwrap();
let program = project.check().unwrap();
let binary = build::build(&program, &project.sources, "main", &directory.0.join(name)).unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":connection}}).to_string(),
)
.unwrap();
let run = |command: &str| {
let result = execute(&binary, &format!("demo.people.{command}"), "{}");
assert!(
result.status.success(),
"{command}: {}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
assert_eq!(
run("users"),
serde_json::json!(["Ada:Team:2", "Ben:none:1"])
);
assert_eq!(
run("plain"),
serde_json::json!(["Ada:unloaded:-1", "Ben:unloaded:-1"])
);
assert_eq!(run("first"), "Ada:Team:2");
let cursor = run("cursor");
assert_eq!(cursor["names"], serde_json::json!(["Ada:Team:2"]));
assert_eq!(cursor["has_more"], true);
assert_eq!(cursor["next"], "Ada:Team:2");
let mut restrictive = connection;
restrictive["max_page_size"] = 1.into();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::json!({"database":{"default":restrictive}}).to_string(),
)
.unwrap();
let result = execute(&binary, "demo.people.first", "{}");
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr).contains("database operation failed"),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn related_values_join_and_group_with_one_sqlite_connection() {
acceptance(
serde_json::json!({"type":"sqlite","path":"related.db","max_connections":1,"max_page_size":2}),
"related-sqlite",
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires isolated database.postgres_test"]
fn related_values_join_and_group_with_one_postgres_connection() {
let directory = Directory::new();
let schema = super::postgres::Schema::new(
&directory.0,
"dever_native_relation",
"CREATE SCHEMA dever_native_relation",
"DROP SCHEMA dever_native_relation CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 1.into();
connection["max_page_size"] = 2.into();
acceptance(connection, "related-postgres");
}
@@ -0,0 +1,258 @@
use super::*;
#[test]
fn database_scalar_source_preserves_exact_values_and_nullable_updates() {
use dever_runtime::value::decimal::Decimal;
let directory = Directory::new();
scalar_fixture(&directory.0);
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("scalars"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/scalars.db","max_connections":1,"max_page_size":20}}}"#).unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let base = serde_json::json!({
"amount":"1.0000", "price":"999.99", "uid":"00112233-4455-6677-8899-aabbccddeeff",
"day":"0000-01-01", "time":"23:59:59.999", "elapsed":i64::MIN,
"document":{"items":[true,null,{"name":"雪"}]},
"optional_uid":"ffffffff-ffff-ffff-ffff-ffffffffffff", "optional_day":"9999-12-31",
"optional_time":"00:00:00", "optional_elapsed":i64::MAX, "optional_document":{"value":42}
});
let created = run("create", base.clone());
assert_eq!(created["id"], 1);
assert_eq!(created["amount"], "1");
for field in [
"price",
"uid",
"day",
"time",
"elapsed",
"document",
"optional_uid",
"optional_day",
"optional_time",
"optional_elapsed",
"optional_document",
] {
assert_eq!(created[field], base[field], "{field}");
}
assert_eq!(run("get", serde_json::json!({"id":1})), created);
for field in [
"price",
"optional_uid",
"optional_day",
"optional_time",
"optional_elapsed",
"optional_document",
] {
let mut changed = base.clone();
changed["id"] = 1.into();
changed[field] = serde_json::Value::Null;
assert!(run("update", changed)[field].is_null(), "{field}");
}
for (field, value) in [
("amount", "0.00001"),
("amount", "1000000000000000000000000000000"),
("price", "1000"),
("price", "0.001"),
("day", "2025-02-29"),
("time", "24:00:00"),
("uid", "00112233445566778899aabbccddeeff"),
] {
let mut invalid = base.clone();
invalid[field] = value.into();
invalid["uid"] = if field == "uid" {
value
} else {
"11112233-4455-6677-8899-aabbccddeeff"
}
.into();
let failed = execute(&binary, "demo.account.create", &invalid.to_string());
assert!(!failed.status.success(), "accepted {field}: {value}");
assert_eq!(run("count", serde_json::json!({})), 1);
}
let mut changed = base.clone();
changed["id"] = 1.into();
changed["amount"] = "-999999999999999999999999999999.9999".into();
let minimum = run("update", changed.clone());
assert_eq!(minimum["amount"], changed["amount"]);
for (index, amount) in [
"-10",
"-0.0001",
"0",
"0.0001",
"10",
"999999999999999999999999999999.9999",
]
.iter()
.enumerate()
{
let mut value = base.clone();
value["amount"] = (*amount).into();
value["uid"] = format!("00000000-0000-0000-0000-{:012x}", index).into();
value["price"] = serde_json::Value::Null;
let row = run("create", value);
assert_eq!(
Decimal::parse(row["amount"].as_str().unwrap()).unwrap(),
Decimal::parse(amount).unwrap()
);
assert!(row["price"].is_null());
}
let page = run("search", serde_json::json!({"low":"-10"}));
let actual: Vec<_> = page["items"]
.as_array()
.unwrap()
.iter()
.map(|row| Decimal::parse(row["amount"].as_str().unwrap()).unwrap())
.collect();
assert_eq!(
actual,
[
"-10",
"-0.0001",
"0",
"0.0001",
"10",
"999999999999999999999999999999.9999"
]
.map(|text| Decimal::parse(text).unwrap())
);
assert_eq!(
run("price", serde_json::json!({"price":"999.990"}))["total"],
1
);
assert_eq!(run("price", serde_json::json!({}))["total"], 6);
let reused = run(
"document",
serde_json::json!({"id":1,"document":base["document"]}),
);
assert_eq!(reused["document"], base["document"]);
assert_eq!(reused["optional_document"], base["document"]);
assert_eq!(run("get", serde_json::json!({"id":1})), reused);
let raw =
r#"{"id":1,"document":{"precise":123456789012345678901234567890.0010,"tiny":1e-1000}}"#;
let output = execute(&binary, "demo.account.document", raw);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = String::from_utf8(output.stdout).unwrap();
assert!(output.contains("123456789012345678901234567890.0010"));
assert!(output.contains("1e-1000"));
let output = execute(&binary, "demo.account.get", r#"{"id":1}"#);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("123456789012345678901234567890.0010")
);
}
fn scalar_fixture(root: &Path) {
let fields = [
("amount", "Decimal(34, 4)", "Decimal"),
("price", "Decimal(5, 2)?", "Decimal?"),
("uid", "Uuid", "Uuid"),
("day", "Date", "Date"),
("time", "Time", "Time"),
("elapsed", "Duration", "Duration"),
("document", "Json", "Json"),
("optional_uid", "Uuid?", "Uuid?"),
("optional_day", "Date?", "Date?"),
("optional_time", "Time?", "Time?"),
("optional_elapsed", "Duration?", "Duration?"),
("optional_document", "Json?", "Json?"),
];
let root = root.join("module/demo/account");
std::fs::create_dir_all(&root).unwrap();
let columns = fields
.iter()
.map(|(name, kind, _)| {
format!(
"{name}: {kind}{}\n",
if *name == "uid" { " unique" } else { "" }
)
})
.collect::<String>();
std::fs::write(
root.join("model.dever"),
format!("type Account {{\n{columns}}}\n"),
)
.unwrap();
let input = fields
.iter()
.map(|(name, _, kind)| format!("{name}: {kind}"))
.collect::<Vec<_>>()
.join(", ");
let values = fields
.iter()
.map(|(name, _, _)| format!("{name} = {name}"))
.collect::<Vec<_>>()
.join(", ");
let methods = [
(
"create",
input.clone(),
"record: model.Account",
format!("record = model.create({{ {values} }})"),
),
(
"update",
format!("id: model.id, {input}"),
"record: model.Account",
format!("record = model.update(id, {{ {values} }})"),
),
(
"get",
"id: model.id".into(),
"record: model.Account",
"record = model.get(id)".into(),
),
(
"count",
"".into(),
"total: Int",
"total = model.count()".into(),
),
(
"search",
"low: Decimal".into(),
"page: model.Page",
"page = model.list({ where = amount >= low, order = [amount.asc] })".into(),
),
(
"price",
"price: Decimal?".into(),
"page: model.Page",
"page = model.list({ where = price == price })".into(),
),
(
"document",
"id: model.id, document: Json".into(),
"record: model.Account",
"record = model.update(id, { document = document, optional_document = document })\nrecord.document = document".into(),
),
];
let mut app = String::new();
let mut api = String::new();
for (name, inputs, output, body) in methods {
app.push_str(&format!("{name}({inputs}) ({output}) {{\n{body}\n}}\n"));
api.push_str(&format!("cmd {name} = app.{name}\n"));
}
std::fs::write(root.join("app.dever"), app).unwrap();
std::fs::write(root.join("api.dever"), api).unwrap();
}
@@ -0,0 +1,166 @@
use super::*;
#[test]
fn text_fields_normalize_writes_without_changing_queries_or_raw_content() {
let directory = Directory::new();
let source = directory.0.join("module/demo/account");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join("model.dever"),
r#"type Account {
name: Text(1, 8) lower unique
note: Text? upper
body: Text raw
label: Text default " 默认 "
}
"#,
)
.unwrap();
std::fs::write(source.join("app.dever"), r#"create(name: Text, note: Text?, body: Text) (row: model.Account) {
row = model.create({name = name, note = note, body = body})
}
update(id: model.id, name: Text) (row: model.Account) {
row = model.update(id, {name = name})
}
replace(expected: Text, next: Text) (changed: Int) {
changed = model.update({where = name == expected}, {name = next})
}
lookup(name: Text) (row: model.Account?) {
row = model.first({where = name == name})
}
many() (total: Int) {
total = model.createMany([{name = " B ", note = " ß ", body = " b "}, {name = " C ", note = null, body = " c "}])
}
merge(name: Text, body: Text) (row: model.Account) {
row = model.upsert({name = name}, {note = null, body = body}, {body = body})
}
guard(value: Bool) (ok: Bool) {
dever.api.require(value, dever.api.Error.Forbidden)
ok = contains(["a", "b"], "a") and not contains([], "c")
}
decode(value: Json) (items: List<Text>) {
items = dever.json.decode(value)
}
type Entry {
label: Text
values: List<Int>
}
type Envelope {
entries: List<Entry>
optional: Text?
}
decodeRecord(value: Json) (row: Envelope) {
row = dever.json.decode(value)
}
"#).unwrap();
std::fs::write(source.join("api.dever"), "cmd create = app.create\ncmd update = app.update\ncmd replace = app.replace\ncmd lookup = app.lookup\ncmd many = app.many\ncmd merge = app.merge\ncmd guard = app.guard\ncmd decode = app.decode\ncmd decode_record = app.decodeRecord\n").unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("text"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
r#"{"database":{"default":{"type":"sqlite","path":"text.db"}}}"#,
)
.unwrap();
let run = |name: &str, input: serde_json::Value| {
let output = execute(&binary, &format!("demo.account.{name}"), &input.to_string());
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["data"].clone()
};
let row = run(
"create",
serde_json::json!({"name":"\u{2003}Ä\t", "note":" ß ", "body":" \n code\n "}),
);
assert_eq!(row["name"], "ä");
assert_eq!(row["note"], "SS");
assert_eq!(row["body"], " \n code\n ");
assert_eq!(row["label"], "默认");
assert!(run("lookup", serde_json::json!({"name":" ä "})).is_null());
assert_eq!(run("lookup", serde_json::json!({"name":"ä"})), row);
assert_eq!(
run(
"replace",
serde_json::json!({"expected":" ä ","next":" D "})
),
0
);
assert_eq!(
run("replace", serde_json::json!({"expected":"ä","next":" D "})),
1
);
assert_eq!(
run("update", serde_json::json!({"id":row["id"],"name":" E "}))["name"],
"e"
);
assert_eq!(run("many", serde_json::json!({})), 2);
let merged = run("merge", serde_json::json!({"name":" B ","body":" exact "}));
assert_eq!(merged["name"], "b");
assert_eq!(merged["body"], " exact ");
assert!(run("lookup", serde_json::json!({"name":"c"}))["note"].is_null());
for name in [" ", "ABCDEFGHI", " E "] {
assert!(
!execute(
&binary,
"demo.account.create",
&serde_json::json!({"name":name,"note":null,"body":""}).to_string()
)
.status
.success()
);
}
assert_eq!(run("guard", serde_json::json!({"value":true})), true);
assert_eq!(
run("decode", serde_json::json!({"value":["雪", " x "]})),
serde_json::json!(["雪", " x "])
);
assert!(
!execute(&binary, "demo.account.decode", r#"{"value":[1]}"#)
.status
.success()
);
assert!(
!execute(&binary, "demo.account.guard", r#"{"value":false}"#)
.status
.success()
);
let record = serde_json::json!({"entries":[{"label":" x ","values":[1,2]}],"optional":null});
assert_eq!(
run("decode_record", serde_json::json!({"value":record})),
record
);
for record in [
r#"{"entries":[],"extra":1}"#,
r#"{"entries":[{"label":"x","values":[true]}]}"#,
r#"{"optional":null}"#,
] {
let input = format!("{{\"value\":{record}}}");
assert!(
!execute(&binary, "demo.account.decode_record", &input)
.status
.success(),
"{record}"
);
}
}
#[test]
fn owned_trim_preserves_allocation_and_unicode_boundaries() {
for input in ["", "abc", " ", "\u{2003}雪\t", " a\n b ", "\0 x \0"] {
let value = String::from(input);
let allocation = value.as_ptr();
let trimmed = dever_runtime::value::text::trim_owned(value);
assert_eq!(trimmed, input.trim());
assert_eq!(trimmed.as_ptr(), allocation);
}
}
+424
View File
@@ -0,0 +1,424 @@
use super::*;
#[path = "http/choice.rs"]
mod choice;
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
time::{Duration, Instant},
};
pub(super) struct Server(pub(super) std::process::Child);
impl Drop for Server {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
pub(super) fn request(
port: u16,
method: &str,
path: &str,
host: &str,
headers: &str,
body: &str,
) -> (u16, String) {
let (status, _, body) = request_head(port, method, path, host, headers, body);
(status, body)
}
pub(super) fn request_head(
port: u16,
method: &str,
path: &str,
host: &str,
headers: &str,
body: &str,
) -> (u16, String, String) {
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
write!(socket, "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap();
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
let (head, body) = response.split_once("\r\n\r\n").unwrap();
(
head.split_whitespace().nth(1).unwrap().parse().unwrap(),
head.into(),
body.into(),
)
}
#[test]
fn json_cmd_api_budget_keeps_input_boundaries_and_raw_spelling() {
let directory = Directory::new();
let root = directory.0.join("module/demo/input");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("app.dever"),
r#"type Packet { payload: Json
note: Text?
count: Int
extra: Json? }
accept(value: Packet) (answer: Int) { answer = value.count }
echo(value: Json) (answer: Json) { answer = value }
"#,
)
.unwrap();
let mut api = "".to_owned();
for (method, visibility) in [("cmd", ""), ("post", "public ")] {
for name in ["accept", "echo"] {
api.push_str(&format!("{visibility}{method} {name} = app.{name}\n"));
}
}
std::fs::write(root.join("api.dever"), api).unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary =
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let config = directory.0.join("setting.json");
std::fs::write(&config, format!("{{\"http\":{{\"host\":\"127.0.0.1\",\"port\":{port}}},\"sites\":{{\"public\":{{\"path\":\"\",\"hosts\":[\"fixture.test\"]}}}}}}" )).unwrap();
let raw = r#"{ "values": [1e9999,-0,1.00e+2,"\u96ea"] }"#;
let input = format!("{{\"value\":{raw}}}");
let expected = format!("{{\"code\":0,\"message\":\"ok\",\"data\":{raw}}}");
let command = Command::new(&binary)
.args([
"--config",
config.to_str().unwrap(),
"demo.input.echo",
&input,
])
.output()
.unwrap();
assert!(command.status.success(), "{:?}", command.stderr);
assert_eq!(
String::from_utf8(command.stdout).unwrap(),
format!("{expected}\n")
);
let mut server = Server(
Command::new(&binary)
.args(["--config", config.to_str().unwrap()])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
let call = |name: &str, input: &str| {
request(
port,
"POST",
&format!("/demo/input/{name}"),
"fixture.test",
"Content-Type: application/json\r\n",
input,
)
};
assert_eq!(call("echo", &input), (200, expected));
for input in [
r#"[{"payload":null,"count":0}]"#,
r#"{"value":[null,null,0,null]}"#,
r#"{"value":{"payload":null,"note":null,"note":null,"count":0}}"#,
r#"{"value":{"payload":{"a":0,"\u0061":1},"count":0}}"#,
r#"{"value":{"payload":"\ud800","count":0}}"#,
r#"{"value":{"payload":null,"count":0,"unknown":0}}"#,
r#"{"value":{"payload":null,"count":0,"extra":null,"extra":null}}"#,
r#"{"value":{"payload":null,"count":0}} {}"#,
] {
assert_eq!(call("accept", input).0, 400, "{input}");
let command = Command::new(&binary)
.args([
"--config",
config.to_str().unwrap(),
"demo.input.accept",
input,
])
.output()
.unwrap();
assert!(!command.status.success());
assert!(String::from_utf8_lossy(&command.stderr).contains("invalid command input"));
}
// 总值数边界的正文超过 Linux 单个 argv 的限制,通过 HTTP 验证同一 codec。
let limit = dever_runtime::wire::document::MAX_VALUES;
for (count, extra) in [(limit - 4, ""), (limit - 6, ",\"extra\":[0]")] {
let payload = vec!["0"; count].join(",");
for (note, status) in [("", 200), (",\"note\":null", 400)] {
let input =
format!("{{\"value\":{{\"payload\":[{payload}],\"count\":0{extra}{note}}}}}");
assert_eq!(call("accept", &input).0, status);
}
}
for (depth, status) in [(62, 200), (63, 400)] {
let payload = "[".repeat(depth) + &"]".repeat(depth);
let input = format!("{{\"value\":{{\"payload\":{payload},\"count\":0}}}}");
assert_eq!(call("accept", &input).0, status);
}
}
#[test]
fn automatic_http_runs_dever_app_strict_inputs_sites_faults_cmd_and_shutdown() {
application(true);
application(false);
}
fn application(commands: bool) {
let directory = Directory::new();
let mut project = Project::load(&fixture().with_file_name("http-application")).unwrap();
if !commands {
let source = project
.sources
.iter_mut()
.find(|source| source.path.ends_with("greeting/api.dever"))
.unwrap();
*source = dever::syntax::source::Source::new(
source.id,
&source.path,
source.text.split("cmd ").next().unwrap().into(),
);
}
if commands {
let source = project
.sources
.iter_mut()
.find(|source| source.path.ends_with("greeting/api.dever"))
.unwrap();
*source = dever::syntax::source::Source::new(
source.id,
&source.path,
format!("{}\npublic post greet = app.greet\n", source.text),
);
}
let program = project.check().unwrap();
let binary =
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let config = directory.0.join("setting.json");
std::fs::write(&config, format!("{{\"http\":{{\"host\":\"127.0.0.1\",\"port\":{port}}},\"sites\":{{\"public\":{{\"path\":\"\",\"hosts\":[\"fixture.test\"]}}}}}}" )).unwrap();
let fragment = serde_json::json!({"source": " \n{\"a\": [1e9999,-0]}\t "}).to_string();
let expected_fragment = "{\"code\":0,\"message\":\"ok\",\"data\":{\"value\":{\"a\": [1e9999,-0]},\"label\":\"雪\\\"\"}}";
if commands {
let command = Command::new(&binary)
.args([
"--config",
config.to_str().unwrap(),
"demo.greeting.greet",
"{\"name\":\"CMD\"}",
])
.output()
.unwrap();
assert!(
command.status.success(),
"{}",
String::from_utf8_lossy(&command.stderr)
);
assert!(String::from_utf8_lossy(&command.stdout).contains("Hello, CMD"));
let command = Command::new(&binary)
.args([
"--config",
config.to_str().unwrap(),
"demo.greeting.fragment",
&fragment,
])
.output()
.unwrap();
assert!(
command.status.success(),
"{}",
String::from_utf8_lossy(&command.stderr)
);
assert_eq!(
String::from_utf8(command.stdout).unwrap(),
format!("{expected_fragment}\n")
);
}
let mut server = Server(
Command::new(&binary)
.args(["--config", config.to_str().unwrap()])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(
server.0.try_wait().unwrap().is_none(),
"server exited during startup"
);
assert!(Instant::now() < deadline, "server did not bind");
std::thread::sleep(Duration::from_millis(10));
}
let (status, body) = request(
port,
"POST",
"/demo/greeting/fragment",
"fixture.test",
"Content-Type: application/json\r\n",
&fragment,
);
assert_eq!(status, 200, "{body}");
assert_eq!(body, expected_fragment);
let (status, head, body) =
request_head(port, "PUT", "/demo/greeting/greet", "fixture.test", "", "");
assert_eq!(status, 405);
assert_eq!(
body,
"{\"code\":405,\"message\":\"method not allowed\",\"data\":null}"
);
let allowed = head
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("allow"))
.unwrap()
.1
.split(',')
.map(str::trim)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
allowed,
if commands {
vec!["GET", "POST"]
} else {
vec!["GET"]
}
.into_iter()
.collect()
);
for (path, host) in [
("/demo/greeting/greet", "wrong.test"),
("/demo/greeting/absent", "fixture.test"),
] {
let (status, head, _) = request_head(port, "PUT", path, host, "", "");
assert_eq!(status, 404);
assert!(!head.to_ascii_lowercase().contains("\r\nallow:"));
}
for expected in [400, 401, 403, 404, 409, 429, 500] {
let (status, body) = request(
port,
"GET",
&format!("/demo/greeting/reject?status={expected}"),
"fixture.test",
"",
"",
);
assert_eq!(status, expected, "{body}");
let body: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(body["code"], expected);
assert!(body["data"].is_null());
assert!(!body.to_string().contains("Problem"));
}
let (status, body) = request(
port,
"GET",
"/demo/greeting/greet?name=%E9%9B%AA",
"fixture.test",
"Cookie: stale=ignored\r\n",
"",
);
assert_eq!(status, 200);
assert_eq!(
body,
"{\"code\":0,\"message\":\"ok\",\"data\":\"Hello, 雪\"}"
);
for path in [
"/demo/greeting/greet",
"/demo/greeting/greet?name=a&name=b",
"/demo/greeting/greet?name=a&%6Eame=b",
"/demo/greeting/greet?name=%ZZ",
"/demo/greeting/greet?name=%",
"/demo/greeting/greet?name=%FF",
"/demo/greeting/greet?name",
"/demo/greeting/greet?name=a&extra=b",
] {
assert_eq!(request(port, "GET", path, "fixture.test", "", "").0, 400);
}
assert_eq!(
request(
port,
"GET",
"/demo/greeting/greet?name=a",
"wrong.test",
"",
""
)
.0,
404
);
assert_eq!(
request(
port,
"GET",
"/demo/greeting/greet?name=a",
"fixture.test",
"Authorization: Bearer invalid\r\n",
""
)
.0,
401
);
let json = "Content-Type: application/json\r\n";
for (body, status) in [
("{\"value\":41}", 200),
("{\"value\":1,\"value\":2}", 400),
("{\"value\":1.2}", 400),
("{\"value\":1,\"extra\":2}", 400),
("[1]", 400),
("{\"value\":null}", 400),
("{\"value\":9223372036854775807}", 500),
] {
let (actual, response) = request(
port,
"POST",
"/demo/greeting/pair",
"fixture.test",
json,
body,
);
assert_eq!(actual, status, "{response}");
assert!(!response.contains("arithmetic fault"));
}
assert_eq!(
request(
port,
"POST",
"/demo/greeting/pair",
"fixture.test",
"",
"{\"value\":1}"
)
.0,
415
);
assert!(
Command::new("sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test",
&server.0.id().to_string()
])
.status()
.unwrap()
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = server.0.try_wait().unwrap() {
assert!(status.success());
break;
}
assert!(Instant::now() < deadline, "shutdown did not release server");
std::thread::sleep(Duration::from_millis(10));
}
}
+145
View File
@@ -0,0 +1,145 @@
use super::*;
#[test]
fn enums_and_local_decode_use_shared_cmd_http_and_json_codecs() {
let directory = Directory::new();
let root = directory.0.join("module/demo/input");
std::fs::create_dir_all(root.join("app")).unwrap();
std::fs::write(root.join("app/read.dever"), r#"type State { Ready
Busy }
type Packet { state: State
optional: State?
states: List<State>
payload: Json }
type Decoded { Ready(value: Packet)
error Invalid(cause: dever.json.JsonResult) }
echo(value: State) (output: State) { output = value }
packet(value: Packet) (output: Packet) { output = value }
decode(payload: Json) (output: Packet) {
value: Packet = dever.json.decode(payload)
output = value
}
capture(payload: Json) (valid: Bool) {
value: Decoded = result(dever.json.decode(payload))
valid = accepted(value)
}
accepted(value: Decoded.Ready(packet)) (valid: Bool) recover("无效输入返回 false") { valid = packet.state == State.Ready }
accepted(value: Decoded.Invalid(cause)) (valid: Bool) recover("无效输入返回 false") { valid = false }
encode(value: State) (output: Json) { output = dever.json.encode(value) }
fault(divisor: Int) (valid: Bool) {
value: Decoded = result(dever.json.decode(dever.json.encode(10 // divisor)))
valid = accepted(value)
}
"#).unwrap();
std::fs::write(root.join("api.dever"), "cmd echo = app.echo\ncmd packet = app.packet\ncmd decode = app.decode\ncmd capture = app.capture\ncmd encode = app.encode\ncmd fault = app.fault\npublic get echo = app.echo\npublic post packet = app.packet\npublic post decode = app.decode\npublic post capture = app.capture\n").unwrap();
let project = Project::load(&directory.0).unwrap();
let program = project.check().unwrap();
let binary =
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
let packet = r#"{"state":"Ready","optional":null,"states":["Busy"],"payload":{"x":1}}"#;
for (name, input, data) in [
("echo", r#"{"value":"Ready"}"#.to_owned(), r#""Ready""#),
("packet", format!(r#"{{"value":{packet}}}"#), packet),
("decode", format!(r#"{{"payload":{packet}}}"#), packet),
("capture", format!(r#"{{"payload":{packet}}}"#), "true"),
(
"capture",
r#"{"payload":{"state":"Wrong"}}"#.into(),
"false",
),
("encode", r#"{"value":"Busy"}"#.into(), r#""Busy""#),
] {
let output = execute(&binary, &format!("demo.input.{name}"), &input);
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{data}}}\n")
);
}
for input in [
r#"{"value":"Wrong"}"#,
r#"{"value":0}"#,
r#"{"value":{"Ready":null}}"#,
] {
assert!(!execute(&binary, "demo.input.echo", input).status.success());
}
let fault = execute(&binary, "demo.input.fault", r#"{"divisor":0}"#);
assert!(!fault.status.success());
let error = String::from_utf8_lossy(&fault.stderr);
assert!(error.contains("integer arithmetic fault"), "{error}");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let config = directory.0.join("setting.json");
std::fs::write(&config, format!(r#"{{"http":{{"host":"127.0.0.1","port":{port}}},"sites":{{"public":{{"path":"","hosts":["fixture.test"]}}}}}}"#)).unwrap();
let mut server = Server(
Command::new(&binary)
.args(["--config", config.to_str().unwrap()])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
for (method, path, body, status, data) in [
(
"GET",
"/demo/input/echo?value=Busy",
"".into(),
200,
r#""Busy""#,
),
(
"GET",
"/demo/input/echo?value=Wrong",
"".into(),
400,
"null",
),
(
"POST",
"/demo/input/packet",
format!(r#"{{"value":{packet}}}"#),
200,
packet,
),
(
"POST",
"/demo/input/decode",
format!(r#"{{"payload":{packet}}}"#),
200,
packet,
),
(
"POST",
"/demo/input/capture",
r#"{"payload":null}"#.into(),
200,
"false",
),
] {
let (actual, output) = request(
port,
method,
path,
"fixture.test",
"Content-Type: application/json\r\n",
&body,
);
assert_eq!(actual, status, "{path}: {output}");
if status == 200 {
assert_eq!(
output,
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{data}}}")
);
}
}
}
+105
View File
@@ -0,0 +1,105 @@
use super::http::Server;
use super::*;
use std::time::{Duration, Instant};
#[cfg(feature = "postgres")]
#[path = "job/heartbeat.rs"]
mod heartbeat;
#[test]
fn dever_jobs_persist_execute_and_rollback_with_bounded_retries() {
let directory = Directory::new();
verify_queue(
&directory,
serde_json::json!({"type":"sqlite","path":"data/jobs.db","max_connections":4}),
);
}
#[cfg(feature = "postgres")]
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_dever_jobs_persist_execute_and_rollback_with_bounded_retries() {
let directory = Directory::new();
let schema = super::database::postgres::Schema::new(
&directory.0,
"dever_native_jobs",
"CREATE SCHEMA dever_native_jobs",
"DROP SCHEMA dever_native_jobs CASCADE",
);
let mut connection = schema.connection.clone();
connection["max_connections"] = 4.into();
verify_queue(&directory, connection);
}
// 两种驱动运行同一 Dever 源码,包含 worker 重启、失败回滚和活跃键幂等。
fn verify_queue(directory: &Directory, connection: serde_json::Value) {
let project = Project::load(&fixture().with_file_name("job")).unwrap();
let program = project.check().unwrap();
assert_eq!(program.jobs.len(), 1);
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("jobs"),
)
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(directory.0.join("config/setting.json"), serde_json::to_vec(&serde_json::json!({"database":{"default":connection},"job":{"workers":2,"poll_ms":10,"lease_ms":2000,"retry_base_ms":10,"retry_max_ms":100},"runtime":{"shutdown_ms":1000}})).unwrap()).unwrap();
let run = |name: &str, input: &str| {
let result = execute(&binary, &format!("demo.queue.{name}"), input);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
let first = run("submit", r#"{"name":"first","key":"one"}"#);
assert_eq!(first, run("submit", r#"{"name":"ignored","key":"one"}"#));
run("submit", r#"{"name":"fail","key":"bad"}"#);
assert!(
!execute(
&binary,
"demo.queue.rollback",
r#"{"name":"rolled back","key":"rollback"}"#
)
.status
.success()
);
assert_eq!(run("count", "{}"), 0);
let mut worker = Server(Command::new(&binary).spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(10);
loop {
assert!(worker.0.try_wait().unwrap().is_none(), "worker exited");
if run("count", "{}") == 1 {
break;
}
assert!(
Instant::now() < deadline,
"worker did not execute queued Dever code"
);
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(150));
assert_eq!(run("count", "{}"), 1);
assert!(
Command::new("sh")
.args(["-c", "kill -TERM \"$1\"", "--", &worker.0.id().to_string()])
.status()
.unwrap()
.success()
);
assert!(worker.0.wait().unwrap().success());
let next = run("submit", r#"{"name":"second","key":"one"}"#);
assert_ne!(first, next);
let mut worker = Server(Command::new(&binary).spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(10);
while run("count", "{}") != 2 {
assert!(
worker.0.try_wait().unwrap().is_none(),
"restarted worker exited"
);
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(20));
}
}
@@ -0,0 +1,98 @@
use super::*;
use dever_runtime::{
database::{Database, Options},
task::{Limits, Runtime},
};
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn postgres_native_job_renews_short_lease_during_business_transaction() {
let directory = Directory::new();
let schema = super::super::database::postgres::Schema::new(
&directory.0,
"dever_native_job_heartbeat",
"CREATE SCHEMA dever_native_job_heartbeat",
"DROP SCHEMA dever_native_job_heartbeat CASCADE",
);
let project = Project::load(&fixture().with_file_name("job")).unwrap();
let program = project.check().unwrap();
assert_eq!(program.models[0].table, "demo_queue_model");
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("heartbeat"),
)
.unwrap();
let mut connection = schema.connection.clone();
connection["max_connections"] = 4.into();
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::write(
directory.0.join("config/setting.json"),
serde_json::to_vec(&serde_json::json!({
"database":{"default":connection},
"job":{"workers":2,"poll_ms":10,"lease_ms":200,"retry_base_ms":10,"retry_max_ms":100},
"runtime":{"shutdown_ms":1000}
}))
.unwrap(),
)
.unwrap();
let run = |name: &str, input: &str| {
let result = execute(&binary, &format!("demo.queue.{name}"), input);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"].clone()
};
run("submit", r#"{"name":"slow","key":"heartbeat"}"#);
let runtime = Runtime::new(Limits::default()).unwrap();
let database = runtime.run(async |scope| {
let options: Options = serde_json::from_value(connection).unwrap();
let database = Database::open(&directory.0, options, scope).await.unwrap();
database.executor().await.unwrap().initialize(scope, "CREATE FUNCTION slow_job() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN PERFORM pg_sleep(0.6); RETURN NEW; END'; CREATE TRIGGER slow_job BEFORE INSERT ON demo_queue_model FOR EACH ROW EXECUTE FUNCTION slow_job()").await.unwrap();
database
}).unwrap();
let started = Instant::now();
let mut worker = Server(Command::new(&binary).spawn().unwrap());
while run("count", "{}") != 1 {
assert!(
worker.0.try_wait().unwrap().is_none(),
"worker exited before completion"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"short lease job did not complete"
);
std::thread::sleep(Duration::from_millis(20));
}
assert!(started.elapsed() >= Duration::from_millis(500));
runtime
.run(async |scope| {
let rows = database
.executor()
.await
.unwrap()
.query(
scope,
"SELECT attempt,state FROM _dever_job",
vec![],
1,
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.await
.unwrap();
assert_eq!(rows, vec![(1, "completed".into())]);
})
.unwrap();
assert!(
Command::new("sh")
.args(["-c", "kill -TERM \"$1\"", "--", &worker.0.id().to_string()])
.status()
.unwrap()
.success()
);
assert!(worker.0.wait().unwrap().success());
database.close();
}
+259
View File
@@ -0,0 +1,259 @@
use super::{Directory, fixture};
mod auth;
mod database;
mod http;
mod job;
mod run;
mod tenant;
mod transport;
mod upload;
use dever::compiler::{build, project::Project};
use std::{
path::Path,
process::{Command, Output},
};
fn execute(binary: &Path, name: &str, input: &str) -> Output {
Command::new(binary).args([name, input]).output().unwrap()
}
#[test]
fn application_commands_execute_with_strict_typed_json_and_original_faults() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("application")).unwrap();
let program = project.check().unwrap();
assert_eq!(program.commands.len(), 9);
let binary =
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
for (name, input, data) in [
("greet", r#"{"name":"雪"}"#, r#""[Hello, 雪]""#),
("double", r#"{"value":21}"#, "42"),
("pair", r#"{"value":41}"#, r#"{"first":41,"second":42}"#),
("empty", "{}", "null"),
("positive", r#"{"value":7}"#, "7"),
("divide", r#"{"value":6,"divisor":2}"#, "3.0"),
("maybe", "{}", "null"),
("maybe", r#"{"value":null}"#, "null"),
("maybe", r#"{"value":7}"#, "7"),
(
"maybe",
r#"{"value":-9223372036854775808}"#,
"-9223372036854775808",
),
(
"maybe",
r#"{"value":9223372036854775807}"#,
"9223372036854775807",
),
(
"echo",
r#"{"value":{"id":"a","name":"雪","scores":[1,2]}}"#,
r#"{"id":"a","name":"雪","scores":[1,2],"ratio":null}"#,
),
(
"many",
r#"{"rows":[{"id":"a","name":"雪","scores":[],"ratio":0.5}]}"#,
r#"[{"id":"a","name":"雪","scores":[],"ratio":0.5}]"#,
),
] {
let result = execute(&binary, &format!("demo.greeting.{name}"), input);
assert!(
result.status.success(),
"{name}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
String::from_utf8(result.stdout).unwrap(),
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{data}}}\n")
);
}
let name = "雪".repeat(24000);
let input = format!(r#"{{"rows":[{{"id":"a","name":"{name}","scores":[1,2]}}]}}"#);
let result = execute(&binary, "demo.greeting.many", &input);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(
String::from_utf8(result.stdout).unwrap(),
format!(
"{{\"code\":0,\"message\":\"ok\",\"data\":[{{\"id\":\"a\",\"name\":\"{name}\",\"scores\":[1,2],\"ratio\":null}}]}}\n"
)
);
let input =
format!(r#"{{"rows":[{{"id":"a","name":"{name}","na\u006de":"duplicate","scores":[]}}]}}"#);
let result = execute(&binary, "demo.greeting.many", &input);
assert!(!result.status.success());
assert!(result.stdout.is_empty());
for (name, input) in [
("double", "{}"),
("double", r#"{"value":1,"value":2}"#),
("double", r#"{"value":1,"extra":2}"#),
("double", "[1]"),
("double", r#"{"value":1.5}"#),
("double", r#"{"value":9223372036854775808}"#),
("double", r#"{"value":"1"}"#),
("double", r#"{"value":null}"#),
("maybe", r#"{"value":"7"}"#),
("maybe", r#"{"value":null,"value":7}"#),
("double", r#"{"value":1} {}"#),
("echo", r#"{"value":["a","b",[],null]}"#),
("echo", r#"{"value":{"id":1,"name":"a","scores":[]}}"#),
(
"echo",
r#"{"value":{"id":"a","name":"b","name":"c","scores":[]}}"#,
),
(
"echo",
r#"{"value":{"id":"a","name":"b","scores":[],"extra":0}}"#,
),
("many", r#"{"rows":[["a","b",[],null]]}"#),
(
"many",
r#"{"rows":[{"id":"a","name":"b","scores":[],"na\u006de":"c"}]}"#,
),
(
"many",
r#"{"rows":[{"id":"a","name":"b","scores":[],"ratio":null,"ratio":0.5}]}"#,
),
(
"many",
r#"{"rows":[{"id":"a","name":"b","scores":[],"extra":{}}]}"#,
),
("empty", "[]"),
("divide", r#"{"value":1e400,"divisor":2}"#),
] {
let result = execute(&binary, &format!("demo.greeting.{name}"), input);
assert!(!result.status.success(), "accepted {name}: {input}");
assert!(result.stdout.is_empty());
assert!(
String::from_utf8_lossy(&result.stderr).contains("invalid command input"),
"{name} {input}: {}",
String::from_utf8_lossy(&result.stderr)
);
}
for (name, input, message) in [
(
"double",
r#"{"value":9223372036854775807}"#,
"integer arithmetic fault",
),
("positive", r#"{"value":-1}"#, "Problem.Rejected"),
] {
let result = execute(&binary, &format!("demo.greeting.{name}"), input);
assert!(!result.status.success());
assert!(result.stdout.is_empty());
let error = String::from_utf8(result.stderr).unwrap();
assert!(
error.contains("domain.dever:") && error.contains(message),
"{error}"
);
}
let result = execute(
&binary,
"demo.greeting.divide",
r#"{"value":1,"divisor":0}"#,
);
assert!(!result.status.success());
assert!(result.stdout.is_empty());
assert!(String::from_utf8_lossy(&result.stderr).contains("non-finite Float"));
for arguments in [
vec![],
vec!["demo.greeting.empty"],
vec!["missing", "{}"],
vec!["demo.greeting.empty", "{}", "extra"],
] {
let result = Command::new(&binary).args(arguments).output().unwrap();
assert!(!result.status.success());
assert!(result.stdout.is_empty());
}
let rejected = directory.0.join("bypass");
assert!(
build::build(
&program,
&project.sources,
"demo.utility.decorate",
&rejected
)
.is_err()
);
assert!(!rejected.exists());
}
fn source(root: &Path, relative: &str) {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "layout validation precedes parsing").unwrap();
}
#[test]
fn application_layout_checks_roles_topics_and_project_root() {
for (files, needle) in [
(
vec!["module/shop/account/main.dever"],
"expected component/domain/role",
),
(
vec!["module/shop/account/test.dever"],
"role 'test' is not implemented yet",
),
(
vec![
"module/shop/account/app.dever",
"module/shop/account/app/read.dever",
],
"not both",
),
(
vec!["module/shop/account/domain/rules/read.dever"],
"must be flat",
),
(
vec!["module/shop/account/app/utils.dever"],
"generic application",
),
] {
let directory = Directory::new();
for path in files {
source(&directory.0, path);
}
let error = Project::load(&directory.0)
.err()
.expect("invalid application layout accepted");
assert!(error.contains(needle), "{error}");
}
let directory = Directory::new();
for path in [
"module/shop/account/app/read.dever",
"module/shop/account/app/write.dever",
"module/shop/account/api.dever",
"module/shop/account/job.dever",
"module/shop/account/api/admin/read.dever",
"module/shop/account/model.dever",
"module/shop/account/model/session.dever",
] {
source(&directory.0, path);
}
assert!(Project::load(&directory.0).is_ok());
for relative in [
"module",
"module/shop",
"module/shop/account/app",
"module/shop/account/app/read.dever",
] {
let error = Project::load(&directory.0.join(relative))
.err()
.expect("project root bypass accepted");
assert!(error.contains("project root"), "{error}");
}
let linked = Directory::new();
std::os::unix::fs::symlink(directory.0.join("module"), linked.0.join("module")).unwrap();
assert!(
Project::load(&linked.0)
.err()
.unwrap()
.contains("not a link")
);
}
+61
View File
@@ -0,0 +1,61 @@
use super::*;
#[test]
fn application_run_keeps_database_between_commands_and_removes_only_its_binary() {
let directory = Directory::new();
let target = directory.0.join("module/demo/account");
std::fs::create_dir_all(&target).unwrap();
let source = fixture()
.with_file_name("database")
.join("module/demo/account");
for name in ["api.dever", "model.dever", "app.dever"] {
std::fs::copy(source.join(name), target.join(name)).unwrap();
}
std::fs::create_dir(directory.0.join("config")).unwrap();
std::fs::create_dir(directory.0.join("data")).unwrap();
std::fs::write(directory.0.join("data/keep.txt"), "keep").unwrap();
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":1}}}"#).unwrap();
let run = |name: &str, input: &str| {
Command::new(env!("CARGO_BIN_EXE_dever"))
.arg("run")
.arg(&directory.0)
.args(["--", name, input])
.output()
.unwrap()
};
let created = run("demo.account.create", r#"{"name":"persistent"}"#);
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
assert!(directory.0.join("data/account.db").is_file());
let count = run("demo.account.count", "{}");
assert!(count.status.success());
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&count.stdout).unwrap()["data"],
1
);
let failed = run(
"demo.account.explode",
r#"{"name":"rolled back","value":9223372036854775807}"#,
);
assert!(!failed.status.success());
let count = run("demo.account.count", "{}");
assert!(count.status.success());
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&count.stdout).unwrap()["data"],
1
);
assert_eq!(
std::fs::read_to_string(directory.0.join("data/keep.txt")).unwrap(),
"keep"
);
assert!(std::fs::read_dir(&directory.0).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".dever-run-")
}));
}
+315
View File
@@ -0,0 +1,315 @@
use super::{
auth::{KEY, tenant_token},
http::{Server, request},
*,
};
use serde_json::{Value, json};
use std::{
net::{TcpListener, TcpStream},
time::{Duration, Instant},
};
#[cfg(feature = "postgres")]
mod postgres;
fn command(binary: &Path, tenant: Option<i64>, name: &str, input: Value) -> Output {
let mut command = Command::new(binary);
if let Some(tenant) = tenant {
command.args(["--tenant", &tenant.to_string()]);
}
let output = command.args([name, &input.to_string()]).output().unwrap();
if !output.status.success() {
eprintln!(
"{name} tenant={tenant:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
output
}
fn success(output: Output) -> Value {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice::<Value>(&output.stdout).unwrap()["data"].clone()
}
fn get(port: u16, tenant: i64, action: &str) -> (u16, Value) {
let name = format!("tenant-{tenant}");
let token = tenant_token(&name, "session", Some(&name));
let (status, body) = request(
port,
"GET",
&format!("/news/item/admin/entry/{action}"),
"fixture.test",
&format!("Authorization: Bearer {token}\r\n"),
"",
);
(
status,
serde_json::from_str::<Value>(&body).unwrap()["data"].clone(),
)
}
fn post(port: u16, tenant: i64, action: &str, input: Value) -> (u16, Value) {
let name = format!("tenant-{tenant}");
let token = tenant_token(&name, "session", Some(&name));
let (status, body) = request(
port,
"POST",
&format!("/news/item/admin/entry/{action}"),
"fixture.test",
&format!("Authorization: Bearer {token}\r\nContent-Type: application/json\r\n"),
&input.to_string(),
);
(
status,
serde_json::from_str::<Value>(&body).unwrap()["data"].clone(),
)
}
fn start(binary: &Path, port: u16) -> Server {
let mut server = Server(
Command::new(binary)
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(
server.0.try_wait().unwrap().is_none(),
"server exited before listen"
);
assert!(Instant::now() < deadline, "server did not listen");
std::thread::sleep(Duration::from_millis(10));
}
server
}
#[test]
fn tenants_execute_dever_http_rest_capabilities_and_persistent_jobs() {
let directory = Directory::new();
verify(
&directory,
json!({"type":"sqlite","path":"data/control.db", "tenant_directory":"data/tenant", "max_connections":4}),
);
}
fn verify(directory: &Directory, connection: Value) {
let project = Project::load(&fixture().with_file_name("tenant")).unwrap();
let program = project.check().unwrap();
program.check_tenant_entries().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("tenant"),
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::fs::create_dir(directory.0.join("config")).unwrap();
let mut settings = json!({
"http":{"host":"127.0.0.1","port":port},
"sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"]}},
"auth":{"providers":{"session":{"verify":"platform.account.verify","jwtSecret":KEY,"ttlSeconds":60}}},
"database":{"default":connection},
"tenant":{"database":"default","max_pools":4,"idle_timeout_ms":1000},
"runtime":{"mode":"api"},
"job":{"workers":1,"poll_ms":20,"lease_ms":3000,"retry_base_ms":100,"retry_max_ms":1000}
});
let config = directory.0.join("config/setting.json");
std::fs::write(&config, settings.to_string()).unwrap();
for tenant in [1, 2] {
assert_eq!(
success(command(
&binary,
None,
"platform.account.seed",
json!({"subject":format!("tenant-{tenant}"),"session":"session"})
)),
tenant
);
}
assert!(
!command(&binary, None, "news.item.count", json!({}))
.status
.success()
);
assert!(
!command(&binary, Some(1), "news.item.count", json!({}))
.status
.success()
);
for tenant in [1, 2] {
success(command(
&binary,
Some(tenant),
"dever.tenant.migrate",
Value::Null,
));
success(command(
&binary,
Some(tenant),
"dever.tenant.migrate",
Value::Null,
));
success(command(
&binary,
Some(tenant),
"dever.auth.owner",
json!({"site":"admin","user_id":tenant}),
));
assert_eq!(
success(command(
&binary,
Some(tenant),
"news.item.create",
json!({"title":"same-title"})
)),
1
);
}
let server = start(&binary, port);
for tenant in [1, 2] {
assert_eq!(get(port, tenant, "count"), (200, json!(1)));
}
let wrong = tenant_token("tenant-1", "session", Some("tenant-2"));
assert_eq!(
request(
port,
"GET",
"/news/item/admin/entry/count",
"fixture.test",
&format!("Authorization: Bearer {wrong}\r\n"),
""
)
.0,
401
);
let id = "01900000-0000-7000-8000-000000000001";
let (status, key) = post(port, 1, "register", json!({"id":id}));
assert_eq!(status, 200, "{key}");
assert_eq!(key, format!("capability:news.item.admin.{id}"));
assert!(
get(port, 1, "permissions")
.1
.as_array()
.unwrap()
.iter()
.any(|value| value["key"] == key)
);
assert!(
!get(port, 2, "permissions")
.1
.as_array()
.unwrap()
.iter()
.any(|value| value["key"] == key)
);
assert_eq!(post(port, 1, "remove", json!({"id":id})).0, 200);
assert!(
!get(port, 1, "permissions")
.1
.as_array()
.unwrap()
.iter()
.any(|value| value["key"] == key)
);
let first = tenant_token("tenant-1", "session", Some("tenant-1"));
let (status, body) = request(
port,
"GET",
"/news/item/admin/resource?page=1&size=10",
"fixture.test",
&format!("Authorization: Bearer {first}\r\n"),
"",
);
assert_eq!(status, 200, "{body}");
assert!(body.contains("same-title"));
success(command(
&binary,
Some(1),
"dever.tenant.disable",
json!("news"),
));
assert_eq!(get(port, 1, "count").0, 403);
assert_eq!(get(port, 2, "count").0, 200);
assert!(
!command(&binary, Some(1), "news.item.count", json!({}))
.status
.success()
);
assert_eq!(
request(
port,
"GET",
"/news/item/admin/entry/health",
"fixture.test",
"",
""
)
.0,
200
);
success(command(
&binary,
Some(1),
"dever.tenant.enable",
json!("news"),
));
assert!(
!command(&binary, Some(1), "dever.tenant.disable", json!("platform"))
.status
.success()
);
for tenant in [1, 2] {
let input = json!({"title":"system-work","key":"same-key"});
let first = success(command(
&binary,
Some(tenant),
"news.item.submit",
input.clone(),
));
let second = success(command(&binary, Some(tenant), "news.item.submit", input));
assert_eq!(first, second);
assert_eq!(
post(
port,
tenant,
"submit",
json!({"title":"user-work","key":"user-key"})
)
.0,
200
);
}
settings["runtime"]["mode"] = "worker".into();
std::fs::write(&config, settings.to_string()).unwrap();
let mut worker = Server(
Command::new(&binary)
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
assert!(worker.0.try_wait().unwrap().is_none(), "worker exited");
let counts = [1, 2]
.map(|tenant| success(command(&binary, Some(tenant), "news.item.count", json!({}))));
if counts == [json!(3), json!(3)] {
break;
}
assert!(
Instant::now() < deadline,
"tasks did not finish: {counts:?}"
);
std::thread::sleep(Duration::from_millis(30));
}
drop(worker);
drop(server);
}
@@ -0,0 +1,34 @@
use super::*;
use crate::application::database::postgres::Schema;
#[test]
#[ignore = "requires explicit isolated database.postgres_test with CREATEDB"]
fn physical_tenants_execute_dever_http_rest_capabilities_and_jobs() {
let directory = Directory::new();
let control = Schema::new(
&directory.0,
"public",
"CREATE DATABASE dever_native_tenant_control",
"DROP DATABASE dever_native_tenant_control WITH (FORCE)",
);
// 每个守卫只清理它成功创建的数据库;迁移仍完整验证已有空库。
let _first = Schema::new(
&directory.0,
"public",
"CREATE DATABASE dever_native_tenant_1",
"DROP DATABASE dever_native_tenant_1 WITH (FORCE)",
);
let _second = Schema::new(
&directory.0,
"public",
"CREATE DATABASE dever_native_tenant_2",
"DROP DATABASE dever_native_tenant_2 WITH (FORCE)",
);
let mut connection = control.connection.clone();
let mut url = url::Url::parse(connection["url"].as_str().unwrap()).unwrap();
url.set_path("/dever_native_tenant_control");
url.set_query(None);
connection["url"] = url.to_string().into();
connection["tenant_database_prefix"] = "dever_native_tenant".into();
verify(&directory, connection);
}
+61
View File
@@ -0,0 +1,61 @@
use super::{http::Server, *};
use dever_runtime::{
http,
task::{Limits, Runtime},
tls,
};
use std::{
net::{TcpListener, TcpStream},
time::{Duration, Instant},
};
#[test]
fn automatic_api_accepts_http2_tls_and_validates_before_listening() {
let directory = Directory::new();
let project = Project::load(&fixture().with_file_name("http-application")).unwrap();
let program = project.check().unwrap();
let binary =
build::build(&program, &project.sources, "main", &directory.0.join("api")).unwrap();
let certificate = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../test/dever-tests/fixtures/tls")
.canonicalize()
.unwrap();
std::fs::create_dir(directory.0.join("config")).unwrap();
for http2 in [false, true] {
let socket = TcpListener::bind("127.0.0.1:0").unwrap();
let port = socket.local_addr().unwrap().port();
drop(socket);
let mut settings = serde_json::json!({"http":{"host":"127.0.0.1","port":port,"tls":{"certificate":certificate.join("server.pem"),"key":certificate.join("server-key.pem")},"limits":{"http2":if http2 { serde_json::json!({}) } else { serde_json::Value::Null }}},"sites":{"public":{"path":"","hosts":["localhost"]}}});
let path = directory.0.join("config/setting.json");
std::fs::write(&path, settings.to_string()).unwrap();
let mut server = Server(Command::new(&binary).spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(server.0.try_wait().unwrap().is_none());
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
Runtime::new(Limits::default()).unwrap().run(async |scope| {
let limits = http::Limits { http2:http2.then(Default::default),..Default::default() };
let root = tls::client(&std::fs::read(certificate.join("root.pem")).unwrap()).unwrap();
let client = http::client(&format!("https://localhost:{port}"),&root,&limits,&Default::default(),scope).unwrap();
for _ in 0..2 {
let response = client.request(http::Request { method:"GET".into(),target:"/demo/greeting/greet?name=Dever".into(),headers:vec![],body:dever_runtime::Bytes::new() }).await.unwrap();
assert_eq!(response.status,200);
assert_eq!(serde_json::from_slice::<serde_json::Value>(&response.body).unwrap()["data"],"Hello, Dever");
}
client.close().await.unwrap();
}).unwrap();
drop(server);
settings["http"]["tls"]["key"] = certificate
.join("root.pem")
.to_string_lossy()
.into_owned()
.into();
std::fs::write(&path, settings.to_string()).unwrap();
let failed = Command::new(&binary).output().unwrap();
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("TLS"));
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok());
}
}
+283
View File
@@ -0,0 +1,283 @@
use super::{
http::{Server, request, request_head},
*,
};
use std::{
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
time::{Duration, Instant},
};
fn copy_sources(source: &Path, target: &Path) {
std::fs::create_dir_all(target).unwrap();
for entry in std::fs::read_dir(source).unwrap() {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_dir() {
copy_sources(&entry.path(), &target.join(entry.file_name()));
} else {
std::fs::copy(entry.path(), target.join(entry.file_name())).unwrap();
}
}
}
fn multipart(bytes: usize, fields: bool) -> String {
let mut body = String::new();
if fields {
for (name, value) in [("label", "snow"), ("count", "7"), ("active", "true")] {
body.push_str(&format!(
"--boundary\r\nContent-Disposition: form-data; name={name}\r\n\r\n{value}\r\n"
));
}
}
body.push_str("--boundary\r\nContent-Disposition: form-data; name=file; filename=\"scene;one.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n");
body.push_str(&"x".repeat(bytes));
body.push_str("\r\n--boundary--\r\n");
body
}
fn wait_until(mut condition: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !condition() {
assert!(
Instant::now() < deadline,
"upload condition did not complete"
);
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn automatic_upload_streams_after_auth_and_cleans_failure_timeout_and_disconnect() {
let directory = Directory::new();
let root = directory.0.join("source");
copy_sources(&fixture().with_file_name("auth"), &root);
for (path, text) in [
(
"app/uploads.dever",
include_str!("../../../fixtures/upload/app.dever"),
),
(
"api/admin/uploads.dever",
include_str!("../../../fixtures/upload/api.dever"),
),
(
"port.dever",
include_str!("../../../fixtures/upload/port.dever"),
),
(
"adapter.dever",
include_str!("../../../fixtures/upload/adapter.dever"),
),
] {
std::fs::write(root.join("module/demo/account").join(path), text).unwrap();
}
let mut project = Project::load(&root).unwrap();
// 临时检出仅隔离夹具写入,诊断源码仍指向稳定文件,允许重复验收复用构建。
for source in &mut project.sources {
if let Ok(relative) = source.path.strip_prefix(&root) {
let target = match relative.to_str().unwrap() {
"module/demo/account/app/uploads.dever" => Some("app"),
"module/demo/account/api/admin/uploads.dever" => Some("api"),
"module/demo/account/port.dever" => Some("port"),
"module/demo/account/adapter.dever" => Some("adapter"),
_ => None,
};
let path = target.map_or_else(
|| fixture().with_file_name("auth").join(relative),
|name| {
fixture()
.with_file_name("upload")
.join(format!("{name}.dever"))
},
);
*source = dever::syntax::source::Source::new(source.id, path, source.text.clone());
}
}
let program = project.check().unwrap();
let binary = build::build(
&program,
&project.sources,
"main",
&directory.0.join("upload"),
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::fs::create_dir(directory.0.join("config")).unwrap();
let settings = serde_json::json!({
"http":{"host":"127.0.0.1","port":port,"limits":{"body_bytes":1024,"timeout_ms":1200},"upload":{"total_bytes":131072,"file_bytes":65536,"field_bytes":256}},
"sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"],"origin":"https://fixture.test"}},
"database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":4}},
"runtime":{"mode":"api","shutdown_ms":1000},
"auth":{"providers":{"session":{"verify":"demo.account.verify","jwtSecret":"upload-native-test-key-not-for-deployment","ttlSeconds":60,"cookie":"session"}}}
});
std::fs::write(
directory.0.join("config/setting.json"),
settings.to_string(),
)
.unwrap();
let seeded = execute(
&binary,
"demo.account.seed",
r#"{"subject":"user","session":"valid-session"}"#,
);
assert!(
seeded.status.success(),
"{}",
String::from_utf8_lossy(&seeded.stderr)
);
let mut server = Server(Command::new(&binary).spawn().unwrap());
wait_until(|| {
assert!(server.0.try_wait().unwrap().is_none());
TcpStream::connect(("127.0.0.1", port)).is_ok()
});
let temporary = directory.0.join("data/tmp");
let count = || std::fs::read_dir(&temporary).map_or(0, |entries| entries.count());
let target = "/demo/account/admin/uploads/inspect";
// 未认证请求不必上传正文即可收到拒绝,也不能创建临时文件。
let mut unauthenticated = TcpStream::connect(("127.0.0.1", port)).unwrap();
unauthenticated
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
write!(unauthenticated, "POST {target} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nContent-Type: multipart/form-data; boundary=boundary\r\nContent-Length: 60000\r\n\r\n").unwrap();
let mut response = String::new();
unauthenticated.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 401"), "{response}");
assert_eq!(count(), 0);
let (status, head, _) = request_head(
port,
"POST",
"/demo/account/admin/session/login",
"fixture.test",
"Origin: https://fixture.test\r\nContent-Type: application/json\r\n",
r#"{"subject":"user","session":"valid-session"}"#,
);
assert_eq!(status, 200, "{head}");
let cookie = head
.lines()
.find_map(|line| line.strip_prefix("set-cookie: "))
.unwrap()
.split(';')
.next()
.unwrap();
let headers = format!(
"Cookie: {cookie}\r\nOrigin: https://fixture.test\r\nContent-Type: multipart/form-data; boundary=boundary\r\n"
);
let input = multipart(50_000, true);
let (status, body) = request(port, "POST", target, "fixture.test", &headers, &input);
assert_eq!(status, 200, "{body}");
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
value["data"],
serde_json::json!({"size":50000,"filename":"scene;one.bin","echo":"snow","increment":8,"enabled":true})
);
assert_eq!(count(), 0);
let (status, body) = request(
port,
"POST",
"/demo/account/admin/uploads/store",
"fixture.test",
&headers,
&multipart(50_000, false),
);
assert_eq!(status, 200, "{body}");
let stored: serde_json::Value = serde_json::from_str(&body).unwrap();
let key = stored["data"].as_str().unwrap();
assert_eq!(
std::fs::read(directory.0.join("data/upload").join(key)).unwrap(),
vec![b'x'; 50_000]
);
assert_eq!(count(), 0);
let mut connection = BufReader::new(TcpStream::connect(("127.0.0.1", port)).unwrap());
connection
.get_mut()
.set_read_timeout(Some(Duration::from_secs(4)))
.unwrap();
let json_headers = format!(
"Cookie: {cookie}\r\nOrigin: https://fixture.test\r\nContent-Type: application/json\r\n"
);
for (path, headers, body) in [
(target, headers.as_str(), input.as_str()),
(
"/demo/account/admin/session/me",
json_headers.as_str(),
"{}",
),
(target, headers.as_str(), input.as_str()),
] {
write!(connection.get_mut(), "POST {path} HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap();
let mut status = String::new();
connection.read_line(&mut status).unwrap();
assert!(status.starts_with("HTTP/1.1 200"), "{status}");
let mut length = None;
loop {
let mut line = String::new();
connection.read_line(&mut line).unwrap();
if line == "\r\n" {
break;
}
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
length = Some(value.trim().parse::<usize>().unwrap());
}
}
let mut output = vec![0; length.unwrap()];
connection.read_exact(&mut output).unwrap();
assert_eq!(count(), 0);
}
drop(connection);
for (path, input, expected) in [
(
"/demo/account/admin/uploads/reject",
multipart(32_768, false),
409,
),
(target, multipart(70_000, true), 413),
(target, input.replace("name=count", "name=unknown"), 400),
(
target,
multipart(10, true).replace("\r\n7\r\n", "\r\nwrong\r\n"),
400,
),
(
target,
multipart(10, true).replace("scene;one.bin", "../escape"),
400,
),
] {
let (status, body) = request(port, "POST", path, "fixture.test", &headers, &input);
assert_eq!(status, expected, "{body}");
assert_eq!(count(), 0);
}
// 正在接收文件时分别断开与超时,等待真实文件清理后才确认返回。
for disconnect in [true, false] {
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(4)))
.unwrap();
let mut input = multipart(32_768, true);
input.truncate(input.len() - 16);
write!(socket, "POST {target} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nContent-Length: 60000\r\n{headers}\r\n{input}").unwrap();
wait_until(|| count() == 1);
if !disconnect {
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 504"), "{response}");
}
drop(socket);
wait_until(|| count() == 0);
}
// 同一服务中的旧 JSON/REST 入口仍通过原有有界读取。
assert_eq!(
request(
port,
"GET",
"/demo/account/admin/session/health",
"fixture.test",
"",
""
)
.0,
200
);
}