Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -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"));
|
||||
}
|
||||
@@ -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:"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}}}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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-")
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user