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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn bytes_preserve_utf8_bounds_failures_and_traversal_order() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("bytes.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
(
|
||||
"joinedSeedErrorEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"BufferResult.Failed",
|
||||
"fail(BufferResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"concatArgumentEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"BufferResult.Failed",
|
||||
"fail(BufferResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"concatLeftErrorEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"DecodeResult.Failed",
|
||||
"fail(DecodeResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"invalidEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"DecodeResult.Failed",
|
||||
"fail(DecodeResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"invalidListEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"DecodeResult.Failed",
|
||||
"fail(DecodeResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"overflowEntry",
|
||||
Some((
|
||||
"test/rust/fixtures/bytes.dever",
|
||||
"integer arithmetic fault",
|
||||
"state + item",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"argumentEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"BufferResult.Failed",
|
||||
"fail(BufferResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((source, message, needle)) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|item| item.path.ends_with(source))
|
||||
.unwrap();
|
||||
let offset = source.text.find(needle).unwrap();
|
||||
let offset = if needle.starts_with("fail(") {
|
||||
offset + 5
|
||||
} else {
|
||||
offset
|
||||
};
|
||||
let (line, column) = source.position(offset);
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_prefix_evaluates_the_suffix_once() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("bytes.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"concatOrderEntry",
|
||||
&directory.0.join("concat-order"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"suffix\ntrue\n");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::{
|
||||
compiler::{build, project::Project},
|
||||
syntax::source::Source,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn native_calendar_and_clocks_keep_values_and_error_identity() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("calendar.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "clocks", "invalid"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
if entry == "invalid" {
|
||||
assert!(!result.status.success());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("DateResult.Failed")
|
||||
&& error.contains("lib/dever/time/calendar.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_command_fields_round_trip_and_reject_invalid_wire_values() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("application")).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("greeting/app.dever"))
|
||||
.unwrap();
|
||||
let start = source.text.find("type Profile").unwrap();
|
||||
let end = source.text.find("type Problem").unwrap();
|
||||
let fields = [
|
||||
("instant", "DateTime"),
|
||||
("date", "Date"),
|
||||
("time", "Time"),
|
||||
("elapsed", "Duration"),
|
||||
("optional", "DateTime?"),
|
||||
];
|
||||
let mut record = "type Profile {\n".to_owned();
|
||||
for (name, kind) in fields {
|
||||
record.push_str(&format!(" {name}: {kind}\n"));
|
||||
}
|
||||
record.push_str("}\n\n");
|
||||
let mut text = format!("{}{}{}", &source.text[..start], record, &source.text[end..]);
|
||||
let start = text.find("double(").unwrap();
|
||||
let end = text.find("echo(").unwrap();
|
||||
let replacement = r#"double(value: Duration) (answer: DateTime) { answer = domain.double(value) }
|
||||
"#;
|
||||
text.replace_range(start..end, replacement);
|
||||
*source = Source::new(source.id, source.path.clone(), text);
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("greeting/domain.dever"))
|
||||
.unwrap();
|
||||
let start = source.text.find("double(").unwrap();
|
||||
let end = source.text.find("positive(").unwrap();
|
||||
let replacement = replacement.replace(
|
||||
"answer = domain.double(value)",
|
||||
"answer = dever.time.add(dever.time.parseDatetime(\"1970-01-01T00:00:00Z\"), value)",
|
||||
);
|
||||
let mut text = source.text.clone();
|
||||
text.replace_range(start..end, &replacement);
|
||||
*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("commands"),
|
||||
)
|
||||
.unwrap();
|
||||
let input = r#"{"value":{"instant":"2024-02-29T12:34:56.12+08:00","date":"2024-02-29","time":"12:34:56.000","elapsed":-3}}"#;
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.greeting.echo", input])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
"{\"code\":0,\"message\":\"ok\",\"data\":{\"instant\":\"2024-02-29T04:34:56.120Z\",\"date\":\"2024-02-29\",\"time\":\"12:34:56\",\"elapsed\":-3,\"optional\":null}}\n"
|
||||
);
|
||||
for invalid in [
|
||||
input.replace("12:34:56.000", "12:34:56.1"),
|
||||
input.replace("2024-02-29\",\"time", "2023-02-29\",\"time"),
|
||||
input.replace("\"elapsed\":-3", "\"elapsed\":-3.0"),
|
||||
input.replace(".12+08:00", ".1234+08:00"),
|
||||
] {
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.greeting.echo", &invalid])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success(), "{invalid}");
|
||||
assert!(result.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("invalid command input"));
|
||||
}
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.greeting.double", r#"{"value":9223372036854775807}"#])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("four-digit UTC calendar range"));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn record_buffers_move_only_after_their_last_field_read() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("record-buffers.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, fault) in [
|
||||
("verify", None),
|
||||
("prefixFault", Some("item * state.first")),
|
||||
("firstFieldFault", Some("state.second + 1")),
|
||||
("lastFieldFault", Some("state.first + 1")),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some(needle) = fault {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains("integer arithmetic fault"), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("record-buffers.dever:{line}:{column}:")),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_collections_and_preserves_original_failure_locations() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("collection.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
(
|
||||
"initializeEntry",
|
||||
Some(("Result.Failed", "Result.Failed(value)")),
|
||||
),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Result.Failed", "Result.Failed(value)")),
|
||||
),
|
||||
(
|
||||
"reductionFailureEntry",
|
||||
Some(("Result.Failed", "Result.Failed(value)")),
|
||||
),
|
||||
(
|
||||
"overflowEntry",
|
||||
Some((
|
||||
"integer overflow",
|
||||
"sum(addContext, [9223372036854775807, 1], 0)",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"handlerFaultEntry",
|
||||
Some(("integer arithmetic fault", "value * 2")),
|
||||
),
|
||||
(
|
||||
"extraEntry",
|
||||
Some(("Result.Failed", "Result.Failed(value)")),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("collection.dever:{line}:{column}:")),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn native_crypto_sinks_and_blocking_workers_keep_their_contracts() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("crypto.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "asyncVerify", "phcVerify"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n", "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_failures_keep_source_identity_and_sensitive_entry_results_are_rejected() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("crypto.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["leak", "leakList"] {
|
||||
let output = directory.0.join(entry);
|
||||
let error = build::build(&program, &project.sources, entry, &output).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Secret") && error.contains("rendered"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!output.exists());
|
||||
}
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"invalid",
|
||||
&directory.0.join("invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("TokenResult.Failed") && error.contains("lib/dever/crypto.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::fixture::{Fixture, app, command, write};
|
||||
use dever::compiler::project::Project;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn api_baselines_are_checked_before_build_and_cli_never_overwrites_them() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let app_path = "module/demo/value/app.dever";
|
||||
write(root, app_path, app("input + 1"));
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
let baseline = root.join("dever.api");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&baseline)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let before = std::fs::read(&baseline).unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&baseline)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(before, std::fs::read(&baseline).unwrap());
|
||||
write(root, app_path, app("input + 2"));
|
||||
assert!(Project::load(root).unwrap().check().is_ok());
|
||||
write(root, app_path, app("input + 2").replace("input", "renamed"));
|
||||
let output_path = root.join("must_not_build");
|
||||
for verb in ["check", "run", "build"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_dever"));
|
||||
command.arg(verb).arg(root);
|
||||
if verb == "build" {
|
||||
command.arg("--output").arg(&output_path);
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("public API differs") && error.contains("+ cmd"),
|
||||
"{verb}: {error}"
|
||||
);
|
||||
}
|
||||
assert!(!output_path.exists());
|
||||
let reviewed = root.join("proposed.api");
|
||||
assert!(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&reviewed)
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(before, std::fs::read(&baseline).unwrap());
|
||||
assert_ne!(before, std::fs::read(reviewed).unwrap());
|
||||
std::fs::remove_file(&baseline).unwrap();
|
||||
std::os::unix::fs::symlink("proposed.api", &baseline).unwrap();
|
||||
assert!(
|
||||
Project::load(root)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("project input links")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_dependency_contract_changes_are_included_in_project_baseline() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
let baseline = fixture.load().unwrap().api_snapshot().unwrap();
|
||||
write(&fixture.directory.0, "dever.api", &baseline);
|
||||
assert!(fixture.load().unwrap().check().is_ok());
|
||||
fixture.entries.clear();
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
(
|
||||
"module/numbers/value/app.dever",
|
||||
&app("input").replace("input", "renamed"),
|
||||
),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.load()
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("public API differs")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::fixture::{Fixture, app};
|
||||
|
||||
#[test]
|
||||
fn archives_reject_traversal_foreign_components_and_file_directory_conflicts() {
|
||||
for (path, needle) in [
|
||||
("module/numbers/../escape", "unsafe"),
|
||||
("/module/numbers/escape", "unsafe"),
|
||||
("module/numbers//escape", "unsafe"),
|
||||
("module/numbers/./escape", "unsafe"),
|
||||
("module/numbers/a\\b", "unsafe"),
|
||||
("module/numbers/C:escape", "unsafe"),
|
||||
("module/other/value/app.dever", "unowned"),
|
||||
("config/setting.json", "unowned"),
|
||||
("module/numbers/value", "file/directory conflict"),
|
||||
] {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
(path, "unowned or unsafe"),
|
||||
],
|
||||
);
|
||||
fixture.reject(needle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_reject_duplicate_central_paths_even_when_zip_library_coalesces_them() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
("module/numbers/assets/first", "same"),
|
||||
("module/numbers/assets/other", "same"),
|
||||
],
|
||||
);
|
||||
fixture.mutate_archive("numbers", |bytes| {
|
||||
let mut cursor = 0;
|
||||
while cursor + 5 <= bytes.len() {
|
||||
if &bytes[cursor..cursor + 5] == b"other" {
|
||||
bytes[cursor..cursor + 5].copy_from_slice(b"first");
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
});
|
||||
fixture.reject("duplicate or inconsistent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_reject_symlinks_and_oversized_expansion_before_reading_contents() {
|
||||
for (link, needle) in [(true, "link or unsupported"), (false, "expanded size")] {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[("module/numbers/value/app.dever", &app("input"))],
|
||||
);
|
||||
fixture.mutate_archive("numbers", |bytes| {
|
||||
let central = bytes
|
||||
.windows(4)
|
||||
.position(|header| header == b"PK\x01\x02")
|
||||
.unwrap();
|
||||
if link {
|
||||
bytes[central + 5] = 3;
|
||||
bytes[central + 38..central + 42]
|
||||
.copy_from_slice(&(0o120777_u32 << 16).to_le_bytes());
|
||||
} else {
|
||||
bytes[central + 24..central + 28]
|
||||
.copy_from_slice(&(256_u32 * 1024 * 1024 + 1).to_le_bytes());
|
||||
}
|
||||
});
|
||||
fixture.reject(needle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::super::Directory;
|
||||
use super::fixture::digest;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn cache_client_authenticates_receipts_and_rejects_missing_corrupt_or_truncated_bytes() {
|
||||
for behavior in [
|
||||
"valid",
|
||||
"missing",
|
||||
"receipt",
|
||||
"digest",
|
||||
"truncated",
|
||||
"oversized",
|
||||
] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let token = "a".repeat(64);
|
||||
let token_path = state.join("deverd.token");
|
||||
std::fs::write(&token_path, &token).unwrap();
|
||||
std::fs::set_permissions(token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("cache.sock");
|
||||
let server = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"archive fixture";
|
||||
let sha256 = digest(bytes);
|
||||
let expected = sha256.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = server.accept().unwrap();
|
||||
let mut length = [0; 4];
|
||||
stream.read_exact(&mut length).unwrap();
|
||||
let mut payload = vec![0; u32::from_be_bytes(length) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let request: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(request["token"], token);
|
||||
assert_eq!(request["operation"]["artifact_get"]["sha256"], expected);
|
||||
assert_eq!(request["operation"]["artifact_get"]["bytes"], bytes.len());
|
||||
if behavior == "oversized" {
|
||||
stream
|
||||
.write_all(&(1024_u32 * 1024 + 1).to_be_bytes())
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
let artifact = if behavior == "missing" {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!({"sha256":if behavior == "receipt" { "0".repeat(64) } else { expected }, "bytes":bytes.len()})
|
||||
};
|
||||
let response = serde_json::to_vec(
|
||||
&serde_json::json!({"ok":true, "status":null, "error":null, "artifact":artifact}),
|
||||
)
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
if !matches!(behavior, "missing" | "receipt") {
|
||||
let mut content = bytes.to_vec();
|
||||
if behavior == "digest" {
|
||||
content[0] ^= 1;
|
||||
}
|
||||
if behavior == "truncated" {
|
||||
content.pop();
|
||||
}
|
||||
stream.write_all(&content).unwrap();
|
||||
}
|
||||
});
|
||||
let result = dever::process::cache::read(&state, &endpoint, &sha256, bytes.len() as u64);
|
||||
worker.join().unwrap();
|
||||
if behavior == "valid" {
|
||||
assert_eq!(result.unwrap(), bytes);
|
||||
} else {
|
||||
assert!(result.is_err(), "accepted {behavior}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_client_rejects_untrusted_state_before_connecting() {
|
||||
let directory = Directory::new();
|
||||
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let error = dever::process::cache::read(
|
||||
&directory.0,
|
||||
&directory.0.join("absent.sock"),
|
||||
&"a".repeat(64),
|
||||
1,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not writable"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_upload_requires_both_authenticated_admission_and_final_receipts() {
|
||||
for rejected in ["none", "admission", "publication"] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let token = "b".repeat(64);
|
||||
let path = state.join("deverd.token");
|
||||
std::fs::write(&path, &token).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("cache.sock");
|
||||
let listener = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"content addressed upload";
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut prefix = [0; 4];
|
||||
stream.read_exact(&mut prefix).unwrap();
|
||||
let mut payload = vec![0; u32::from_be_bytes(prefix) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let request: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(request["token"], token);
|
||||
let expected = serde_json::json!({"sha256":digest(bytes), "bytes":bytes.len()});
|
||||
assert_eq!(request["operation"]["artifact_put"], expected);
|
||||
for phase in ["admission", "publication"] {
|
||||
let artifact = if rejected == phase {
|
||||
serde_json::json!({"sha256":"0".repeat(64),"bytes":bytes.len()})
|
||||
} else {
|
||||
expected.clone()
|
||||
};
|
||||
let response = serde_json::to_vec(
|
||||
&serde_json::json!({"ok":true,"status":null,"error":null,"artifact":artifact}),
|
||||
)
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
if phase == "admission" {
|
||||
if rejected == phase {
|
||||
assert_eq!(stream.read(&mut prefix).unwrap(), 0);
|
||||
break;
|
||||
}
|
||||
let mut received = vec![0; bytes.len()];
|
||||
stream.read_exact(&mut received).unwrap();
|
||||
assert_eq!(received, bytes);
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = dever::process::cache::write(&state, &endpoint, bytes);
|
||||
server.join().unwrap();
|
||||
assert_eq!(result.is_ok(), rejected == "none");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_resource_get_uses_the_historical_deverd_envelope_and_exact_receipt() {
|
||||
// 原 service.rs::resource 请求版本/catalog/扩展身份;服务先回复 receipt,再写裸资产。
|
||||
for corrupt in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
let token = "c".repeat(64);
|
||||
let token_path = state.join("deverd.token");
|
||||
std::fs::write(&token_path, &token).unwrap();
|
||||
std::fs::set_permissions(token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("resources.sock");
|
||||
let listener = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"signed runtime fixture";
|
||||
let catalog = "d".repeat(64);
|
||||
let extension = serde_json::json!({"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64"});
|
||||
let expected = serde_json::json!({"token":token,"operation":{"resource_get":{"version":"0.0.1","catalog":catalog,"extension":extension,"artifact":{"sha256":digest(bytes),"bytes":bytes.len()}}},"installed":[]});
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut prefix = [0; 4];
|
||||
stream.read_exact(&mut prefix).unwrap();
|
||||
let mut request = vec![0; u32::from_be_bytes(prefix) as usize];
|
||||
stream.read_exact(&mut request).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&request).unwrap(),
|
||||
expected
|
||||
);
|
||||
let response = serde_json::to_vec(&serde_json::json!({"ok":true,"status":null,"error":null,"artifact":{"sha256":digest(bytes),"bytes":bytes.len()}})).unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
let mut content = bytes.to_vec();
|
||||
if corrupt {
|
||||
content[0] ^= 1;
|
||||
}
|
||||
stream.write_all(&content).unwrap();
|
||||
});
|
||||
let result = dever::process::cache::resource(
|
||||
&state,
|
||||
&endpoint,
|
||||
"0.0.1",
|
||||
&catalog,
|
||||
Some(&extension),
|
||||
&digest(bytes),
|
||||
bytes.len() as u64,
|
||||
);
|
||||
worker.join().unwrap();
|
||||
if corrupt {
|
||||
assert!(result.unwrap_err().contains("SHA-256"));
|
||||
} else {
|
||||
assert_eq!(result.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 使用自有 registry 和缓存验证完整命令;不访问生产 daemon 或真实 registry。
|
||||
|
||||
use super::fixture::digest;
|
||||
use super::{Fixture, app, command, write};
|
||||
use dever::compiler::project::{
|
||||
Project,
|
||||
dependency::{self, Registry, Store},
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Services {
|
||||
indexes: BTreeMap<String, Vec<serde_json::Value>>,
|
||||
downloads: BTreeMap<String, Vec<u8>>,
|
||||
cache: RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
requests: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
pub(super) fn release(&mut self, name: &str, version: &str, dependencies: &[(&str, &str)]) {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"format":"dever-package-v1", "name":name, "version":version,
|
||||
"dependencies":dependencies.iter().copied().collect::<BTreeMap<_, _>>(), "lib":[]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
("dever-package.json".into(), manifest),
|
||||
(
|
||||
format!("module/{name}/value/app.dever"),
|
||||
app("input + 1").into_bytes(),
|
||||
),
|
||||
(
|
||||
format!("module/{name}/assets/home.html"),
|
||||
b"<h1>binary \0 resource</h1>".to_vec(),
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(&bytes).unwrap();
|
||||
}
|
||||
let bytes = zip.finish().unwrap().into_inner();
|
||||
self.indexes.entry(name.into()).or_default().push(
|
||||
serde_json::json!({ "version":version,"sha256":digest(&bytes),"bytes":bytes.len() }),
|
||||
);
|
||||
self.downloads
|
||||
.insert(format!("/v1/packages/{name}/{version}.zip"), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry for Services {
|
||||
fn get(&self, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
self.requests.borrow_mut().push(path.into());
|
||||
let bytes = if let Some(name) = path
|
||||
.strip_prefix("/v1/packages/")
|
||||
.and_then(|path| path.strip_suffix("/index.json"))
|
||||
{
|
||||
serde_json::to_vec(&serde_json::json!({"format":"dever-package-index-v1","name":name,"versions":self.indexes.get(name).ok_or("missing test index")?})).unwrap()
|
||||
} else {
|
||||
self.downloads
|
||||
.get(path)
|
||||
.ok_or("missing test archive")?
|
||||
.clone()
|
||||
};
|
||||
assert!(bytes.len() <= limit);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Services {
|
||||
fn get(&self, sha256: &str, _bytes: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
Ok(self.cache.borrow().get(sha256).cloned())
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.cache
|
||||
.borrow_mut()
|
||||
.insert(digest(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn versions(root: &std::path::Path) -> Vec<(String, String)> {
|
||||
let lock: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("dever.lock")).unwrap()).unwrap();
|
||||
lock["packages"].as_array().map_or(vec![], |packages| {
|
||||
packages
|
||||
.iter()
|
||||
.map(|package| {
|
||||
(
|
||||
package["name"].as_str().unwrap().into(),
|
||||
package["version"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_backtracks_then_update_and_remove_keep_a_valid_offline_closure() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[("common_dep", "^1")]);
|
||||
services.release("alpha", "2.0.0", &[("common_dep", "^2")]);
|
||||
services.release("beta", "1.0.0", &[("common_dep", "^1")]);
|
||||
services.release("common_dep", "1.0.0", &[]);
|
||||
services.release("common_dep", "2.0.0", &[]);
|
||||
dependency::execute_with(
|
||||
"add",
|
||||
root,
|
||||
&["alpha@*".into(), "beta@^1".into()],
|
||||
&services,
|
||||
&services,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
versions(root),
|
||||
vec![
|
||||
("alpha".into(), "1.0.0".into()),
|
||||
("beta".into(), "1.0.0".into()),
|
||||
("common_dep".into(), "1.0.0".into())
|
||||
]
|
||||
);
|
||||
services.requests.borrow_mut().clear();
|
||||
dependency::execute_with("remove", root, &["beta".into()], &services, &services).unwrap();
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
assert_eq!(versions(root)[0].1, "1.0.0");
|
||||
dependency::execute_with("update", root, &[], &services, &services).unwrap();
|
||||
assert_eq!(versions(root)[0].1, "2.0.0");
|
||||
dependency::execute_with("doctor", root, &[], &services, &services).unwrap();
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("alpha.value.value(common_dep.value.value(input))"),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
Project::load_with_artifacts(root, |sha256, bytes| {
|
||||
Store::get(&services, sha256, bytes).map(Option::unwrap)
|
||||
})
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_uses_exact_archives_without_resolving_or_rewriting_and_export_preserves_bytes() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
services.cache.borrow_mut().clear();
|
||||
services.requests.borrow_mut().clear();
|
||||
services.indexes.clear();
|
||||
dependency::install_with(root, &services, &services).unwrap();
|
||||
assert_eq!(
|
||||
&*services.requests.borrow(),
|
||||
&["/v1/packages/alpha/1.0.0.zip"]
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
services.requests.borrow_mut().clear();
|
||||
let output = root.join("export");
|
||||
let report = dependency::export_with(root, "alpha", "assets", &output, &services).unwrap();
|
||||
let report: serde_json::Value = serde_json::from_str(&report).unwrap();
|
||||
let bytes = std::fs::read(output.join("home.html")).unwrap();
|
||||
assert_eq!(bytes, b"<h1>binary \0 resource</h1>");
|
||||
assert_eq!(report["files"][0]["sha256"], digest(&bytes));
|
||||
assert!(
|
||||
dependency::export_with(root, "alpha", "assets", &output, &services)
|
||||
.unwrap_err()
|
||||
.contains("already exists")
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&output).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(output.join("home.html"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_integrity_and_collision_errors_preserve_both_project_inputs() {
|
||||
for failure in ["conflict", "corrupt", "cycle", "collision"] {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
services.release(
|
||||
"beta",
|
||||
"1.0.0",
|
||||
if failure == "cycle" {
|
||||
&[("beta", "^1")]
|
||||
} else if failure == "conflict" {
|
||||
&[("alpha", "^2")]
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
);
|
||||
if failure == "corrupt" {
|
||||
services
|
||||
.downloads
|
||||
.get_mut("/v1/packages/beta/1.0.0.zip")
|
||||
.unwrap()[0] ^= 1;
|
||||
}
|
||||
if failure == "collision" {
|
||||
write(root, "module/beta/private.txt", "user content");
|
||||
}
|
||||
assert!(
|
||||
dependency::execute_with("add", root, &["beta@^1".into()], &services, &services)
|
||||
.is_err(),
|
||||
"accepted {failure}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_cache_is_an_error_not_a_download_and_export_rejects_escaping_paths() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
for relative in [
|
||||
"../assets",
|
||||
"/assets",
|
||||
"assets//icons",
|
||||
"assets/.",
|
||||
"assets\\x",
|
||||
] {
|
||||
assert!(
|
||||
dependency::export_with(root, "alpha", relative, &root.join("out"), &services).is_err()
|
||||
);
|
||||
}
|
||||
services.requests.borrow_mut().clear();
|
||||
services.cache.borrow_mut().values_mut().next().unwrap()[0] ^= 1;
|
||||
assert!(
|
||||
dependency::install_with(root, &services, &services)
|
||||
.unwrap_err()
|
||||
.contains("SHA-256")
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_transport_rejects_redirect_and_oversize_without_following() {
|
||||
use std::io::Read;
|
||||
for (reply, limit, success) in [
|
||||
(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
|
||||
2,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\n123",
|
||||
2,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
8,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut peer, _) = listener.accept().unwrap();
|
||||
peer.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut received = Vec::new();
|
||||
let mut byte = [0];
|
||||
while !received.ends_with(b"\r\n\r\n") {
|
||||
peer.read_exact(&mut byte).unwrap();
|
||||
received.push(byte[0]);
|
||||
assert!(received.len() < 4096);
|
||||
}
|
||||
assert!(received.starts_with(b"GET /v1/packages/alpha/index.json HTTP/1.1\r\n"));
|
||||
peer.write_all(reply.as_bytes()).unwrap();
|
||||
});
|
||||
let registry = dependency::HttpRegistry::loopback(&origin).unwrap();
|
||||
assert_eq!(
|
||||
registry.get("/v1/packages/alpha/index.json", limit).is_ok(),
|
||||
success
|
||||
);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
assert!(dependency::HttpRegistry::new("http://127.0.0.1:1").is_err());
|
||||
assert!(dependency::HttpRegistry::new("https://example.test/root").is_err());
|
||||
assert!(dependency::HttpRegistry::new("https://secret@example.test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_guard_rejects_busy_and_symlink_locks_without_changing_settings() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let before = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
let path = root.join(".dever-project.lock");
|
||||
let lock = std::fs::File::create(&path).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
lock.lock().unwrap();
|
||||
let services = Services::default();
|
||||
let error = dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("busy"), "{error}");
|
||||
lock.unlock().unwrap();
|
||||
drop(lock);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("config/setting.json", &path).unwrap();
|
||||
assert!(
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_reconciles_changed_requests_and_preserves_unrelated_settings() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
services.release("alpha", "2.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
write(root, "config/setting.json", br#"{"package":{"registry":"https://packages.example.test","use":["alpha@^2"]},"business":{"secret":"unchanged"}}"#);
|
||||
dependency::execute_with("update", root, &[], &services, &services).unwrap();
|
||||
assert_eq!(versions(root)[0].1, "2.0.0");
|
||||
let setting: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
assert_eq!(setting["business"]["secret"], "unchanged");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::{Fixture, app, command};
|
||||
|
||||
#[test]
|
||||
fn locked_documentation_uses_verified_assets_and_retains_source_links() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input + 1")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
(
|
||||
"module/numbers/value/doc/value.md",
|
||||
"# 值\n\n## numbers.value.value\n\n返回输入加一。\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut reads = 0;
|
||||
let project = dever::compiler::project::Project::load_with_artifacts(
|
||||
&fixture.directory.0,
|
||||
|sha256, _| {
|
||||
reads += 1;
|
||||
fixture
|
||||
.assets
|
||||
.get(sha256)
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing test asset".into())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reads, 1);
|
||||
fixture.assets.clear();
|
||||
let mut index = project.inspect().unwrap();
|
||||
assert_eq!(reads, 1);
|
||||
assert!(
|
||||
index.documentation_errors.is_empty(),
|
||||
"{:?}",
|
||||
index.documentation_errors
|
||||
);
|
||||
index.select("numbers.value.value", 1).unwrap();
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|value| value.symbol == "numbers.value.value")
|
||||
.unwrap();
|
||||
assert_eq!(description.markdown, "返回输入加一。");
|
||||
assert_eq!(index.documentation_files.len(), 1);
|
||||
let file = &index.documentation_files[description.file];
|
||||
assert!(
|
||||
file.path
|
||||
.ends_with("package/module/numbers/value/doc/value.md")
|
||||
);
|
||||
assert!(
|
||||
file.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("## numbers.value.value")
|
||||
);
|
||||
let symbol = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|value| value.name == "numbers.value.value")
|
||||
.unwrap();
|
||||
let source = &index.files[symbol.declarations[0].file];
|
||||
assert!(source.text.as_ref().unwrap().contains("input + 1"));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use super::super::Directory;
|
||||
use dever::compiler::project::Project;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub(super) struct Entry {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
pub bytes: u64,
|
||||
pub dependencies: Vec<Dependency>,
|
||||
pub manifest_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub(super) struct Dependency {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub(super) struct Fixture {
|
||||
pub directory: Directory,
|
||||
pub entries: Vec<Entry>,
|
||||
pub assets: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new(roots: &[&str]) -> Self {
|
||||
let directory = Directory::new();
|
||||
write(
|
||||
&directory.0,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"package":{"registry":"https://packages.example.test","use":roots}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
Self {
|
||||
directory,
|
||||
entries: Vec::new(),
|
||||
assets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package(&mut self, name: &str, dependencies: &[(&str, &str)], files: &[(&str, &str)]) {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"format":"dever-package-v1", "name":name, "version":"1.0.0",
|
||||
"dependencies":dependencies.iter().copied().collect::<BTreeMap<_, _>>(), "lib":[]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file("dever-package.json", options).unwrap();
|
||||
zip.write_all(&manifest).unwrap();
|
||||
for &(path, text) in files {
|
||||
zip.start_file(path, options).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = zip.finish().unwrap().into_inner();
|
||||
let entry = Entry {
|
||||
name: name.into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: digest(&bytes),
|
||||
bytes: bytes.len() as u64,
|
||||
dependencies: dependencies
|
||||
.iter()
|
||||
.map(|(name, _)| Dependency {
|
||||
name: (*name).into(),
|
||||
version: "1.0.0".into(),
|
||||
})
|
||||
.collect(),
|
||||
manifest_sha256: digest(&manifest),
|
||||
};
|
||||
self.assets.insert(entry.sha256.clone(), bytes);
|
||||
self.entries.push(entry);
|
||||
self.lock();
|
||||
}
|
||||
|
||||
pub fn lock(&mut self) {
|
||||
#[derive(Serialize)]
|
||||
struct Lock<'entries> {
|
||||
format: &'static str,
|
||||
toolchain: &'static str,
|
||||
libs: Vec<()>,
|
||||
workers: Vec<()>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
packages: &'entries Vec<Entry>,
|
||||
}
|
||||
self.entries
|
||||
.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
for entry in &mut self.entries {
|
||||
entry
|
||||
.dependencies
|
||||
.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
}
|
||||
let lock = Lock {
|
||||
format: "dever-lock-v6",
|
||||
toolchain: env!("CARGO_PKG_VERSION"),
|
||||
libs: vec![],
|
||||
workers: vec![],
|
||||
packages: &self.entries,
|
||||
};
|
||||
write(
|
||||
&self.directory.0,
|
||||
"dever.lock",
|
||||
serde_json::to_string_pretty(&lock).unwrap() + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
pub fn mutate_archive(&mut self, name: &str, change: impl FnOnce(&mut Vec<u8>)) {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.name == name)
|
||||
.unwrap();
|
||||
let mut bytes = self.assets.remove(&entry.sha256).unwrap();
|
||||
change(&mut bytes);
|
||||
entry.sha256 = digest(&bytes);
|
||||
entry.bytes = bytes.len() as u64;
|
||||
self.assets.insert(entry.sha256.clone(), bytes);
|
||||
self.lock();
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Project, String> {
|
||||
Project::load_with_artifacts(&self.directory.0, |sha256, _| {
|
||||
self.assets
|
||||
.get(sha256)
|
||||
.cloned()
|
||||
.ok_or_else(|| "locked asset is missing".into())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject(&self, needle: &str) {
|
||||
let error = self.load().err().expect("invalid dependencies accepted");
|
||||
assert!(error.contains(needle), "expected {needle}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn write(root: &Path, relative: &str, contents: impl AsRef<[u8]>) {
|
||||
let path = root.join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn app(expression: &str) -> String {
|
||||
format!("value(input: Int) (answer: Int) {{ answer = {expression} }}\n")
|
||||
}
|
||||
|
||||
pub(super) fn command() -> String {
|
||||
"cmd value = app.value\n".to_owned()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! 真实 CLI 参数分派及 Package/Lib 双向锁合并,不安装全局运行时。
|
||||
|
||||
use super::super::commands::Services;
|
||||
use super::python_fixture::Python;
|
||||
use dever::compiler::project::dependency::{self, Lock};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn package_changes_preserve_lib_closure_and_lib_changes_preserve_packages() {
|
||||
let mut python = Python::new();
|
||||
python.transport.release("alpha", "1.0.0", "");
|
||||
python.transport.release("beta", "1.0.0", "");
|
||||
python.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
let before = python.lock();
|
||||
let mut packages = Services::default();
|
||||
packages.release("numbers", "1.0.0", &[]);
|
||||
packages.release("numbers", "2.0.0", &[]);
|
||||
let root = &python.fixture.directory.0;
|
||||
for (action, specs, version) in [
|
||||
("add", vec!["numbers@^1".into()], "1.0.0"),
|
||||
("update", vec!["numbers@^2".into()], "2.0.0"),
|
||||
] {
|
||||
dependency::execute_with(action, root, &specs, &packages, &python.store).unwrap();
|
||||
let lock = python.lock();
|
||||
assert!(lock.libs == before.libs);
|
||||
assert_eq!(lock.packages[0].version, version);
|
||||
}
|
||||
let packages_before = serde_json::to_value(python.lock().packages).unwrap();
|
||||
python.command("add", &["pip:beta@1.0.0"]).unwrap();
|
||||
python.command("remove", &["pip:alpha@1.0.0"]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(python.lock().packages).unwrap(),
|
||||
packages_before
|
||||
);
|
||||
dependency::execute_with(
|
||||
"remove",
|
||||
root,
|
||||
&["numbers".into()],
|
||||
&packages,
|
||||
&python.store,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(python.lock().packages.is_empty());
|
||||
assert_eq!(python.lock().libs[0].spec.name, "beta");
|
||||
python.command("doctor", &[]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_cli_routes_lib_install_doctor_list_and_rejects_bad_target_arguments() {
|
||||
let python = Python::new();
|
||||
let root = &python.fixture.directory.0;
|
||||
std::fs::write(root.join("dever.lock"), Lock::new(vec![]).encode().unwrap()).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
for action in ["doctor", "list", "install"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.arg("lib")
|
||||
.arg(action)
|
||||
.arg(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{action}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if action != "list" {
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("validated 0 Lib(s)"));
|
||||
}
|
||||
if action != "install" {
|
||||
assert!(!root.join(".dever-project.lock").exists());
|
||||
}
|
||||
}
|
||||
for arguments in [
|
||||
vec!["list", "--target", "linux-x86_64"],
|
||||
vec!["doctor", "--target"],
|
||||
vec!["doctor", "--target", "invalid-target"],
|
||||
vec![
|
||||
"doctor",
|
||||
"--target",
|
||||
"linux-x86_64",
|
||||
"--target",
|
||||
"linux-x86_64",
|
||||
],
|
||||
vec!["install", "pip:unexpected@1.0.0"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.arg("lib")
|
||||
.arg(arguments[0])
|
||||
.arg(root)
|
||||
.args(&arguments[1..])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "accepted {arguments:?}");
|
||||
}
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_registry_rejects_ambiguous_urls_before_transport() {
|
||||
use dependency::library::registry::official;
|
||||
assert!(official("https://proxy.golang.org/example.com/mod/@v/v1.0.0.zip").is_ok());
|
||||
for value in [
|
||||
"https://pypi.org:443/pypi/a/json",
|
||||
"https://@pypi.org/pypi/a/json",
|
||||
"https://pypi.org/x/%2e%2e/pypi/a/json",
|
||||
"https://pypi.org/x%2fpypi/a/json",
|
||||
"https://pypi.org/../pypi/a/json",
|
||||
"https://pypi.org/pypi/a/json?secret=value",
|
||||
"https://pypi.org/pypi/a/json#fragment",
|
||||
"https://pypi.org.evil.test/pypi/a/json",
|
||||
" http://pypi.org/pypi/a/json",
|
||||
"https://pypi.org\\evil.test/pypi/a/json",
|
||||
] {
|
||||
assert!(official(value).is_err(), "accepted ambiguous registry URL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 自有签名、归档和 Lib 输入验证;不连接生产 deverd 或修改机器缓存。
|
||||
|
||||
mod central;
|
||||
mod pep517;
|
||||
mod pep517_fixture;
|
||||
mod python;
|
||||
mod python_fixture;
|
||||
|
||||
use super::{Fixture, fixture::digest, write};
|
||||
use dever::compiler::project::dependency::{
|
||||
library::{Spec, archive},
|
||||
worker::signed::SignedResources,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn lib_identities_normalize_extras_and_reject_ranges_and_path_injection() {
|
||||
assert_eq!(
|
||||
"pip:Some_Name[TEST,docs]@1.2.3"
|
||||
.parse::<Spec>()
|
||||
.unwrap()
|
||||
.key(),
|
||||
"pip:some-name[docs,test]@1.2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
"go:example.org/widget@v1.2.3"
|
||||
.parse::<Spec>()
|
||||
.unwrap()
|
||||
.version,
|
||||
"1.2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
"npm:@scope/name@1.2.3".parse::<Spec>().unwrap().name,
|
||||
"@scope/name"
|
||||
);
|
||||
for value in [
|
||||
"npm:thing@^1",
|
||||
"npm:../thing@1.2.3",
|
||||
"pip:thing[]@1",
|
||||
"pip:thing@>=1",
|
||||
"go:../thing@1.2.3",
|
||||
"npm:@scope/../thing@1.2.3",
|
||||
] {
|
||||
assert!(value.parse::<Spec>().is_err(), "accepted {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_resources_require_independent_signature_and_exact_catalog_bytes() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = fixture.directory.0.join("installation");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[91; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
let bytes = b"signed fixture contents";
|
||||
let path = "runtime/npm/linux-x86_64/runtime.pack";
|
||||
let target = dever::compiler::toolchain::BuildTarget::host().unwrap();
|
||||
let catalog = serde_json::to_vec(&serde_json::json!({"format":"dever-release-v2","version":"0.0.1","platform":target.name(),"artifacts":[{"path":"base.txt","bytes":bytes.len(),"sha256":digest(bytes)}],"extensions":[{"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64","artifacts":[{"path":path,"bytes":bytes.len(),"sha256":digest(bytes)}]}]})).unwrap();
|
||||
write(&root, "versions/0.0.1/base.txt", bytes);
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
let signed = SignedResources::load(&root, "0.0.1").unwrap();
|
||||
assert_eq!(signed.read("base.txt").unwrap(), bytes);
|
||||
let actual = signed.read_with(path, |version, catalog_hash, extension, artifact| {
|
||||
assert_eq!(version, "0.0.1");
|
||||
assert_eq!(catalog_hash, digest(&catalog));
|
||||
assert_eq!(extension, &serde_json::json!({"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64"}));
|
||||
assert_eq!(artifact.path, path);
|
||||
Ok(bytes.to_vec())
|
||||
}).unwrap();
|
||||
assert_eq!(actual, bytes);
|
||||
assert!(
|
||||
signed
|
||||
.read_with(path, |_, _, _, _| Ok(b"changed".to_vec()))
|
||||
.is_err()
|
||||
);
|
||||
write(&root, "versions/0.0.1/base.txt", b"different signed file");
|
||||
assert!(signed.read("base.txt").is_err());
|
||||
write(&root, "versions/0.0.1/manifest.json", b"{}");
|
||||
assert!(SignedResources::load(&root, "0.0.1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_consumption_rejects_links_and_file_directory_collisions() {
|
||||
let make = |entries: &[(&str, &[u8], tar::EntryType)]| {
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes, kind) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(*kind);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, *bytes).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap()
|
||||
};
|
||||
let bytes = make(&[(
|
||||
"package/main.js",
|
||||
b"export const value = 42;",
|
||||
tar::EntryType::Regular,
|
||||
)]);
|
||||
assert_eq!(archive::files(&bytes, false).unwrap().len(), 1);
|
||||
assert!(archive::entries(&bytes, false).unwrap()["package/main.js"].1);
|
||||
let bytes = make(&[("package/main.js", b"", tar::EntryType::Symlink)]);
|
||||
assert!(archive::files(&bytes, false).unwrap_err().contains("links"));
|
||||
let bytes = make(&[
|
||||
("package", b"not a directory", tar::EntryType::Regular),
|
||||
("package/main.js", b"", tar::EntryType::Regular),
|
||||
]);
|
||||
assert!(
|
||||
archive::files(&bytes, false)
|
||||
.unwrap_err()
|
||||
.contains("collision")
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file("../escape", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(b"bad").unwrap();
|
||||
assert!(archive::files(&zip.finish().unwrap().into_inner(), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_zip_rejects_duplicate_central_paths() {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for name in ["package/first", "package/other"] {
|
||||
zip.start_file(
|
||||
name,
|
||||
zip::write::SimpleFileOptions::default().unix_permissions(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(name.as_bytes()).unwrap();
|
||||
}
|
||||
let mut bytes = zip.finish().unwrap().into_inner();
|
||||
let files = archive::entries(&bytes, true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files["package/first"].1);
|
||||
let mut cursor = 0;
|
||||
while cursor + 5 <= bytes.len() {
|
||||
if &bytes[cursor..cursor + 5] == b"other" {
|
||||
bytes[cursor..cursor + 5].copy_from_slice(b"first");
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
assert!(
|
||||
archive::entries(&bytes, true)
|
||||
.unwrap_err()
|
||||
.contains("duplicate or inconsistent")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_wheel_identity_abi_and_dependency_edges_are_checked_before_installation() {
|
||||
let bytes = python_fixture::wheel("tiny", "1.0.0", "");
|
||||
let lib = serde_json::from_value(serde_json::json!({"spec":{"ecosystem":"pip","name":"tiny","version":"1.0.0"},"dependencies":[],"runtime":{"name":"python","version":"3.12.1","sha256":"0".repeat(64)},"artifacts":[],"schema":"pypi-wheel-v1"})).unwrap();
|
||||
let markers = serde_json::json!({"implementation_name":"cpython","implementation_version":"3.12.1","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.12.1","python_version":"3.12","sys_platform":"linux"});
|
||||
let mut runtime = serde_json::from_value::<dever::compiler::project::dependency::library::runtime::Manifest>(serde_json::json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap();
|
||||
let files =
|
||||
dever::compiler::project::dependency::library::python::install(&lib, &bytes, &runtime, &[])
|
||||
.unwrap();
|
||||
assert_eq!(files["tiny/__init__.py"], b"VALUE = 42\n");
|
||||
runtime.python_wheel_tags.clear();
|
||||
assert!(
|
||||
dever::compiler::project::dependency::library::python::install(&lib, &bytes, &runtime, &[])
|
||||
.unwrap_err()
|
||||
.contains("tags")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 源码 hook 真正执行签名 Python/C 工具;安装回放与失败均保持原始项目文件。
|
||||
|
||||
use super::{pep517_fixture, python_fixture::Python};
|
||||
use dever::compiler::project::dependency::library::lock::receipt::Receipt;
|
||||
|
||||
#[test]
|
||||
fn pep517_native_build_and_exact_install_replay_use_locked_static_dynamic_inputs() {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, "stable");
|
||||
fixture.command("add", &["pip:built-native@1.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:built-native@1.0.0", "pip:runtime-helper@1.0.0"]
|
||||
);
|
||||
let receipt = Receipt::parse(&lock.builds[0]).unwrap();
|
||||
assert_eq!(
|
||||
receipt
|
||||
.inputs
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:build-helper@1.0.0", "pip:dynamic-helper@1.0.0"]
|
||||
);
|
||||
assert_eq!(receipt.dynamic_requires, ["dynamic-helper==1.0.0"]);
|
||||
let before = snapshot(&fixture);
|
||||
let original = fixture
|
||||
.store
|
||||
.0
|
||||
.borrow_mut()
|
||||
.remove(&receipt.output.sha256)
|
||||
.unwrap();
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert_eq!(fixture.store.0.borrow()[&receipt.output.sha256], original);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
fixture.command("doctor", &[]).unwrap();
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
// 完整冷缓存恢复只下载固定 source/wheel;不请求任何版本 metadata。
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| url.starts_with("https://files.pythonhosted.org/"))
|
||||
);
|
||||
assert_eq!(fixture.store.0.borrow()[&receipt.output.sha256], original);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pep517_changed_metadata_and_exit10_preserve_previous_project() {
|
||||
for mode in ["mismatch", "exit10"] {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, mode);
|
||||
fixture
|
||||
.command("add", &["pip:runtime-helper@1.0.0"])
|
||||
.unwrap();
|
||||
let before = snapshot(&fixture);
|
||||
let error = fixture
|
||||
.command("add", &["pip:built-native@1.0.0"])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains(if mode == "mismatch" {
|
||||
"prepared metadata"
|
||||
} else {
|
||||
"expected PEP517 exit10"
|
||||
}),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pep517_nonreproducible_replay_preserves_lock_and_does_not_publish_replacement() {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, "unstable");
|
||||
fixture.command("add", &["pip:built-native@1.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
let receipt = Receipt::parse(&lock.builds[0]).unwrap();
|
||||
fixture.store.0.borrow_mut().remove(&receipt.output.sha256);
|
||||
let before = snapshot(&fixture);
|
||||
let cache = fixture.store.0.borrow().clone();
|
||||
assert!(
|
||||
fixture
|
||||
.command("install", &[])
|
||||
.unwrap_err()
|
||||
.contains("not byte reproducible")
|
||||
);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
assert_eq!(*fixture.store.0.borrow(), cache);
|
||||
}
|
||||
|
||||
fn snapshot(fixture: &Python) -> [Vec<u8>; 2] {
|
||||
let root = &fixture.fixture.directory.0;
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Owned test backend: separate hooks, exact build dependencies, real C compilation."""
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
NAME = "built_native"
|
||||
VERSION = "1.0.0"
|
||||
INFO = NAME + "-" + VERSION + ".dist-info"
|
||||
META = "Metadata-Version: 2.1\nName: built-native\nVersion: 1.0.0\nRequires-Dist: runtime-helper>=1\n\n"
|
||||
|
||||
|
||||
def get_requires_for_build_wheel(settings):
|
||||
import build_helper
|
||||
assert build_helper.VALUE == 42
|
||||
assert subprocess.check_output(["build-probe"]).strip() == b"signed-console-42"
|
||||
return ["dynamic-helper==1.0.0"]
|
||||
|
||||
|
||||
def prepare_metadata_for_build_wheel(directory, settings):
|
||||
import dynamic_helper
|
||||
assert dynamic_helper.VALUE == 42
|
||||
assert not Path("hook-was-here").exists()
|
||||
Path("hook-was-here").write_text("metadata hook must run separately")
|
||||
target = Path(directory) / INFO
|
||||
target.mkdir()
|
||||
(target / "METADATA").write_text(META)
|
||||
return INFO
|
||||
|
||||
|
||||
def build_wheel(directory, settings, metadata_directory=None):
|
||||
import dynamic_helper
|
||||
assert dynamic_helper.VALUE == 42
|
||||
assert not Path("hook-was-here").exists()
|
||||
assert metadata_directory and (Path(metadata_directory) / "METADATA").read_text() == META
|
||||
mode = Path("mode.txt").read_text().strip()
|
||||
source = Path("native.c")
|
||||
source.write_text("int answer(void) { return 42; }\n")
|
||||
native = Path("native.so").resolve()
|
||||
compiler = shlex.split(os.environ["CC"])
|
||||
tools = Path(compiler[0]).parent
|
||||
subprocess.run(compiler + ["-B" + str(tools) + "/", "-ffreestanding", "-fno-use-linker-plugin", "-fno-lto", "-shared", "-fPIC", "-nostdlib", str(source), "-o", str(native)], check=True)
|
||||
assert ctypes.CDLL(str(native)).answer() == 42
|
||||
assert subprocess.check_output(["signed-pack-probe", "42"]) == b"signed-tool-42\n"
|
||||
files = {
|
||||
NAME + "/native.so": native.read_bytes(),
|
||||
NAME + "/__init__.py": b"VALUE = 42\n",
|
||||
INFO + "/METADATA": (META + ("Changed: yes\n" if mode == "mismatch" else "")).encode(),
|
||||
INFO + "/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py3-none-linux_x86_64\n",
|
||||
}
|
||||
if mode == "unstable":
|
||||
files[NAME + "/nondeterministic.txt"] = str(time.time_ns()).encode()
|
||||
record = "".join(path + ",sha256=" + base64.urlsafe_b64encode(hashlib.sha256(value).digest()).decode().rstrip("=") + "," + str(len(value)) + "\n" for path, value in sorted(files.items()))
|
||||
files[INFO + "/RECORD"] = (record + INFO + "/RECORD,,\n").encode()
|
||||
filename = NAME + "-" + VERSION + "-py3-none-linux_x86_64.whl"
|
||||
with zipfile.ZipFile(Path(directory) / filename, "w") as archive:
|
||||
for path, value in sorted(files.items()):
|
||||
archive.writestr(zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0)), value)
|
||||
if mode == "exit10":
|
||||
print("expected PEP517 exit10", flush=True)
|
||||
os._exit(10)
|
||||
return filename
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 仅测试读取 dev Pod 工具构造签名 pack;生产路径没有这些工具定位逻辑。
|
||||
|
||||
use super::super::{
|
||||
Fixture,
|
||||
fixture::{digest, write},
|
||||
};
|
||||
use super::python_fixture::Python;
|
||||
use dever::compiler::project::dependency::worker::signed::SignedResources;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, path::Path, process::Command, sync::OnceLock};
|
||||
|
||||
pub(super) fn prepare(fixture: &mut Python, mode: &str) {
|
||||
static ASSETS: OnceLock<BTreeMap<String, Vec<u8>>> = OnceLock::new();
|
||||
fixture.signed = signed(&fixture.fixture, ASSETS.get_or_init(assets));
|
||||
let helper = super::python_fixture::wheel_with_files(
|
||||
"build-helper",
|
||||
"1.0.0",
|
||||
"",
|
||||
BTreeMap::from([
|
||||
(
|
||||
"build_helper/probe.py".into(),
|
||||
b"def main():\n print('signed-console-42')\n".to_vec(),
|
||||
),
|
||||
(
|
||||
"build_helper-1.0.0.dist-info/entry_points.txt".into(),
|
||||
b"[console_scripts]\nbuild-probe = build_helper.probe:main\n".to_vec(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
fixture
|
||||
.transport
|
||||
.release_wheel("build-helper", "1.0.0", helper);
|
||||
fixture.transport.release("dynamic-helper", "1.0.0", "");
|
||||
fixture.transport.release("runtime-helper", "1.0.0", "");
|
||||
let source = super::python_fixture::zip(BTreeMap::from([
|
||||
("built-native-1.0.0/pyproject.toml".into(), b"[build-system]\nrequires = ['build-helper>=1']\nbuild-backend = 'backend'\nbackend-path = ['.']\n".to_vec()),
|
||||
("built-native-1.0.0/backend.py".into(), include_bytes!("pep517_backend.py").to_vec()),
|
||||
("built-native-1.0.0/mode.txt".into(), mode.as_bytes().to_vec()),
|
||||
]));
|
||||
let url = "https://files.pythonhosted.org/packages/built-native-1.0.0.zip";
|
||||
fixture.transport.files.insert(url.into(), source.clone());
|
||||
fixture.transport.files.insert(
|
||||
"https://pypi.org/pypi/built-native/json".into(),
|
||||
serde_json::to_vec(&json!({"releases":{"1.0.0":[]}})).unwrap(),
|
||||
);
|
||||
fixture.transport.files.insert("https://pypi.org/pypi/built-native/1.0.0/json".into(), serde_json::to_vec(&json!({"urls":[{"packagetype":"sdist","filename":"built-native-1.0.0.zip","url":url,"digests":{"sha256":digest(&source)},"size":source.len()}]})).unwrap());
|
||||
}
|
||||
|
||||
fn assets() -> BTreeMap<String, Vec<u8>> {
|
||||
let details = Command::new("/usr/bin/python3").args(["-I", "-S", "-B", "-c", "import sys,sysconfig,json;print(json.dumps([sys.version.split()[0],sysconfig.get_path('stdlib')]))"]).output().unwrap();
|
||||
assert!(details.status.success());
|
||||
let values: Vec<String> = serde_json::from_slice(&details.stdout).unwrap();
|
||||
let version = &values[0];
|
||||
let short = version.rsplit_once('.').unwrap().0;
|
||||
let markers = json!({"implementation_name":"cpython","implementation_version":version,"os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":version,"python_version":short,"sys_platform":"linux"});
|
||||
let tags = ["py3-none-any", "py3-none-linux_x86_64"];
|
||||
let mut runtime = BTreeMap::new();
|
||||
runtime.insert("bin/python3".into(), fs::read("/usr/bin/python3").unwrap());
|
||||
files(
|
||||
Path::new(&values[1]),
|
||||
&format!("lib/python{short}/"),
|
||||
&mut runtime,
|
||||
);
|
||||
runtime.insert("dever-runtime.json".into(), serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":tags,"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap());
|
||||
let mut libraries = BTreeMap::new();
|
||||
elf_libraries(Path::new("/usr/bin/python3"), &mut libraries);
|
||||
for entry in fs::read_dir(Path::new(&values[1]).join("lib-dynload")).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.path().extension().is_some_and(|value| value == "so") {
|
||||
elf_libraries(&entry.path(), &mut libraries);
|
||||
}
|
||||
}
|
||||
let runtime = archive(&runtime);
|
||||
let identity = json!({"name":"cpython","version":version,"sha256":digest(&runtime)});
|
||||
let mut tools = BTreeMap::new();
|
||||
let mut executables = Vec::new();
|
||||
for (name, path) in [
|
||||
("sh", "/bin/sh"),
|
||||
("cc", "/usr/bin/cc"),
|
||||
("c++", "/usr/bin/c++"),
|
||||
("as", "/usr/bin/as"),
|
||||
("ld", "/usr/bin/ld"),
|
||||
("ar", "/usr/bin/ar"),
|
||||
("ranlib", "/usr/bin/ranlib"),
|
||||
("make", "/usr/bin/make"),
|
||||
("cc1", "/usr/libexec/gcc/x86_64-linux-gnu/14/cc1"),
|
||||
("collect2", "/usr/libexec/gcc/x86_64-linux-gnu/14/collect2"),
|
||||
] {
|
||||
let relative = format!("rootfs/usr/bin/{name}");
|
||||
tools.insert(relative.clone(), fs::read(path).unwrap());
|
||||
executables.push(relative);
|
||||
elf_libraries(Path::new(path), &mut libraries);
|
||||
}
|
||||
let script = "rootfs/usr/bin/signed-pack-probe";
|
||||
tools.insert(
|
||||
script.into(),
|
||||
b"#!/bin/sh\nprintf 'signed-tool-%s\\n' \"$1\"\n".to_vec(),
|
||||
);
|
||||
executables.push(script.into());
|
||||
tools.insert("dever-build.json".into(), serde_json::to_vec(&json!({"format":"dever-lib-build-v1","ecosystem":"pip","target":"linux-x86_64","runtime_sha256":identity["sha256"],"python_runtime_sha256":null,"executables":executables})).unwrap());
|
||||
let tools = archive(&tools);
|
||||
let mut assets = BTreeMap::from([
|
||||
("runtime/pip/linux-x86_64/manifest.json".into(), serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":identity,"target":"linux-x86_64","python_markers":markers,"python_wheel_tags":tags}})).unwrap()),
|
||||
("runtime/pip/linux-x86_64/runtime.pack".into(), runtime),
|
||||
("build/pip/linux-x86_64/manifest.json".into(), serde_json::to_vec(&json!({"format":"dever-registry-build-v1","ecosystem":"pip","target":"linux-x86_64","runtime_sha256":identity["sha256"],"python_runtime_sha256":null,"sha256":digest(&tools)})).unwrap()),
|
||||
("build/pip/linux-x86_64/build.pack".into(), tools),
|
||||
]);
|
||||
for (name, bytes) in libraries {
|
||||
assets.insert(format!("process-runtime/linux-x86_64/lib/{name}"), bytes);
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
fn files(root: &Path, prefix: &str, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(root).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
if name == "__pycache__" || name == "site-packages" || name == "test" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
files(&entry.path(), &format!("{prefix}{name}/"), output);
|
||||
} else if entry.file_type().unwrap().is_file() {
|
||||
output.insert(format!("{prefix}{name}"), fs::read(entry.path()).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elf_libraries(program: &Path, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
let result = Command::new("/usr/bin/ldd").arg(program).output().unwrap();
|
||||
assert!(result.status.success(), "fixture ldd {}", program.display());
|
||||
for name in String::from_utf8(result.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.filter(|value| value.starts_with('/'))
|
||||
{
|
||||
let path = Path::new(name);
|
||||
let name = path.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
let bytes = fs::read(path).unwrap();
|
||||
if let Some(previous) = output.insert(name, bytes.clone()) {
|
||||
assert_eq!(previous, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(files: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(0o755);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
fn signed(fixture: &Fixture, assets: &BTreeMap<String, Vec<u8>>) -> SignedResources {
|
||||
let root = fixture.directory.0.join("pep517-signed");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[82; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let catalog = serde_json::to_vec(&json!({"format":"dever-release-v2","version":"0.0.1","platform":"linux-x86_64","artifacts":assets.iter().map(|(path, bytes)| json!({"path":path,"bytes":bytes.len(),"sha256":digest(bytes)})).collect::<Vec<_>>(),"extensions":[]})).unwrap();
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
for (path, bytes) in assets {
|
||||
write(&root, &format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
SignedResources::load(&root, "0.0.1").unwrap()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! 真实 wheel、回环元数据及命令发布回归,不以字符串匹配代替完整闭包与文件校验。
|
||||
|
||||
use super::python_fixture::{Python, wheel, zip};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock,
|
||||
library::{self, python},
|
||||
};
|
||||
use dever::compiler::toolchain::BuildTarget;
|
||||
|
||||
#[test]
|
||||
fn python_resolves_wheel_metadata_extras_markers_and_backtracks() {
|
||||
let mut fixture = Python::new();
|
||||
fixture.transport.release("alpha", "1.0.0", "Requires-Dist: common>=1,<3\nRequires-Dist: helper; extra == 'test'\nProvides-Extra: test\nRequires-Dist: windows-only; sys_platform == 'win32'\n");
|
||||
fixture
|
||||
.transport
|
||||
.release("common", "2.0.0", "Requires-Dist: helper>=2\n");
|
||||
fixture
|
||||
.transport
|
||||
.release("common", "1.0.0", "Requires-Dist: helper<2\n");
|
||||
fixture.transport.release("helper", "1.0.0", "");
|
||||
fixture.transport.release("helper", "2.0.0", "");
|
||||
fixture
|
||||
.command("add", &["pip:alpha[test]@1.0.0", "pip:helper@1.0.0"])
|
||||
.unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"pip:alpha[test]@1.0.0",
|
||||
"pip:common@1.0.0",
|
||||
"pip:helper@1.0.0"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| !url.contains("wrong-registry-summary") && !url.contains("windows-only"))
|
||||
);
|
||||
let metadata = fixture.runtime();
|
||||
let runtime = library::runtime::load(
|
||||
&fixture.signed,
|
||||
"pip",
|
||||
BuildTarget::host().unwrap(),
|
||||
&metadata.pack,
|
||||
)
|
||||
.unwrap();
|
||||
for lib in &lock.libs {
|
||||
let files = python::install(
|
||||
lib,
|
||||
&fixture.store.0.borrow()[&lib.artifacts[0].sha256],
|
||||
&runtime.manifest,
|
||||
&lock.libs,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(files.keys().any(|path| path.ends_with("__init__.py")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_update_preserves_other_roots_and_exact_install_never_resolves() {
|
||||
let mut fixture = Python::new();
|
||||
for (name, version) in [("alpha", "1.0.0"), ("alpha", "2.0.0"), ("beta", "1.0.0")] {
|
||||
fixture.transport.release(name, version, "");
|
||||
}
|
||||
fixture
|
||||
.command("add", &["pip:alpha@1.0.0", "pip:beta@1.0.0"])
|
||||
.unwrap();
|
||||
fixture.command("update", &["pip:alpha@2.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:alpha@2.0.0", "pip:beta@1.0.0"]
|
||||
);
|
||||
let root = &fixture.fixture.directory.0;
|
||||
let before = [
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
];
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert_eq!(fixture.transport.requests.borrow().len(), 2);
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| url.starts_with("https://files.pythonhosted.org/"))
|
||||
);
|
||||
assert_eq!(fixture.store.0.borrow().len(), 2);
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
let cache = fixture.store.0.borrow().clone();
|
||||
for action in ["install", "doctor"] {
|
||||
fixture.store.1.borrow_mut().clear();
|
||||
fixture.command(action, &[]).unwrap();
|
||||
let reads = fixture.store.1.borrow();
|
||||
let read_bytes = reads.iter().map(|(_, bytes)| bytes).sum::<usize>();
|
||||
eprintln!(
|
||||
"cache {action}: reads={} bytes={read_bytes} fixtures={:?}",
|
||||
reads.len(),
|
||||
cache.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(reads.len(), cache.len());
|
||||
assert_eq!(read_bytes, cache.values().map(Vec::len).sum::<usize>());
|
||||
for hash in cache.keys() {
|
||||
assert_eq!(reads.iter().filter(|(key, _)| key == hash).count(), 1);
|
||||
}
|
||||
}
|
||||
assert_eq!(*fixture.store.0.borrow(), cache);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
assert_eq!(
|
||||
before,
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap()
|
||||
]
|
||||
);
|
||||
let hash = cache.keys().next().unwrap();
|
||||
for damaged in [vec![0; cache[hash].len()], vec![]] {
|
||||
fixture.store.0.borrow_mut().insert(hash.clone(), damaged);
|
||||
for action in ["install", "doctor"] {
|
||||
assert!(
|
||||
fixture
|
||||
.command(action, &[])
|
||||
.unwrap_err()
|
||||
.contains("digest or length")
|
||||
);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_resolution_and_tampered_restore_leave_project_and_cache_unchanged() {
|
||||
let mut fixture = Python::new();
|
||||
fixture.transport.release("alpha", "1.0.0", "");
|
||||
fixture
|
||||
.transport
|
||||
.release("broken", "1.0.0", "Requires-Dist: alpha<1\n");
|
||||
fixture.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
let root = &fixture.fixture.directory.0;
|
||||
let before = [
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
];
|
||||
assert!(
|
||||
fixture
|
||||
.command("add", &["pip:broken@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("conflict")
|
||||
);
|
||||
let source = fixture.lock().libs[0].artifacts[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.locator
|
||||
.clone();
|
||||
fixture.transport.files.insert(source, b"tampered".to_vec());
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
assert!(
|
||||
fixture
|
||||
.command("install", &[])
|
||||
.unwrap_err()
|
||||
.contains("digest")
|
||||
);
|
||||
assert!(fixture.store.0.borrow().is_empty());
|
||||
assert_eq!(
|
||||
before,
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_record_rejects_mutation_omission_duplicate_and_weak_hash() {
|
||||
let fixture = Python::new();
|
||||
let bytes = wheel("alpha", "1.0.0", "");
|
||||
let lib = serde_json::from_value(serde_json::json!({"spec":{"ecosystem":"pip","name":"alpha","version":"1.0.0"},"dependencies":[],"runtime":fixture.runtime().pack,"artifacts":[],"schema":"pypi-wheel-v1"})).unwrap();
|
||||
let runtime = library::runtime::load(
|
||||
&fixture.signed,
|
||||
"pip",
|
||||
BuildTarget::host().unwrap(),
|
||||
&fixture.runtime().pack,
|
||||
)
|
||||
.unwrap();
|
||||
let original = library::archive::files(&bytes, true).unwrap();
|
||||
python::install(&lib, &bytes, &runtime.manifest, &[]).unwrap();
|
||||
let record = "alpha-1.0.0.dist-info/RECORD";
|
||||
for mode in 0..4 {
|
||||
let mut files = original.clone();
|
||||
match mode {
|
||||
0 => {
|
||||
files.insert("alpha/__init__.py".into(), b"VALUE = 43\n".to_vec());
|
||||
}
|
||||
1 => {
|
||||
files.insert("alpha/extra.py".into(), b"unlisted".to_vec());
|
||||
}
|
||||
2 => {
|
||||
let duplicate = files[record].clone();
|
||||
files.get_mut(record).unwrap().extend_from_slice(&duplicate);
|
||||
}
|
||||
_ => {
|
||||
let text = String::from_utf8(files[record].clone())
|
||||
.unwrap()
|
||||
.replace("sha256=", "md5=");
|
||||
files.insert(record.into(), text.into_bytes());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
python::install(&lib, &zip(files), &runtime.manifest, &[]).is_err(),
|
||||
"accepted mode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_resolver_failure_does_not_mutate_input_lock() {
|
||||
let mut fixture = Python::new();
|
||||
fixture
|
||||
.transport
|
||||
.release("alpha", "1.0.0", "Requires-Dist: missing>=1\n");
|
||||
let mut lock = Lock::new(vec![]);
|
||||
let before = lock.encode().unwrap();
|
||||
let result = python::resolve(
|
||||
&["pip:alpha@1.0.0".parse().unwrap()],
|
||||
BuildTarget::host().unwrap(),
|
||||
&fixture.runtime(),
|
||||
&fixture.transport,
|
||||
&fixture.store,
|
||||
&mut lock,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_requirements_are_checked_without_a_spurious_locked_edge() {
|
||||
let mut fixture = Python::new();
|
||||
fixture
|
||||
.transport
|
||||
.release("alpha", "1.0.0", "Requires-Dist: alpha>=1\n");
|
||||
fixture.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
assert!(fixture.lock().libs[0].dependencies.is_empty());
|
||||
fixture.command("doctor", &[]).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! 回环 PyPI 和仅测试签名发行;测试显式使用 dev Pod 的 Python,不修改全局安装。
|
||||
|
||||
use super::super::{
|
||||
Fixture,
|
||||
fixture::{digest, write},
|
||||
};
|
||||
use dever::compiler::project::dependency::{
|
||||
Store,
|
||||
library::{registry::Transport, runtime},
|
||||
worker::signed::SignedResources,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeMap,
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
pub(super) struct Python {
|
||||
pub fixture: Fixture,
|
||||
pub signed: SignedResources,
|
||||
pub transport: Registry,
|
||||
pub store: Memory,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Memory(
|
||||
pub RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
pub RefCell<Vec<(String, usize)>>,
|
||||
);
|
||||
impl Store for Memory {
|
||||
fn get(&self, hash: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
let bytes = self.0.borrow().get(hash).cloned();
|
||||
self.1
|
||||
.borrow_mut()
|
||||
.push((hash.into(), bytes.as_ref().map_or(0, Vec::len)));
|
||||
Ok(bytes)
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.0.borrow_mut().insert(digest(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Registry {
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
versions: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
|
||||
pub requests: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Transport for Registry {
|
||||
fn get(&self, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
self.requests.borrow_mut().push(url.into());
|
||||
let bytes = self
|
||||
.files
|
||||
.get(url)
|
||||
.ok_or_else(|| format!("test registry misses {url}"))?
|
||||
.clone();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
request.push(byte[0]);
|
||||
assert!(request.len() < 4096);
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
});
|
||||
let client: ureq::Agent = ureq::Agent::config_builder()
|
||||
.proxy(None)
|
||||
.max_redirects(0)
|
||||
.timeout_global(Some(std::time::Duration::from_secs(3)))
|
||||
.build()
|
||||
.into();
|
||||
let mut response = client
|
||||
.get(format!("http://{address}/metadata"))
|
||||
.call()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.as_reader()
|
||||
.take(limit as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.unwrap();
|
||||
thread.join().unwrap();
|
||||
if bytes.len() > limit {
|
||||
return Err("fixture exceeded transfer budget".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn release(&mut self, name: &str, version: &str, headers: &str) -> Vec<u8> {
|
||||
self.release_wheel(name, version, wheel(name, version, headers))
|
||||
}
|
||||
|
||||
pub fn release_wheel(&mut self, name: &str, version: &str, bytes: Vec<u8>) -> Vec<u8> {
|
||||
let filename = format!("{}-{version}-py3-none-any.whl", name.replace('-', "_"));
|
||||
let url = format!("https://files.pythonhosted.org/packages/{filename}");
|
||||
self.files.insert(url.clone(), bytes.clone());
|
||||
let artifact = serde_json::json!({"packagetype":"bdist_wheel","filename":filename,"url":url,"digests":{"sha256":digest(&bytes)},"size":bytes.len(),"requires_python":">=3.11"});
|
||||
self.files.insert(format!("https://pypi.org/pypi/{name}/{version}/json"), serde_json::to_vec(&serde_json::json!({"info":{"requires_dist":["wrong-registry-summary>=999"]},"urls":[artifact]})).unwrap());
|
||||
self.versions
|
||||
.entry(name.into())
|
||||
.or_default()
|
||||
.insert(version.into(), serde_json::json!([]));
|
||||
self.files.insert(
|
||||
format!("https://pypi.org/pypi/{name}/json"),
|
||||
serde_json::to_vec(&serde_json::json!({"releases":self.versions[name]})).unwrap(),
|
||||
);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wheel(name: &str, version: &str, headers: &str) -> Vec<u8> {
|
||||
wheel_with_files(name, version, headers, BTreeMap::new())
|
||||
}
|
||||
|
||||
pub(super) fn wheel_with_files(
|
||||
name: &str,
|
||||
version: &str,
|
||||
headers: &str,
|
||||
extra: BTreeMap<String, Vec<u8>>,
|
||||
) -> Vec<u8> {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Digest, Sha256};
|
||||
let stem = format!("{}-{version}.dist-info", name.replace('-', "_"));
|
||||
let mut files = BTreeMap::from([
|
||||
(
|
||||
format!("{}/__init__.py", name.replace('-', "_")),
|
||||
b"VALUE = 42\n".to_vec(),
|
||||
),
|
||||
(
|
||||
format!("{stem}/METADATA"),
|
||||
format!("Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n{headers}\n")
|
||||
.into_bytes(),
|
||||
),
|
||||
(
|
||||
format!("{stem}/WHEEL"),
|
||||
b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n".to_vec(),
|
||||
),
|
||||
]);
|
||||
files.extend(extra);
|
||||
let mut record = files
|
||||
.iter()
|
||||
.map(|(path, bytes)| {
|
||||
format!(
|
||||
"{path},sha256={},{}\n",
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
|
||||
bytes.len()
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
record.push_str(&format!("{stem}/RECORD,,\n"));
|
||||
files.insert(format!("{stem}/RECORD"), record.into_bytes());
|
||||
zip(files)
|
||||
}
|
||||
|
||||
pub(super) fn zip(files: BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, bytes) in files {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(&bytes).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
impl Python {
|
||||
pub fn new() -> Self {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = fixture.directory.0.join("test-signed");
|
||||
let executable = std::fs::read("/usr/bin/python3")
|
||||
.expect("Python fixture requires the existing dev Pod interpreter");
|
||||
let result = std::process::Command::new("/usr/bin/python3")
|
||||
.args([
|
||||
"-I",
|
||||
"-S",
|
||||
"-B",
|
||||
"-c",
|
||||
"import sys; print(sys.version.split()[0])",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success());
|
||||
let version = String::from_utf8(result.stdout).unwrap().trim().to_owned();
|
||||
let short = version.rsplit_once('.').unwrap().0;
|
||||
let markers = serde_json::json!({"implementation_name":"cpython","implementation_version":version,"os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":version,"python_version":short,"sys_platform":"linux"});
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap();
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes) in [
|
||||
("bin/python3", executable),
|
||||
("dever-runtime.json", manifest),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(0o755);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let pack = archive.into_inner().unwrap().finish().unwrap();
|
||||
let metadata = serde_json::to_vec(&serde_json::json!({"format":"dever-registry-runtime-v1","runtime":{"pack":{"name":"cpython","version":version,"sha256":digest(&pack)},"target":"linux-x86_64","python_markers":markers,"python_wheel_tags":["py3-none-any"]}})).unwrap();
|
||||
let assets = [
|
||||
("runtime/pip/linux-x86_64/manifest.json", metadata),
|
||||
("runtime/pip/linux-x86_64/runtime.pack", pack),
|
||||
];
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[81; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
let catalog = serde_json::to_vec(&serde_json::json!({"format":"dever-release-v2","version":"0.0.1","platform":"linux-x86_64","artifacts":assets.iter().map(|(path, bytes)| serde_json::json!({"path":path,"bytes":bytes.len(),"sha256":digest(bytes)})).collect::<Vec<_>>(),"extensions":[]})).unwrap();
|
||||
for (path, bytes) in assets {
|
||||
write(&root, &format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
let signed = SignedResources::load(&root, "0.0.1").unwrap();
|
||||
Self {
|
||||
fixture,
|
||||
signed,
|
||||
transport: Registry::default(),
|
||||
store: Memory::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime(&self) -> runtime::Metadata {
|
||||
runtime::metadata(
|
||||
&self.signed,
|
||||
"pip",
|
||||
dever::compiler::toolchain::BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn command(&self, action: &str, specs: &[&str]) -> Result<String, String> {
|
||||
dever::compiler::project::dependency::library::command::execute_with(
|
||||
action,
|
||||
&self.fixture.directory.0,
|
||||
&specs
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
dever::compiler::toolchain::BuildTarget::host().unwrap(),
|
||||
&self.transport,
|
||||
&self.store,
|
||||
Some(&self.signed),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lock(&self) -> dever::compiler::project::dependency::Lock {
|
||||
dever::compiler::project::dependency::Lock::decode(
|
||||
&std::fs::read_to_string(self.fixture.directory.0.join("dever.lock")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use super::fixture::{Fixture, app, write};
|
||||
|
||||
fn single() -> Fixture {
|
||||
let mut fixture = Fixture::new(&["numbers@^1"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[("module/numbers/value/app.dever", &app("input"))],
|
||||
);
|
||||
fixture
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_bytes_and_manifest_digests_are_verified_before_parsing_sources() {
|
||||
let mut fixture = single();
|
||||
fixture.entries[0].manifest_sha256 = "0".repeat(64);
|
||||
fixture.lock();
|
||||
fixture.reject("manifest changed");
|
||||
let mut fixture = single();
|
||||
fixture.assets.values_mut().next().unwrap()[0] ^= 1;
|
||||
fixture.reject("SHA-256 mismatch");
|
||||
let mut fixture = single();
|
||||
fixture.entries[0].bytes += 1;
|
||||
fixture.lock();
|
||||
fixture.reject("size or SHA-256 mismatch");
|
||||
let mut fixture = single();
|
||||
fixture.assets.clear();
|
||||
fixture.reject("asset is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_closure_rejects_stale_missing_cycles_and_orphan_entries() {
|
||||
let fixture = single();
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"config/setting.json",
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["numbers@^2"]}}"#,
|
||||
);
|
||||
fixture.reject("lock is stale");
|
||||
let mut fixture = single();
|
||||
fixture.entries[0]
|
||||
.dependencies
|
||||
.push(super::fixture::Dependency {
|
||||
name: "absent".into(),
|
||||
version: "1.0.0".into(),
|
||||
});
|
||||
fixture.lock();
|
||||
fixture.reject("missing or mismatched dependency");
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^1")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package(
|
||||
"units",
|
||||
&[("numbers", "^1")],
|
||||
&[("module/units/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.reject("cyclic");
|
||||
let mut fixture = single();
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.reject("unreferenced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_dependencies_must_match_locked_edges_and_ranges() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^2")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.reject("dependency units lock is stale");
|
||||
let mut fixture = Fixture::new(&["numbers@*", "units@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "*")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.entries[0].dependencies.clear();
|
||||
fixture.lock();
|
||||
fixture.reject("dependencies changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_lock_validation_never_fall_back_to_local_sources() {
|
||||
for (settings, needle) in [
|
||||
(
|
||||
r#"{"package":{"registry":"http://packages.test","use":[]}}"#,
|
||||
"HTTPS origin",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test/path","use":[]}}"#,
|
||||
"HTTPS origin",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["numbers@*","numbers@^1"]}}"#,
|
||||
"repeats",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["dever@*"]}}"#,
|
||||
"nonreserved",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["numbers@*"]},"package":null}"#,
|
||||
"invalid project configuration JSON",
|
||||
),
|
||||
(
|
||||
r#"{"lib":["npm:example@^1"]}"#,
|
||||
"npm requires an exact semantic version",
|
||||
),
|
||||
] {
|
||||
let fixture = single();
|
||||
write(&fixture.directory.0, "config/setting.json", settings);
|
||||
fixture.reject(needle);
|
||||
}
|
||||
let fixture = single();
|
||||
std::fs::remove_file(fixture.directory.0.join("dever.lock")).unwrap();
|
||||
fixture.reject("require dever.lock");
|
||||
let fixture = single();
|
||||
let path = fixture.directory.0.join("dever.lock");
|
||||
let lock = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, lock.trim_end()).unwrap();
|
||||
fixture.reject("not canonical");
|
||||
std::fs::write(&path, lock.replace("\"workers\": []", "\"workers\": [{}]")).unwrap();
|
||||
fixture.reject("missing field `port`");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("config/setting.json", path).unwrap();
|
||||
fixture.reject("project input links");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
mod api;
|
||||
mod archive;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cache;
|
||||
mod commands;
|
||||
mod documentation;
|
||||
mod fixture;
|
||||
mod library;
|
||||
mod lock;
|
||||
mod npm;
|
||||
mod worker;
|
||||
|
||||
use fixture::{Fixture, app, command, write};
|
||||
|
||||
#[test]
|
||||
fn locked_sources_build_native_with_transitive_calls_and_original_locations() {
|
||||
let mut fixture = Fixture::new(&["numbers@^1"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^1")],
|
||||
&[(
|
||||
"module/numbers/value/app.dever",
|
||||
&app("units.value.value(input) * 2"),
|
||||
)],
|
||||
);
|
||||
fixture.package(
|
||||
"units",
|
||||
&[],
|
||||
&[("module/units/value/app.dever", &app("input + 1"))],
|
||||
);
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/demo/value/app.dever",
|
||||
app("numbers.value.value(input)"),
|
||||
);
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/demo/value/api.dever",
|
||||
command(),
|
||||
);
|
||||
let before = std::fs::read(fixture.directory.0.join("dever.lock")).unwrap();
|
||||
let project = fixture.load().unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = dever::compiler::build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&fixture.directory.0.join("app"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.value.value", "{\"input\":20}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":42}\n"
|
||||
);
|
||||
let result = std::process::Command::new(binary)
|
||||
.args(["demo.value.value", "{\"input\":9223372036854775807}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&result.stderr).contains("package/module/units/value/app.dever:")
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read(fixture.directory.0.join("dever.lock")).unwrap()
|
||||
);
|
||||
assert!(!fixture.directory.0.join("module/numbers").exists());
|
||||
assert!(!fixture.directory.0.join("package").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_only_project_and_local_sources_share_identity_and_layout_checks() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input + 1")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
("module/numbers/assets/home.html", "<h1>resource</h1>"),
|
||||
],
|
||||
);
|
||||
assert!(!fixture.directory.0.join("module").exists());
|
||||
let project = fixture.load().unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert_eq!(program.commands[0].name, "numbers.value.value");
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/numbers/unrelated/readme.txt",
|
||||
"local component must win by explicit error",
|
||||
);
|
||||
fixture.reject("collides with local component");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_source_format_and_unimplemented_roles_cannot_bypass_checking() {
|
||||
for (path, text, load_error) in [
|
||||
(
|
||||
"module/numbers/value/app.dever.md",
|
||||
app("input"),
|
||||
Some("no Dever source"),
|
||||
),
|
||||
(
|
||||
"module/numbers/value/test.dever",
|
||||
app("input"),
|
||||
Some("role 'test' is not implemented yet"),
|
||||
),
|
||||
] {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package("numbers", &[], &[(path, &text)]);
|
||||
fixture.reject(load_error.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_roles_allow_a_single_topic_file() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app/only.dever", &app("input")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
let project = fixture.load().unwrap();
|
||||
assert_eq!(
|
||||
project.check().unwrap().commands[0].name,
|
||||
"numbers.value.value"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::{Fixture, tools};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock,
|
||||
library::{command, npm},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, os::unix::fs::DirBuilderExt};
|
||||
|
||||
#[test]
|
||||
fn npm_signed_hooks_native_addon_and_missing_output_replay_are_real() {
|
||||
let mut fixture = Fixture::new();
|
||||
tools::prepare(&mut fixture);
|
||||
let script=br#"const fs=require('node:fs');const path=require('node:path');const child=require('node:child_process');
|
||||
const tools=process.env.PATH.split(':').find(p=>p.endsWith('/rootfs/usr/bin'));
|
||||
if(!tools)throw Error('signed tools PATH missing');
|
||||
if((fs.statSync('executable.cjs').mode&0o111)===0)throw Error('archive executable bit lost');
|
||||
if((fs.statSync('addon.c').mode&0o111)!==0)throw Error('archive executable bit invented');
|
||||
if(fs.readFileSync(path.join(path.dirname(process.execPath),'..','shared.txt'),'utf8')!=='shared runtime fixture')throw Error('shared runtime path changed');
|
||||
if(child.execFileSync(path.join(tools,'fixture-node'),{encoding:'utf8'})!=='signed script')throw Error('signed script did not execute');
|
||||
fs.mkdirSync('build/Release',{recursive:true});
|
||||
child.execFileSync(path.join(tools,'cc'),['-B'+tools+'/','-fno-use-linker-plugin','-fno-lto','-shared','-fPIC','-nostdlib','addon.c','-o','build/Release/addon.node'],{stdio:'inherit'});
|
||||
fs.writeFileSync('generated.js','module.exports=42;');
|
||||
"#;
|
||||
let source=br#"typedef void* napi_env;typedef void* napi_value;typedef unsigned long size_t;
|
||||
extern int napi_create_string_utf8(napi_env,const char*,size_t,napi_value*);
|
||||
__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env,napi_value exports){napi_value result;napi_create_string_utf8(env,"signed addon",12,&result);return result;}
|
||||
"#;
|
||||
fixture.package(json!({"name":"built","version":"1.0.0","optionalDependencies":{"unavailable":"1.0.0"},"scripts":{"install":"node build.cjs","prepare":"node must-not-run.cjs"}}),BTreeMap::from([("package/build.cjs".into(),script.to_vec()),("package/addon.c".into(),source.to_vec()),("package/executable.cjs".into(),b"#!/usr/bin/env node\n".to_vec())]));
|
||||
fixture.package(json!({"name":"unavailable","version":"1.0.0","scripts":{"install":"node -e 'process.exit(7)'"},"bin":{"fixture-bin":"index.js"}}), BTreeMap::new());
|
||||
let project = fixture.directory.0.join("project");
|
||||
fs::DirBuilder::new().mode(0o700).create(&project).unwrap();
|
||||
fs::create_dir(project.join("config")).unwrap();
|
||||
fs::write(project.join("config/setting.json"), "{\"lib\":[]}").unwrap();
|
||||
command::execute_with(
|
||||
"add",
|
||||
&project,
|
||||
&["npm:built@1.0.0".into()],
|
||||
fixture.target,
|
||||
&fixture,
|
||||
&fixture,
|
||||
Some(&fixture.signed),
|
||||
)
|
||||
.unwrap();
|
||||
let lock_file = fs::read_to_string(project.join("dever.lock")).unwrap();
|
||||
let setting = fs::read(project.join("config/setting.json")).unwrap();
|
||||
fixture.lock = Lock::decode(&lock_file).unwrap();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files["node_modules/built/generated.js"],
|
||||
b"module.exports=42;"
|
||||
);
|
||||
assert!(files["node_modules/built/build/Release/addon.node"].starts_with(b"\x7fELF"));
|
||||
assert_eq!(fixture.graph().instances.len(), 1);
|
||||
assert_eq!(
|
||||
fixture.graph().instances[0].edges[0].omission,
|
||||
Some(npm::Omission::BuildFailure)
|
||||
);
|
||||
assert_eq!(fixture.lock.libs.len(), 1);
|
||||
let receipt = &fixture.lock.builds[0];
|
||||
assert_eq!(
|
||||
receipt["native_entries"],
|
||||
json!(["node_modules/built/build/Release/addon.node"])
|
||||
);
|
||||
let output = receipt["output"]["sha256"].as_str().unwrap().to_owned();
|
||||
let original = fixture.cache.borrow_mut().remove(&output).unwrap();
|
||||
let sources = receipt["sources"].as_array().unwrap();
|
||||
assert_eq!(sources.len(), 2);
|
||||
let mut expected_urls = vec![];
|
||||
for source in sources {
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.remove(source["sha256"].as_str().unwrap())
|
||||
.unwrap();
|
||||
expected_urls.push(source["source"]["locator"].as_str().unwrap().to_owned());
|
||||
}
|
||||
let lock_before = fixture.lock.encode().unwrap();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
command::execute_with(
|
||||
"install",
|
||||
&project,
|
||||
&[],
|
||||
fixture.target,
|
||||
&fixture,
|
||||
&fixture,
|
||||
Some(&fixture.signed),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fixture.cache.borrow()[&output], original);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), lock_before);
|
||||
assert_eq!(
|
||||
fs::read_to_string(project.join("dever.lock")).unwrap(),
|
||||
lock_file
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(project.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
let mut fetched = fixture.calls.borrow()[calls..].to_vec();
|
||||
expected_urls.sort();
|
||||
fetched.sort();
|
||||
assert_eq!(fetched, expected_urls);
|
||||
nondeterministic_replay_is_rejected(&mut fixture);
|
||||
failed_native_build_is_not_published(&mut fixture);
|
||||
}
|
||||
|
||||
fn nondeterministic_replay_is_rejected(fixture: &mut Fixture) {
|
||||
fixture.lock = Lock::new(vec![]);
|
||||
fixture.package(json!({"name":"unstable","version":"1.0.0","scripts":{"install":"node -e \"require('node:fs').writeFileSync('nonce',require('node:crypto').randomBytes(16))\""}}), BTreeMap::new());
|
||||
fixture.resolve(&["npm:unstable@1.0.0"]).unwrap();
|
||||
let mut lock = fixture.lock.clone();
|
||||
fixture.reads.borrow_mut().clear();
|
||||
npm::prepare(&mut lock, fixture.target, &fixture.signed, fixture).unwrap();
|
||||
for lib in &lock.libs {
|
||||
let artifact = &lib.artifacts[0];
|
||||
let reads = fixture.reads.borrow();
|
||||
let source_reads = reads
|
||||
.iter()
|
||||
.filter(|(hash, _)| hash == &artifact.sha256)
|
||||
.collect::<Vec<_>>();
|
||||
eprintln!(
|
||||
"npm build: fixture={} bytes={} reads={}",
|
||||
artifact.sha256,
|
||||
artifact.bytes,
|
||||
source_reads.len()
|
||||
);
|
||||
assert_eq!(source_reads.len(), 1);
|
||||
assert_eq!(source_reads[0].1 as u64, artifact.bytes);
|
||||
}
|
||||
fixture.lock = lock;
|
||||
let digest = fixture.lock.builds[0]["output"]["sha256"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
fixture.cache.borrow_mut().remove(&digest).unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
let cache_entries = fixture.cache.borrow().len();
|
||||
let error = npm::restore(
|
||||
&fixture.lock,
|
||||
&fixture.graph(),
|
||||
fixture.target,
|
||||
&fixture.signed,
|
||||
fixture,
|
||||
fixture,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not byte reproducible"), "{error}");
|
||||
assert_eq!(fixture.cache.borrow().len(), cache_entries);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
fn failed_native_build_is_not_published(fixture: &mut Fixture) {
|
||||
fixture.lock = Lock::new(vec![]);
|
||||
fixture.package(json!({"name":"broken","version":"1.0.0","scripts":{"install":"node -e \"const fs=require('node:fs');fs.mkdirSync('build/Release',{recursive:true});fs.writeFileSync('build/Release/broken.node','invalid addon');\""}}), BTreeMap::new());
|
||||
fixture.resolve(&["npm:broken@1.0.0"]).unwrap();
|
||||
let mut lock = fixture.lock.clone();
|
||||
let before = lock.encode().unwrap();
|
||||
let cache_entries = fixture.cache.borrow().len();
|
||||
let error = npm::prepare(&mut lock, fixture.target, &fixture.signed, fixture).unwrap_err();
|
||||
assert!(error.contains("Lib build failed"), "{error}");
|
||||
assert_eq!(lock.encode().unwrap(), before);
|
||||
assert_eq!(fixture.cache.borrow().len(), cache_entries);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use super::super::super::Directory;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use dever::compiler::{
|
||||
project::dependency::{
|
||||
Lock, Store,
|
||||
library::{self, Spec, registry::Transport, runtime},
|
||||
worker::signed::SignedResources,
|
||||
},
|
||||
toolchain::BuildTarget,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
use std::{cell::RefCell, collections::BTreeMap, io::Cursor};
|
||||
|
||||
pub(super) fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn archive(files: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let gzip = flate2::GzBuilder::new()
|
||||
.mtime(0)
|
||||
.write(Vec::new(), flate2::Compression::fast());
|
||||
let mut builder = tar::Builder::new(gzip);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(if bytes.starts_with(b"#!") {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
});
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, Cursor::new(bytes))
|
||||
.unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
pub(super) struct Fixture {
|
||||
pub directory: Directory,
|
||||
pub documents: BTreeMap<String, Vec<u8>>,
|
||||
pub cache: RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
pub reads: RefCell<Vec<(String, usize)>>,
|
||||
pub calls: RefCell<Vec<String>>,
|
||||
pub metadata: runtime::Metadata,
|
||||
pub target: BuildTarget,
|
||||
pub signed: SignedResources,
|
||||
pub lock: Lock,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new() -> Self {
|
||||
let directory = Directory::new();
|
||||
let target = BuildTarget::host().unwrap();
|
||||
let metadata = json!({"pack":{"name":"node","version":"22.1.0","sha256":hash(b"node pack")},"target":target.name(),"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"});
|
||||
let signed = signed(
|
||||
&directory,
|
||||
&BTreeMap::from([(
|
||||
format!("runtime/npm/{}/manifest.json", target.name()),
|
||||
serde_json::to_vec(
|
||||
&json!({"format":"dever-registry-runtime-v1","runtime":metadata}),
|
||||
)
|
||||
.unwrap(),
|
||||
)]),
|
||||
);
|
||||
Self {
|
||||
directory,
|
||||
documents: BTreeMap::new(),
|
||||
cache: RefCell::new(BTreeMap::new()),
|
||||
reads: RefCell::new(vec![]),
|
||||
calls: RefCell::new(vec![]),
|
||||
metadata: serde_json::from_value(metadata).unwrap(),
|
||||
target,
|
||||
signed,
|
||||
lock: Lock::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package(&mut self, document: Value, extra: BTreeMap<String, Vec<u8>>) {
|
||||
let name = document["name"].as_str().unwrap();
|
||||
let version = document["version"].as_str().unwrap();
|
||||
let base = format!("https://registry.npmjs.org/{name}");
|
||||
let locator = format!("{base}/-/archive-{version}.tgz");
|
||||
let mut files = extra;
|
||||
files.insert(
|
||||
"package/package.json".into(),
|
||||
serde_json::to_vec(&document).unwrap(),
|
||||
);
|
||||
files.insert("package/index.js".into(), b"module.exports = 42;".to_vec());
|
||||
let bytes = archive(&files);
|
||||
let mut metadata = document.clone();
|
||||
metadata["dist"] = json!({"tarball":locator,"integrity":format!("sha512-{}",STANDARD.encode(Sha512::digest(&bytes)))});
|
||||
let mut index: Value = self
|
||||
.documents
|
||||
.get(&base)
|
||||
.map(|bytes| serde_json::from_slice(bytes).unwrap())
|
||||
.unwrap_or_else(|| json!({"name":name,"versions":{}}));
|
||||
index["versions"][version] = metadata.clone();
|
||||
self.documents
|
||||
.insert(base.clone(), serde_json::to_vec(&index).unwrap());
|
||||
self.documents.insert(
|
||||
format!("{base}/{version}"),
|
||||
serde_json::to_vec(&metadata).unwrap(),
|
||||
);
|
||||
self.documents.insert(locator, bytes);
|
||||
}
|
||||
|
||||
pub fn resolve(&mut self, roots: &[&str]) -> Result<(), String> {
|
||||
let specs = roots
|
||||
.iter()
|
||||
.map(|value| value.parse::<Spec>())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut lock = self.lock.clone();
|
||||
library::npm::resolve(&specs, self.target, &self.metadata, self, self, &mut lock)?;
|
||||
self.lock = lock;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn graph(&self) -> library::npm::Environment {
|
||||
serde_json::from_value(self.lock.npm[0].clone()).unwrap()
|
||||
}
|
||||
|
||||
pub fn install(&self) -> Result<BTreeMap<String, Vec<u8>>, String> {
|
||||
library::npm::install(
|
||||
&self.lock,
|
||||
&self.graph().roots,
|
||||
self.target,
|
||||
self,
|
||||
&self.signed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for Fixture {
|
||||
fn get(&self, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
library::registry::official(url)?;
|
||||
self.calls.borrow_mut().push(url.into());
|
||||
let bytes = self
|
||||
.documents
|
||||
.get(url)
|
||||
.ok_or_else(|| "registry returned HTTP 404".to_owned())?;
|
||||
assert!(bytes.len() <= limit);
|
||||
Ok(bytes.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Fixture {
|
||||
fn get(&self, digest: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
let bytes = self.cache.borrow().get(digest).cloned();
|
||||
self.reads
|
||||
.borrow_mut()
|
||||
.push((digest.into(), bytes.as_ref().map_or(0, Vec::len)));
|
||||
Ok(bytes)
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.cache.borrow_mut().insert(hash(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn signed(directory: &Directory, files: &BTreeMap<String, Vec<u8>>) -> SignedResources {
|
||||
let root = directory.0.join("signed");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[72; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let write = |path: &str, bytes: &[u8]| {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
};
|
||||
write(
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()).as_bytes(),
|
||||
);
|
||||
let mut artifacts = vec![json!({"path":"base.txt","bytes":4,"sha256":hash(b"base")})];
|
||||
let target = BuildTarget::host().unwrap();
|
||||
for (path, bytes) in files {
|
||||
artifacts.push(json!({"path":path,"bytes":bytes.len(),"sha256":hash(bytes)}));
|
||||
write(&format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write("versions/0.0.1/base.txt", b"base");
|
||||
let catalog=serde_json::to_vec(&json!({"format":"dever-release-v2","version":"0.0.1","platform":target.name(),"artifacts":artifacts,"extensions":[]})).unwrap();
|
||||
write("versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()).as_bytes(),
|
||||
);
|
||||
SignedResources::load(&root, "0.0.1").unwrap()
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::Fixture;
|
||||
use dever::compiler::project::dependency::library::npm::{self, Kind, Omission};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn npm_scoped_metadata_uses_strict_official_url_policy() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(
|
||||
json!({"name":"app","version":"1.0.0","dependencies":{"@types/node":"^22"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.package(
|
||||
json!({"name":"@types/node","version":"22.10.0"}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
for address in [
|
||||
"https://registry.npmjs.org/@types/node",
|
||||
"https://registry.npmjs.org/@types/node/22.10.0",
|
||||
"https://registry.npmjs.org/@types/node/-/archive-22.10.0.tgz",
|
||||
] {
|
||||
assert!(fixture.calls.borrow().iter().any(|url| url == address));
|
||||
}
|
||||
let graph = fixture.graph();
|
||||
let node = graph
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "@types/node")
|
||||
.unwrap();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files[&format!("{}/index.js", node.path)],
|
||||
b"module.exports = 42;"
|
||||
);
|
||||
let lib = fixture
|
||||
.lock
|
||||
.libs
|
||||
.iter()
|
||||
.find(|lib| lib.spec.name == "@types/node")
|
||||
.unwrap();
|
||||
let mut source = lib.artifacts[0].source.clone().unwrap();
|
||||
for locator in [
|
||||
"https://registry.npmjs.org/@types%2fnode/-/archive-22.10.0.tgz",
|
||||
"https://registry.npmjs.org/@types%2Fnode/-/archive-22.10.0.tgz",
|
||||
"https://registry.npmjs.org/@types/node/%2e%2e/archive-22.10.0.tgz",
|
||||
] {
|
||||
source.locator = locator.into();
|
||||
assert!(source.validate(&lib.spec).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_nested_instances_keep_peer_context_and_install_offline() {
|
||||
let mut fixture = Fixture::new();
|
||||
for document in [
|
||||
json!({"name":"left","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"1.0.0"}}),
|
||||
json!({"name":"right","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"2.0.0"}}),
|
||||
json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":">=1 <3"}}),
|
||||
json!({"name":"engine","version":"1.0.0"}),
|
||||
json!({"name":"engine","version":"2.0.0"}),
|
||||
] {
|
||||
fixture.package(document, BTreeMap::new());
|
||||
}
|
||||
fixture
|
||||
.resolve(&["npm:left@1.0.0", "npm:right@1.0.0"])
|
||||
.unwrap();
|
||||
let graph = fixture.graph();
|
||||
for root in ["left", "right"] {
|
||||
let plugin = graph
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.path == format!("node_modules/{root}/node_modules/plugin"))
|
||||
.unwrap();
|
||||
assert_eq!(plugin.edges[0].kind, Kind::Peer);
|
||||
assert_eq!(
|
||||
plugin.edges[0].target.as_deref(),
|
||||
Some(format!("node_modules/{root}/node_modules/engine").as_str())
|
||||
);
|
||||
}
|
||||
fixture.documents.clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(files.len(), 12);
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
assert_eq!(fixture.lock.libs.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_peer_conflict_backtracks_versions_and_incompatible_roots_fail_atomically() {
|
||||
let mut fixture = Fixture::new();
|
||||
for document in [
|
||||
json!({"name":"app","version":"1.0.0","dependencies":{"plugin":"*","engine":"1.0.0"}}),
|
||||
json!({"name":"plugin","version":"2.0.0","peerDependencies":{"engine":"2.0.0"}}),
|
||||
json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":"1.0.0"}}),
|
||||
json!({"name":"engine","version":"1.0.0"}),
|
||||
json!({"name":"engine","version":"2.0.0"}),
|
||||
] {
|
||||
fixture.package(document, BTreeMap::new());
|
||||
}
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.graph()
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.spec
|
||||
.version,
|
||||
"1.0.0"
|
||||
);
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:engine@1.0.0", "npm:engine@2.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("incompatible")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_optional_target_and_absent_peer_are_explicit_but_integrity_errors_are_fatal() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0","optionalDependencies":{"wrong":"*","missing":"*"},"peerDependencies":{"peer":"*"},"peerDependenciesMeta":{"peer":{"optional":true}}}),BTreeMap::new());
|
||||
fixture.package(
|
||||
json!({"name":"wrong","version":"1.0.0","os":["!linux"]}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
let graph = fixture.graph();
|
||||
let edges = &graph.instances[0].edges;
|
||||
for (name, reason) in [
|
||||
("wrong", Omission::Target),
|
||||
("missing", Omission::Unavailable),
|
||||
("peer", Omission::OptionalPeerAbsent),
|
||||
] {
|
||||
assert_eq!(
|
||||
edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == name)
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(reason)
|
||||
);
|
||||
}
|
||||
fixture.install().unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:missing@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("no npm version")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
fixture.documents.insert(
|
||||
"https://registry.npmjs.org/wrong/-/archive-1.0.0.tgz".into(),
|
||||
b"corrupt optional archive".to_vec(),
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("integrity")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_one_version_cannot_change_bytes_between_worker_groups() {
|
||||
let mut fixture = Fixture::new();
|
||||
let metadata = json!({"name":"shared","version":"1.0.0"});
|
||||
fixture.package(metadata.clone(), BTreeMap::new());
|
||||
fixture.resolve(&["npm:shared@1.0.0"]).unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
fixture.package(
|
||||
metadata,
|
||||
BTreeMap::from([("package/changed.js".into(), b"different bytes".to_vec())]),
|
||||
);
|
||||
let error = fixture.resolve(&["npm:shared@1.0.0"]).unwrap_err();
|
||||
assert!(error.contains("artifact differs"), "{error}");
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_bundled_packages_have_one_owner_and_tampered_manifest_or_graph_fails() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0","dependencies":{"tiny":"1.0.0"},"bundleDependencies":["tiny"]}),BTreeMap::from([
|
||||
("package/node_modules/tiny/package.json".into(),serde_json::to_vec(&json!({"name":"tiny","version":"1.0.0"})).unwrap()),
|
||||
("package/node_modules/tiny/index.js".into(),b"module.exports=7".to_vec()),
|
||||
]));
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
assert_eq!(fixture.lock.libs.len(), 1);
|
||||
assert_eq!(fixture.install().unwrap().len(), 4);
|
||||
let original = fixture.lock.npm[0].clone();
|
||||
fixture.lock.npm[0]["instances"][0]["edges"][0]["requirement"] = json!("2.0.0");
|
||||
assert!(fixture.install().unwrap_err().contains("range"));
|
||||
fixture.lock.npm[0] = original;
|
||||
let mut graph = fixture.graph();
|
||||
graph.instances[1].source.prefix = "package/".into();
|
||||
assert!(
|
||||
npm::validate(&graph, &fixture.lock)
|
||||
.unwrap_err()
|
||||
.contains("owned")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_registry_and_archive_metadata_must_agree_and_urls_are_official() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0"}), BTreeMap::new());
|
||||
let address = "https://registry.npmjs.org/app/1.0.0";
|
||||
let original = fixture.documents[address].clone();
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
metadata["dependencies"] = json!({"hidden":"1.0.0"});
|
||||
fixture
|
||||
.documents
|
||||
.insert(address.into(), serde_json::to_vec(&metadata).unwrap());
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("metadata")
|
||||
);
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
metadata["dist"]["tarball"] = json!("https://attacker.invalid/app.tgz");
|
||||
fixture
|
||||
.documents
|
||||
.insert(address.into(), serde_json::to_vec(&metadata).unwrap());
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("official")
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|url| url.contains("attacker"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_cycles_close_without_infinite_nested_copies_and_missing_build_receipt_fails() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(
|
||||
json!({"name":"alpha","version":"1.0.0","dependencies":{"beta":"1.0.0"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.package(
|
||||
json!({"name":"beta","version":"1.0.0","dependencies":{"alpha":"1.0.0"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:alpha@1.0.0"]).unwrap();
|
||||
assert_eq!(fixture.graph().instances.len(), 2);
|
||||
assert_eq!(fixture.install().unwrap().len(), 4);
|
||||
fixture.package(
|
||||
json!({"name":"hook","version":"1.0.0","scripts":{"install":"must not run"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:hook@1.0.0"]).unwrap();
|
||||
let graph = fixture
|
||||
.lock
|
||||
.npm
|
||||
.iter()
|
||||
.find(|value| value["roots"][0]["name"] == "hook")
|
||||
.unwrap()
|
||||
.clone();
|
||||
fixture.lock.npm = vec![graph];
|
||||
assert!(fixture.install().unwrap_err().contains("lifecycle"));
|
||||
fixture.lock.npm[0]["build"] = json!("0".repeat(64));
|
||||
assert!(fixture.install().unwrap_err().contains("receipt"));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 注入 registry、签名 catalog 与真实 tgz;不运行宿主 Node/npm,不访问公网。
|
||||
|
||||
mod build;
|
||||
mod fixture;
|
||||
mod graph;
|
||||
mod process;
|
||||
mod receipt;
|
||||
mod tools;
|
||||
|
||||
use fixture::Fixture;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 共享执行器回归使用 Pod 内的真实子进程,不依赖线上服务。
|
||||
|
||||
use super::super::super::Directory;
|
||||
use dever::compiler::project::dependency::library::build::process;
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn child_gone(path: &Path) {
|
||||
let pid = fs::read_to_string(path).unwrap();
|
||||
assert!(
|
||||
!Path::new("/proc").join(pid.trim()).exists(),
|
||||
"child {pid} was not reaped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_output_overflow_drains_pipes_and_reaps_its_group() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.current_dir(&directory.0).args(["-c", "echo $$ > parent; /bin/sleep 300 & echo $! > child; /usr/bin/head -c 33554432 /dev/zero; wait"]);
|
||||
let error = process::run(command, &directory.0).unwrap_err();
|
||||
assert!(error.contains("16 MiB"), "{error}");
|
||||
child_gone(&directory.0.join("parent"));
|
||||
child_gone(&directory.0.join("child"));
|
||||
assert!(
|
||||
fs::read_dir(&directory.0)
|
||||
.unwrap()
|
||||
.all(|entry| entry.unwrap().metadata().unwrap().len() < 32)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_success_reaps_descendants_holding_output_open() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
.current_dir(&directory.0)
|
||||
.args(["-c", "/bin/sleep 300 & echo $! > child; exit 0"]);
|
||||
assert!(process::run(command, &directory.0).unwrap().success());
|
||||
child_gone(&directory.0.join("child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_scratch_budget_kills_and_reaps_before_returning() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.current_dir(&directory.0).args(["-c", "echo $$ > parent; /bin/sleep 300 & echo $! > child; /usr/bin/truncate -s 536870913 excess; wait"]);
|
||||
let error = process::run(command, &directory.0).unwrap_err();
|
||||
assert!(error.contains("512 MiB"), "{error}");
|
||||
child_gone(&directory.0.join("parent"));
|
||||
child_gone(&directory.0.join("child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_probe_distinguishes_unloadable_exit_and_signal_from_budget_errors() {
|
||||
let directory = Directory::new();
|
||||
for script in ["exit 10", "ulimit -c 0; kill -SEGV $$"] {
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.args(["-c", script]);
|
||||
let status = process::status(command, &directory.0).unwrap();
|
||||
assert!(!status.success());
|
||||
}
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.args(["-c", "exit 10"]);
|
||||
assert!(
|
||||
process::run(command, &directory.0)
|
||||
.unwrap_err()
|
||||
.contains("Lib build failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_script_header_cannot_select_host_tools_or_inject_arguments() {
|
||||
let fixture = super::Fixture::new();
|
||||
for header in [
|
||||
"#!/bin/bash",
|
||||
"#!/usr/bin/env node --eval unsafe",
|
||||
"#!/tmp/untrusted",
|
||||
] {
|
||||
let mut stage = dever::compiler::project::dependency::library::build::stage::Stage::new(
|
||||
&fixture.signed,
|
||||
)
|
||||
.unwrap();
|
||||
stage
|
||||
.write(
|
||||
"input/rootfs/usr/bin/script",
|
||||
format!("{header}\nexit 0\n").into_bytes(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let tools = ["rootfs/usr/bin/script".to_owned()].into_iter().collect();
|
||||
assert!(
|
||||
stage
|
||||
.prepare_tools(&tools)
|
||||
.unwrap_err()
|
||||
.contains("signed interpreter")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::{
|
||||
Fixture,
|
||||
fixture::{archive, hash, signed},
|
||||
};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock, Store,
|
||||
library::{
|
||||
Spec,
|
||||
lock::{Artifact, Library, Runtime},
|
||||
npm::Environment,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
// Fixture 按既有 v6 协议编码,独立于实现中的私有 Receipt 类型。
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Inputs {
|
||||
format: String,
|
||||
toolchain: String,
|
||||
libs: Vec<Library>,
|
||||
workers: Vec<Value>,
|
||||
npm: Vec<Environment>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Tools {
|
||||
format: String,
|
||||
ecosystem: String,
|
||||
target: String,
|
||||
runtime_sha256: String,
|
||||
python_runtime_sha256: Option<String>,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Receipt {
|
||||
ecosystem: String,
|
||||
roots: Vec<Spec>,
|
||||
sources: Vec<Artifact>,
|
||||
graph: Option<Environment>,
|
||||
runtime: Runtime,
|
||||
auxiliary_runtime: Option<Runtime>,
|
||||
native_entries: BTreeSet<String>,
|
||||
native_rejections: BTreeMap<String, String>,
|
||||
python_markers: Option<Value>,
|
||||
tools: Tools,
|
||||
frontend: String,
|
||||
python: Option<Value>,
|
||||
dynamic_requires: Vec<String>,
|
||||
inputs: Inputs,
|
||||
config_settings: BTreeMap<String, Value>,
|
||||
output: Artifact,
|
||||
}
|
||||
|
||||
fn prepare(fixture: &mut Fixture, addons: bool) -> Receipt {
|
||||
let node_pack = archive(&BTreeMap::from([(
|
||||
"fixture/node.txt".into(),
|
||||
b"author fixture runtime".to_vec(),
|
||||
)]));
|
||||
let python_pack = archive(&BTreeMap::from([(
|
||||
"fixture/python.txt".into(),
|
||||
b"author fixture auxiliary runtime".to_vec(),
|
||||
)]));
|
||||
let build_pack = archive(&BTreeMap::from([(
|
||||
"fixture/tools.txt".into(),
|
||||
b"already prepared output; no hooks run".to_vec(),
|
||||
)]));
|
||||
fixture.metadata.pack.sha256 = hash(&node_pack);
|
||||
let mut extra = BTreeMap::new();
|
||||
if addons {
|
||||
extra.insert(
|
||||
"package/foreign.node".into(),
|
||||
b"prebuilt foreign format".to_vec(),
|
||||
);
|
||||
}
|
||||
fixture.package(
|
||||
json!({"name":"built","version":"1.0.0","scripts":{"install":"must never run"}}),
|
||||
extra,
|
||||
);
|
||||
fixture.resolve(&["npm:built@1.0.0"]).unwrap();
|
||||
let graph = fixture.graph();
|
||||
let mut output = BTreeMap::from([
|
||||
(
|
||||
"node_modules/built/package.json".into(),
|
||||
serde_json::to_vec(
|
||||
&json!({"name":"built","version":"1.0.0","scripts":{"install":"must never run"}}),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
"node_modules/built/generated.js".into(),
|
||||
b"module.exports=42".to_vec(),
|
||||
),
|
||||
]);
|
||||
if addons {
|
||||
output.insert(
|
||||
"node_modules/built/foreign.node".into(),
|
||||
b"prebuilt foreign format".to_vec(),
|
||||
);
|
||||
}
|
||||
let bytes = archive(&output);
|
||||
fixture.put(&bytes).unwrap();
|
||||
let python = Runtime {
|
||||
name: "python".into(),
|
||||
version: "3.12.1".into(),
|
||||
sha256: hash(&python_pack),
|
||||
};
|
||||
let tools = Tools {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: "npm".into(),
|
||||
target: fixture.target.name().into(),
|
||||
runtime_sha256: fixture.metadata.pack.sha256.clone(),
|
||||
python_runtime_sha256: Some(python.sha256.clone()),
|
||||
sha256: hash(&build_pack),
|
||||
};
|
||||
let target = fixture.target.name();
|
||||
let files=BTreeMap::from([
|
||||
(format!("runtime/npm/{target}/runtime.pack"),node_pack),
|
||||
(format!("runtime/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":fixture.metadata.pack,"target":target,"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"}})).unwrap()),
|
||||
(format!("runtime/pip/{target}/runtime.pack"),python_pack),
|
||||
(format!("runtime/pip/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":python,"target":target,"python_markers":{"implementation_name":"cpython","implementation_version":"3.12.1","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.12.1","python_version":"3.12","sys_platform":"linux"},"python_wheel_tags":["py3-none-any"]}})).unwrap()),
|
||||
(format!("build/npm/{target}/build.pack"),build_pack),
|
||||
(format!("build/npm/{target}/manifest.json"),serde_json::to_vec(&tools).unwrap()),
|
||||
]);
|
||||
fixture.signed = signed(&fixture.directory, &files);
|
||||
Receipt {
|
||||
ecosystem: "npm".into(),
|
||||
roots: graph.roots.clone(),
|
||||
sources: fixture
|
||||
.lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| lib.artifacts.clone())
|
||||
.collect(),
|
||||
graph: Some(graph),
|
||||
runtime: fixture.metadata.pack.clone(),
|
||||
auxiliary_runtime: Some(python),
|
||||
native_entries: BTreeSet::new(),
|
||||
native_rejections: if addons {
|
||||
BTreeMap::from([("node_modules/built/foreign.node".into(), "format".into())])
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
},
|
||||
python_markers: None,
|
||||
tools,
|
||||
frontend: "cfe67ebbc939762cd4ba7328943bf97a72d06a4b79071f056ebd8e5ab566a721".into(),
|
||||
python: None,
|
||||
dynamic_requires: vec![],
|
||||
inputs: serde_json::from_value(serde_json::to_value(&fixture.lock).unwrap()).unwrap(),
|
||||
config_settings: BTreeMap::new(),
|
||||
output: Artifact {
|
||||
target: target.into(),
|
||||
path: format!("build/npm/{}/installation.tgz", hash(&bytes)),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: hash(&bytes),
|
||||
source: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn attach(fixture: &mut Fixture, receipt: &Receipt) {
|
||||
fixture.lock.npm[0]["build"] = json!(hash(&serde_json::to_vec(receipt).unwrap()));
|
||||
fixture.lock.builds = vec![serde_json::to_value(receipt).unwrap()];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_lifecycle_receipt_consumes_signed_inputs_without_network_or_hooks() {
|
||||
let mut fixture = Fixture::new();
|
||||
let receipt = prepare(&mut fixture, false);
|
||||
attach(&mut fixture, &receipt);
|
||||
fixture.documents.clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files["node_modules/built/generated.js"],
|
||||
b"module.exports=42"
|
||||
);
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
let text = fixture.lock.encode().unwrap();
|
||||
let reparsed = Lock::decode(&text).unwrap();
|
||||
fixture.lock = reparsed;
|
||||
fixture.install().unwrap();
|
||||
fixture.lock.builds[0]["frontend"] = json!("0".repeat(64));
|
||||
assert!(fixture.install().unwrap_err().contains("receipt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_native_receipt_accounts_for_all_addons_and_never_admits_failed_build() {
|
||||
let mut fixture = Fixture::new();
|
||||
let mut receipt = prepare(&mut fixture, true);
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(
|
||||
!fixture
|
||||
.install()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.any(|path| path.ends_with(".node"))
|
||||
);
|
||||
receipt.native_rejections.clear();
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("admission"));
|
||||
receipt
|
||||
.native_entries
|
||||
.insert("node_modules/built/foreign.node".into());
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("ELF"));
|
||||
receipt.native_entries.clear();
|
||||
receipt
|
||||
.native_rejections
|
||||
.insert("node_modules/built/new.node".into(), "unloadable".into());
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("admission"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_replay_rejects_malformed_binding_before_fetching_or_executing() {
|
||||
let mut fixture = Fixture::new();
|
||||
let mut receipt = prepare(&mut fixture, false);
|
||||
receipt.tools.runtime_sha256 = "0".repeat(64);
|
||||
attach(&mut fixture, &receipt);
|
||||
fixture.cache.borrow_mut().clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let error = dever::compiler::project::dependency::library::npm::restore(
|
||||
&fixture.lock,
|
||||
&fixture.graph(),
|
||||
fixture.target,
|
||||
&fixture.signed,
|
||||
&fixture,
|
||||
&fixture,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("binding differs"), "{error}");
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
assert!(fixture.cache.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_cached_receipt_rejects_tampering_but_needs_no_build_tools() {
|
||||
let mut fixture = Fixture::new();
|
||||
let receipt = prepare(&mut fixture, false);
|
||||
attach(&mut fixture, &receipt);
|
||||
let original = fixture.cache.borrow()[&receipt.output.sha256].clone();
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.insert(receipt.output.sha256.clone(), b"tampered".to_vec());
|
||||
assert!(fixture.install().unwrap_err().contains("digest"));
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.insert(receipt.output.sha256.clone(), original);
|
||||
std::fs::remove_file(fixture.directory.0.join(format!(
|
||||
"signed/versions/0.0.1/build/npm/{}/build.pack",
|
||||
fixture.target.name()
|
||||
)))
|
||||
.unwrap();
|
||||
fixture.install().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 只在开发 Pod 执行:读取已安装工具,组装临时签名输入;不安装或激活软件。
|
||||
|
||||
use super::{
|
||||
Fixture,
|
||||
fixture::{archive, hash, signed},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, path::Path, process::Command};
|
||||
|
||||
pub(super) fn prepare(fixture: &mut Fixture) {
|
||||
let target = fixture.target.name();
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut dependencies = BTreeMap::new();
|
||||
let node = Path::new("/usr/local/bin/node");
|
||||
let python = Path::new("/usr/bin/python3");
|
||||
let node_bytes = fs::read(node).expect("Node must already exist in the development Pod");
|
||||
let python_bytes = fs::read(python).expect("Python must already exist in the development Pod");
|
||||
libraries(node, &mut dependencies);
|
||||
libraries(python, &mut dependencies);
|
||||
let markers = json!({"implementation_name":"cpython","implementation_version":"3.13.0","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.13.0","python_version":"3.13","sys_platform":"linux"});
|
||||
let node_pack=archive(&BTreeMap::from([
|
||||
("bin/node".into(),node_bytes),
|
||||
("shared.txt".into(),b"shared runtime fixture".to_vec()),
|
||||
("dever-runtime.json".into(),serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"npm","target":target,"executable":"bin/node","arguments":[],"build":null,"python_wheel_tags":[],"python_extension_suffixes":[],"python_markers":null})).unwrap()),
|
||||
]));
|
||||
let python_pack=archive(&BTreeMap::from([
|
||||
("bin/python3".into(),python_bytes),
|
||||
("shared.txt".into(),b"shared runtime fixture".to_vec()),
|
||||
("dever-runtime.json".into(),serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":target,"executable":"bin/python3","arguments":["-I","-S","-B"],"build":null,"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".cpython-313-x86_64-linux-gnu.so"],"python_markers":markers})).unwrap()),
|
||||
]));
|
||||
fixture.metadata.pack.sha256 = hash(&node_pack);
|
||||
fixture.metadata.pack.version = "24.21.0".into();
|
||||
let python_identity = json!({"name":"python","version":"3.13.0","sha256":hash(&python_pack)});
|
||||
let mut tools = BTreeMap::new();
|
||||
let mut executables = Vec::new();
|
||||
for (name, path) in [
|
||||
("sh", "/bin/sh"),
|
||||
("cc", "/usr/bin/cc"),
|
||||
("c++", "/usr/bin/c++"),
|
||||
("as", "/usr/bin/as"),
|
||||
("ld", "/usr/bin/ld"),
|
||||
("ar", "/usr/bin/ar"),
|
||||
("ranlib", "/usr/bin/ranlib"),
|
||||
("make", "/usr/bin/make"),
|
||||
("cc1", "/usr/libexec/gcc/x86_64-linux-gnu/14/cc1"),
|
||||
("collect2", "/usr/libexec/gcc/x86_64-linux-gnu/14/collect2"),
|
||||
] {
|
||||
let path = Path::new(path);
|
||||
libraries(path, &mut dependencies);
|
||||
let destination = format!("rootfs/usr/bin/{name}");
|
||||
tools.insert(destination.clone(), fs::read(path).unwrap());
|
||||
executables.push(destination);
|
||||
}
|
||||
files(
|
||||
Path::new("/usr/local/lib/node_modules/npm"),
|
||||
"npm/frontend/node_modules/npm/",
|
||||
&mut tools,
|
||||
);
|
||||
tools.insert(
|
||||
"rootfs/usr/bin/fixture-node".into(),
|
||||
b"#!/usr/bin/env node\nprocess.stdout.write('signed script');\n".to_vec(),
|
||||
);
|
||||
executables.push("rootfs/usr/bin/fixture-node".into());
|
||||
tools.insert("dever-build.json".into(),serde_json::to_vec(&json!({"format":"dever-lib-build-v1","ecosystem":"npm","target":target,"runtime_sha256":fixture.metadata.pack.sha256,"python_runtime_sha256":python_identity["sha256"],"executables":executables})).unwrap());
|
||||
let tools_pack = archive(&tools);
|
||||
assets.insert(format!("build/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-build-v1","ecosystem":"npm","target":target,"runtime_sha256":fixture.metadata.pack.sha256,"python_runtime_sha256":python_identity["sha256"],"sha256":hash(&tools_pack)})).unwrap());
|
||||
assets.insert(format!("build/npm/{target}/build.pack"), tools_pack);
|
||||
assets.insert(format!("runtime/npm/{target}/runtime.pack"), node_pack);
|
||||
assets.insert(format!("runtime/pip/{target}/runtime.pack"), python_pack);
|
||||
assets.insert(format!("runtime/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":fixture.metadata.pack,"target":target,"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"}})).unwrap());
|
||||
assets.insert(format!("runtime/pip/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":python_identity,"target":target,"python_markers":markers,"python_wheel_tags":["py3-none-any"]}})).unwrap());
|
||||
for (name, bytes) in dependencies {
|
||||
assets.insert(format!("process-runtime/{target}/lib/{name}"), bytes);
|
||||
}
|
||||
fixture.signed = signed(&fixture.directory, &assets);
|
||||
}
|
||||
|
||||
fn files(root: &Path, prefix: &str, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(root).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
if kind.is_dir() {
|
||||
files(&entry.path(), &format!("{prefix}{name}/"), output);
|
||||
} else if kind.is_file() {
|
||||
output.insert(format!("{prefix}{name}"), fs::read(entry.path()).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn libraries(program: &Path, files: &mut BTreeMap<String, Vec<u8>>) {
|
||||
let output = Command::new("/usr/bin/ldd").arg(program).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ldd fixture input {}",
|
||||
program.display()
|
||||
);
|
||||
for path in String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.filter(|value| value.starts_with('/'))
|
||||
{
|
||||
let path = Path::new(path);
|
||||
let name = path.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
let bytes = fs::read(path).unwrap();
|
||||
if let Some(previous) = files.insert(name, bytes.clone()) {
|
||||
assert_eq!(previous, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! 从合法 Dever Port 到实际嵌入 ELF;无 fails 的声明必须在检查期拒绝。
|
||||
|
||||
use super::{Fixture, app, command, write};
|
||||
use dever::compiler::{
|
||||
build::{self, BuildTarget, Resource},
|
||||
project::Project,
|
||||
};
|
||||
use std::{fs, os::unix::fs::PermissionsExt, process::Command};
|
||||
|
||||
mod arguments;
|
||||
mod command;
|
||||
mod jobs;
|
||||
mod roots;
|
||||
|
||||
#[test]
|
||||
fn external_builds_and_executes_after_source_removal() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("port.native(input) + input"),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/port.dever",
|
||||
"native(input: Int) (value: Int)\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/adapter.dever",
|
||||
"external exec \"worker\" {}\n",
|
||||
);
|
||||
let port = root.join("module/demo/value/port.dever");
|
||||
assert!(
|
||||
Project::load(root)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("fails")
|
||||
);
|
||||
let source = fs::read_to_string(&port).unwrap().replace(
|
||||
"native(input: Int) (value: Int)",
|
||||
"native(input: Int) (value: Int) fails dever.api.Error",
|
||||
);
|
||||
fs::write(&port, source).unwrap();
|
||||
roots::prepare(root);
|
||||
jobs::prepare(root);
|
||||
let project = Project::load(root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let contract = program.external_workers().next().unwrap();
|
||||
let manifest: serde_json::Value = serde_json::from_str(&contract.contract).unwrap();
|
||||
let identity = "dever-component-v1;setting=none;operation=native;input=input:wire-v1:Output:Int;;output=value:wire-v1:Output:Int;;dever.api.Error.Invalid;dever.api.Error.Unauthorized;dever.api.Error.Forbidden;dever.api.Error.NotFound;dever.api.Error.Conflict;dever.api.Error.TooManyRequests;";
|
||||
let checksum = identity.bytes().fold(0xcbf29ce484222325u64, |hash, byte| {
|
||||
(hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
assert_eq!(manifest["schema"], format!("{checksum:016x}"));
|
||||
let worker = root.join("fixture");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/runtime/worker/fixture.rs");
|
||||
let result = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&worker)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-launch-v1","ecosystem":"exec","entry":contract.entry,"executable":contract.entry,"arguments":[],"working_directory":"module/demo/value"})).unwrap();
|
||||
let resources = [
|
||||
(contract.entry.clone(), fs::read(&worker).unwrap(), true),
|
||||
(
|
||||
format!("{}.dever-worker.json", contract.entry),
|
||||
launch,
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes, executable)| Resource {
|
||||
path,
|
||||
bytes,
|
||||
executable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let binary = build::build_with_resources(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&root.join("app"),
|
||||
BuildTarget::host().unwrap(),
|
||||
&resources,
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(root.join("module")).unwrap();
|
||||
fs::remove_file(worker).unwrap();
|
||||
fs::create_dir(root.join("data")).unwrap();
|
||||
fs::set_permissions(root.join("data"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
roots::commands(&binary);
|
||||
roots::http(&binary, root);
|
||||
jobs::run(&binary, root);
|
||||
}
|
||||
|
||||
fn command_cases(binary: &std::path::Path, entry: &str) {
|
||||
for _ in 0..2 {
|
||||
let result = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":50}\n"
|
||||
);
|
||||
}
|
||||
for (input, expected) in [
|
||||
(-1, "Worker output does not match checked Port"),
|
||||
(-2, "Worker returned an undeclared business error"),
|
||||
(-3, "Invalid"),
|
||||
] {
|
||||
let output = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, &format!("{{\"input\":{input}}}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains(expected),
|
||||
"input {input}, expected {expected}: {error}"
|
||||
);
|
||||
if input != -3 {
|
||||
assert!(error.contains("adapter.dever:"), "{error}");
|
||||
}
|
||||
}
|
||||
// Worker 已交付合法终态;根入口仍须等 shutdown,不能提前发布成功结果。
|
||||
for input in [-4, -5] {
|
||||
let output = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, &format!("{{\"input\":{input}}}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"input {input}: shutdown failure was discarded"
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"input {input}: CMD published output before cleanup"
|
||||
);
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains("invalid Worker shutdown acknowledgement"),
|
||||
"input {input}: {error}"
|
||||
);
|
||||
if input == -5 {
|
||||
assert!(
|
||||
error.contains("dever.api.Error.Invalid") && error.contains("cleanup:"),
|
||||
"business error and cleanup must both survive: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 原始 argv 不消费配置参数或命令名;非法 UTF-8 通过语言失败合同返回。
|
||||
|
||||
use super::{Fixture, Project, build, write};
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_arguments_preserve_all_values_and_reject_invalid_utf8() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
write(
|
||||
root,
|
||||
"arguments.dever",
|
||||
"arguments() (values: List<Text>) { values = dever.process.arguments() }\n",
|
||||
);
|
||||
let project = Project::load(&root.join("arguments.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"arguments",
|
||||
&root.join("arguments"),
|
||||
)
|
||||
.unwrap();
|
||||
for arguments in [
|
||||
vec![],
|
||||
vec![
|
||||
"--config",
|
||||
"配置.json",
|
||||
"demo.run",
|
||||
"",
|
||||
"a b",
|
||||
"$(touch never)",
|
||||
"\"quoted\"",
|
||||
"雪☃",
|
||||
],
|
||||
] {
|
||||
let result = Command::new(&binary)
|
||||
.args(&arguments)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
format!("{arguments:?}\n")
|
||||
);
|
||||
}
|
||||
let result = Command::new(binary)
|
||||
.arg(OsString::from_vec(vec![0xff, 0xfe]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("ArgumentsResult.Failed") && error.contains("lib/dever/process.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 真实 Dever command Port 经过检查、Rust 生成与原生执行,删除源码后仍独立运行。
|
||||
|
||||
use super::{BuildTarget, Fixture, Project, Resource, app, build, command, write};
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_command_preserves_binary_streams_argv_and_nonzero_exit() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let body = "output = port.execute([\"--echo\", \"a b\", \"$(touch never)\", \"雪☃\"], dever.bytes.fromInts([0, 255, 1]))\n answer = output.code == 7 and output.stdout == dever.bytes.fromInts([0, 255, 1]) and output.stderr == dever.bytes.fromText(\"a b\\n$(touch never)\\n雪☃\") and input == 8";
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("0")
|
||||
.replace("answer: Int", "answer: Bool")
|
||||
.replace("answer = 0", body),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/port.dever",
|
||||
"execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/adapter.dever",
|
||||
"external command \"command\" {}\n",
|
||||
);
|
||||
let project = Project::load(root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let external = program.external_workers().next().unwrap();
|
||||
let fixture = root.join("fixture");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/runtime/worker/fixture.rs");
|
||||
let compiled = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
compiled.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&compiled.stderr)
|
||||
);
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-command-launch-v1","ecosystem":"command","entry":external.entry,"executable":external.entry,"arguments":[],"working_directory":"module/demo/value"})).unwrap();
|
||||
let resources = [
|
||||
(external.entry.clone(), fs::read(&fixture).unwrap(), true),
|
||||
(
|
||||
format!("{}.dever-command.json", external.entry),
|
||||
launch,
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes, executable)| Resource {
|
||||
path,
|
||||
bytes,
|
||||
executable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let binary = build::build_with_resources(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&root.join("app"),
|
||||
BuildTarget::host().unwrap(),
|
||||
&resources,
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(root.join("module")).unwrap();
|
||||
fs::remove_file(fixture).unwrap();
|
||||
for _ in 0..2 {
|
||||
let result = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":true}\n"
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"adapter":{"demo.value":{"command":{"output_limit":1}}}}"#,
|
||||
);
|
||||
let failed = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
let error = String::from_utf8_lossy(&failed.stderr);
|
||||
assert!(
|
||||
error.contains("external command failed") && error.contains("adapter.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
let output = serde_json::from_slice::<serde_json::Value>(&failed.stdout);
|
||||
assert!(output.is_err() || output.unwrap()["code"] != 0);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 在同一 Worker 原生产物上验证持久 Job 的 worker-only 服务根。
|
||||
|
||||
use super::{roots::Server, write};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let app = root.join("module/demo/value/app.dever");
|
||||
let source = fs::read_to_string(&app).unwrap();
|
||||
fs::write(app, format!("{source}{APP}")).unwrap();
|
||||
let api = root.join("module/demo/value/api.dever");
|
||||
let source = fs::read_to_string(&api).unwrap();
|
||||
fs::write(api, format!("{source}\ncmd submit = app.submit\n")).unwrap();
|
||||
write(root, "module/demo/value/job.dever", JOB);
|
||||
}
|
||||
|
||||
pub(super) fn run(binary: &Path, root: &Path) {
|
||||
let config = root.join("setting-worker.json");
|
||||
fs::write(
|
||||
&config,
|
||||
json!({
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":4}},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"http":{"host":"worker-only-no-listener","port":0},
|
||||
"runtime":{"mode":"worker","shutdown_ms":1000},
|
||||
"job":{"workers":1,"poll_ms":10,"lease_ms":2000}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let database = root.join("data/worker.db");
|
||||
for input in [8, -4] {
|
||||
let submitted = Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.args([
|
||||
"demo.value.submit",
|
||||
&json!({"input":input,"key":format!("root-{input}")}).to_string(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
submitted.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&submitted.stderr)
|
||||
);
|
||||
let response: Value = serde_json::from_slice(&submitted.stdout).unwrap();
|
||||
let id = response["data"].as_str().unwrap();
|
||||
let pending = queue_row(&database, id);
|
||||
assert_eq!(pending["state"], "pending");
|
||||
assert_eq!(pending["attempt"], 0);
|
||||
assert_eq!(pending["execution"], json!({"kind":"System","tenant":null}));
|
||||
assert_eq!(pending["tenant"], 0);
|
||||
let log = root.join("job.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let completed = loop {
|
||||
assert!(
|
||||
server.0.try_wait().unwrap().is_none(),
|
||||
"worker root exited: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
let row = queue_row(&database, id);
|
||||
if row["state"] == "completed" {
|
||||
break row;
|
||||
}
|
||||
assert!(
|
||||
row["state"] == "pending" || row["state"] == "running",
|
||||
"{row}"
|
||||
);
|
||||
assert!(Instant::now() < deadline, "Job did not complete: {row}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
assert_eq!(completed["attempt"], 1);
|
||||
assert_eq!(completed["execution"], pending["execution"]);
|
||||
assert_eq!(completed["rows"], 0, "readonly Job changed Model data");
|
||||
server.shutdown(&log, input == -4);
|
||||
assert_eq!(queue_row(&database, id), completed);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_row(database: &Path, id: &str) -> Value {
|
||||
// 使用 dev 已有 sqlite3 模块只读检查本测试队列;不向私有队列表注入数据。
|
||||
let output = Command::new("python3")
|
||||
.args(["-c", r#"import json, pathlib, sqlite3, sys
|
||||
with sqlite3.connect(pathlib.Path(sys.argv[1]).as_uri() + '?mode=ro', uri=True) as database:
|
||||
row = database.execute('SELECT state,attempt,execution,execution_tenant FROM _dever_job WHERE id=?', (sys.argv[2],)).fetchone()
|
||||
rows = database.execute('SELECT COUNT(*) FROM demo_value_model').fetchone()[0]
|
||||
print(json.dumps(dict(state=row[0], attempt=row[1], execution=json.loads(row[2]), tenant=row[3], rows=rows)))
|
||||
"#])
|
||||
.arg(database)
|
||||
.arg(id)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
const APP: &str = r#"type Work { input: Int }
|
||||
|
||||
submit(input: Int, key: Text) (id: Id) {
|
||||
id = dever.job.enqueue(job.perform, Work { input = input }, key)
|
||||
}
|
||||
|
||||
perform(input: Work) () { answer = stored(input.input) }
|
||||
"#;
|
||||
|
||||
const JOB: &str = r#"database default
|
||||
|
||||
long job perform(input: app.Work) () retry(1) timeout(1000) {
|
||||
app.perform(input)
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 同一原生产物覆盖普通 CMD、只读数据库 CMD 和服务根的 Worker 收口。
|
||||
|
||||
use super::{app, command, command_cases, write};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let mut source = app("port.native(input) + input");
|
||||
source.push_str(
|
||||
r#"stored(input: Int) (answer: Int) {
|
||||
count = model.count()
|
||||
answer = count + port.native(input) + input
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/app.dever", source);
|
||||
let mut source = command();
|
||||
source.push_str(
|
||||
r#"cmd stored = app.stored
|
||||
|
||||
public get stored = app.stored
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", source);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/model.dever",
|
||||
r#"global type Value { label: Text }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn commands(binary: &Path) {
|
||||
for entry in ["demo.value.value", "demo.value.stored"] {
|
||||
command_cases(binary, entry);
|
||||
}
|
||||
assert!(binary.parent().unwrap().join("data/worker.db").is_file());
|
||||
}
|
||||
|
||||
pub(super) fn http(binary: &Path, root: &Path) {
|
||||
for bad_shutdown in [false, true] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let config = root.join("setting-http.json");
|
||||
fs::write(
|
||||
&config,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"http":{"host":"127.0.0.1","port":port},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"runtime":{"mode":"api","shutdown_ms":1000},
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let log = root.join("http.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.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: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
assert!(Instant::now() < deadline, "server did not bind");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(request(port, 8), (200, 50));
|
||||
let (status, identity) = request(port, 99);
|
||||
assert_eq!(status, 200);
|
||||
assert!(identity > 99, "Worker PID was not returned");
|
||||
assert_eq!(request(port, -3).0, 400);
|
||||
if bad_shutdown {
|
||||
// 当前 HTTP 调用先成功;停机错误只能在共享服务根结束时传播。
|
||||
assert_eq!(request(port, -4), (200, 38));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
assert_eq!(request(port, 99), (200, identity));
|
||||
}
|
||||
server.shutdown(&log, bad_shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn request(port: u16, input: i64) -> (u16, i64) {
|
||||
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
write!(socket, "GET /demo/value/stored?input={input} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\n\r\n").unwrap();
|
||||
let mut response = String::new();
|
||||
socket.read_to_string(&mut response).unwrap();
|
||||
let (head, body) = response.split_once("\r\n\r\n").unwrap();
|
||||
let status = head.split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
if status == 200 {
|
||||
assert_eq!(body["code"], 0);
|
||||
(status, body["data"].as_i64().unwrap())
|
||||
} else {
|
||||
assert_eq!(body["code"], status);
|
||||
assert!(body["data"].is_null());
|
||||
(status, 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Server(pub(super) Child);
|
||||
|
||||
impl Server {
|
||||
pub(super) fn shutdown(&mut self, log: &Path, bad_shutdown: bool) {
|
||||
rustix::process::kill_process(
|
||||
rustix::process::Pid::from_raw(self.0.id() as i32).unwrap(),
|
||||
rustix::process::Signal::TERM,
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let status = loop {
|
||||
if let Some(status) = self.0.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "service root did not finish");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let error = fs::read_to_string(log).unwrap();
|
||||
assert_eq!(status.success(), !bad_shutdown, "{error}");
|
||||
assert_eq!(
|
||||
error.contains("invalid Worker shutdown acknowledgement"),
|
||||
bad_shutdown,
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn executes_specialized_handlers_with_typed_failures_and_value_copies() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("handler.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert!(
|
||||
build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"runOnce",
|
||||
&directory.0.join("invalid")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(value)")),
|
||||
),
|
||||
(
|
||||
"argumentEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(value)")),
|
||||
),
|
||||
(
|
||||
"collectionEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(value)")),
|
||||
),
|
||||
(
|
||||
"faultEntry",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("handler.dever:{line}:{column}:")),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_signatures_are_checked_before_specialization() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("handler.dever");
|
||||
let source = std::fs::read_to_string(fixture().with_file_name("handler.dever")).unwrap();
|
||||
let changed = source.replacen(
|
||||
"route: handler(value: Int) (answer: Int)",
|
||||
"route: handler(value: Text) (answer: Int)",
|
||||
1,
|
||||
);
|
||||
std::fs::write(&path, changed).unwrap();
|
||||
assert!(
|
||||
Project::load(&path)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("C005")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_error_sets_preserve_nominal_identity_and_original_locations() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("failure_identity.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, failure) in [
|
||||
("verify", None),
|
||||
(
|
||||
"forwardFirst",
|
||||
Some(("FirstProblem.Failed", "FirstProblem.Failed(\"first\")")),
|
||||
),
|
||||
(
|
||||
"forwardSecond",
|
||||
Some(("SecondProblem.Failed", "SecondProblem.Failed(\"second\")")),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = failure {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(error.contains(message), "{error}");
|
||||
assert!(
|
||||
error.contains(&format!("failure_identity.dever:{line}:{column}:")),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[path = "http/live.rs"]
|
||||
mod live;
|
||||
#[path = "http/streaming.rs"]
|
||||
mod streaming;
|
||||
#[path = "http/websocket.rs"]
|
||||
mod websocket;
|
||||
|
||||
#[test]
|
||||
fn native_dever_http2_tls_and_shared_pool() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("https.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("https"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary)
|
||||
.current_dir(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/dever-tests/fixtures/tls"),
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_http_send_serve_cancel_and_typed_failure() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("http.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "cancelled", "invalid", "invalidLimits"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if entry.starts_with("invalid") {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains(if entry == "invalid" {
|
||||
"ResponseResult.Failed"
|
||||
} else {
|
||||
"invalid HTTP limits"
|
||||
}) && error.contains("http/call.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_http_handler_can_schedule_without_losing_request_ownership() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("http.dever")).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("http.dever"))
|
||||
.unwrap();
|
||||
*source = dever::syntax::source::Source::new(
|
||||
source.id,
|
||||
&source.path,
|
||||
source.text.replace(
|
||||
"dever.http.serve(route, listener, dever.http.defaultLimits())",
|
||||
"dever.http.serve(scheduledRoute, listener, dever.http.defaultLimits())",
|
||||
),
|
||||
);
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("scheduled"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
net::TcpStream,
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct Server(Child);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_stream_and_sse_deliver_before_handler_completes() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("live-http.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"serve",
|
||||
&directory.0.join("live"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.current_dir(&directory.0)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut stdout = BufReader::new(server.0.stdout.take().unwrap());
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).unwrap();
|
||||
let port = line.trim().parse::<u16>().unwrap();
|
||||
for path in ["/stream", "/sse"] {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"GET {path} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = BufReader::new(stream);
|
||||
line.clear();
|
||||
stream.read_line(&mut line).unwrap();
|
||||
assert!(line.starts_with("HTTP/1.1 200"), "{line}");
|
||||
loop {
|
||||
line.clear();
|
||||
assert!(stream.read_line(&mut line).unwrap() > 0);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let first = chunk(&mut stream);
|
||||
let received = Instant::now();
|
||||
if path == "/stream" {
|
||||
assert_eq!(first, b"first");
|
||||
assert_eq!(chunk(&mut stream), b"last");
|
||||
assert!(received.elapsed() >= Duration::from_millis(100));
|
||||
assert!(chunk(&mut stream).is_empty());
|
||||
} else {
|
||||
assert_eq!(
|
||||
first,
|
||||
"event: change\nid: \nretry: 10\ndata: 一\ndata: 二\n\n".as_bytes()
|
||||
);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
let data = chunk(&mut stream);
|
||||
if data.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert_eq!(data, b":\n\n");
|
||||
count += 1;
|
||||
}
|
||||
assert!(count >= 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(stream: &mut BufReader<TcpStream>) -> Vec<u8> {
|
||||
let mut line = String::new();
|
||||
assert!(stream.read_line(&mut line).unwrap() > 0);
|
||||
let size = usize::from_str_radix(line.trim(), 16).unwrap();
|
||||
let mut bytes = vec![0; size];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
let mut ending = [0; 2];
|
||||
stream.read_exact(&mut ending).unwrap();
|
||||
assert_eq!(ending, *b"\r\n");
|
||||
bytes
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
process::Command,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn native_http_open_stream_upload_early_close_and_cancel() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("http-stream.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("http-stream"),
|
||||
)
|
||||
.unwrap();
|
||||
let output_path = directory.0.join("stdout");
|
||||
let error_path = directory.0.join("stderr");
|
||||
let mut child = Command::new(binary)
|
||||
.stdout(std::fs::File::create(&output_path).unwrap())
|
||||
.stderr(std::fs::File::create(&error_path).unwrap())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(20) {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!(
|
||||
"streaming native test timed out: {}",
|
||||
std::fs::read_to_string(&error_path).unwrap()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert!(
|
||||
status.success(),
|
||||
"{}",
|
||||
std::fs::read_to_string(&error_path).unwrap()
|
||||
);
|
||||
assert_eq!(std::fs::read(output_path).unwrap(), b"true\n");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_websocket_original_client_server_stream_and_faults() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("websocket.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "opened", "invalid"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if entry == "invalid" {
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains("ConnectResult.Failed") && error.contains("websocket.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn ids_preserve_text_identity_aliases_and_map_semantics() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("id.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, failure) in [
|
||||
("verify", None),
|
||||
(
|
||||
"duplicateEntry",
|
||||
Some((
|
||||
"test/rust/fixtures/id.dever",
|
||||
"duplicate Map literal key",
|
||||
"{ dever.id.fromText(left)",
|
||||
)),
|
||||
),
|
||||
(
|
||||
"failureEntry",
|
||||
Some((
|
||||
"lib/dever/bytes.dever",
|
||||
"DecodeResult.Failed",
|
||||
"fail(DecodeResult.Failed(message))",
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((source, message, needle)) = failure {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|item| item.path.ends_with(source))
|
||||
.unwrap();
|
||||
let offset = source.text.find(needle).unwrap();
|
||||
let offset = if needle.starts_with("fail(") {
|
||||
offset + 5
|
||||
} else {
|
||||
offset
|
||||
};
|
||||
let (line, column) = source.position(offset);
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#[path = "inspection/documentation.rs"]
|
||||
mod documentation;
|
||||
|
||||
use super::Directory;
|
||||
use dever::compiler::project::Project;
|
||||
use std::process::Command;
|
||||
|
||||
#[path = "inspection/dispatch.rs"]
|
||||
mod dispatch;
|
||||
#[path = "inspection/flows.rs"]
|
||||
mod flows;
|
||||
#[path = "inspection/links.rs"]
|
||||
mod links;
|
||||
|
||||
#[test]
|
||||
fn inspection_preserves_multiple_callers_sites_and_utf16_columns() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("main.dever");
|
||||
let source = "label(name: Text) (text: Text) {\n text = name\n}\n\nfirst(name: Text) (text: Text) {\n text = \"😀\" + label(name) + label(\"另\")\n}\n\nsecond(name: Text) (text: Text) {\n text = label(name)\n}\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let project = Project::load(&path).unwrap();
|
||||
let mut index = project.inspect().unwrap();
|
||||
index.select("main.label", 1).unwrap();
|
||||
let label = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "main.label")
|
||||
.unwrap();
|
||||
let calls: Vec<_> = index
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|call| call.callee == label.id)
|
||||
.collect();
|
||||
assert_eq!(calls.len(), 3);
|
||||
assert_eq!(
|
||||
calls
|
||||
.iter()
|
||||
.map(|call| call.caller)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let line = source.lines().nth(5).unwrap();
|
||||
let first = calls.iter().find(|call| call.site.start.line == 5).unwrap();
|
||||
assert_eq!(
|
||||
first.site.start.character,
|
||||
line[..line.find("label").unwrap()].encode_utf16().count()
|
||||
);
|
||||
assert_eq!(
|
||||
first.site.end.character - first.site.start.character,
|
||||
"label".len()
|
||||
);
|
||||
assert_eq!(index.files.len(), 1);
|
||||
assert!(index.select("does.not.exist", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_retains_clauses_and_distinguishes_callback_and_task_dispatch() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("main.dever");
|
||||
std::fs::write(&path, "label(value: true) (text: Text) {\n text = \"yes\"\n}\nlabel(value: other) (text: Text) {\n text = \"no\"\n}\nmain() (texts: List<Text>, text: Text) {\n texts = each(label, [true, false])\n task = run(label(true))\n text = wait(task)\n}\n").unwrap();
|
||||
let index = Project::load(&path).unwrap().inspect().unwrap();
|
||||
let label = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "main.label")
|
||||
.unwrap();
|
||||
assert_eq!(label.declarations.len(), 2);
|
||||
let calls: Vec<_> = index
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|call| call.callee == label.id)
|
||||
.collect();
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert!(calls.iter().any(|call| call.kind == "callback"));
|
||||
assert!(calls.iter().any(|call| call.kind == "run"));
|
||||
for call in calls {
|
||||
assert_eq!(call.site.end.character - call.site.start.character, 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_cli_resolves_real_command_bindings_and_reports_failures() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args([
|
||||
"inspect",
|
||||
root.to_str().unwrap(),
|
||||
"--symbol",
|
||||
"hello.greeting.greet",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let index: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(index["calls"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(index["calls"][0]["kind"], "command");
|
||||
let failed = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args(["inspect", root.to_str().unwrap(), "--symbol", "missing"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(failed.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("unknown symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_does_not_change_generated_rust_or_accept_invalid_source() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("main.dever");
|
||||
std::fs::write(&path, "main() (value: Int) {\n value = 42\n}\n").unwrap();
|
||||
let project = Project::load(&path).unwrap();
|
||||
let emit = || {
|
||||
let program = project.check().unwrap();
|
||||
dever::compiler::emit::generate(
|
||||
&program,
|
||||
&project.sources,
|
||||
&std::collections::BTreeSet::from([0]),
|
||||
)
|
||||
};
|
||||
let before = emit();
|
||||
project.inspect().unwrap();
|
||||
assert_eq!(emit(), before);
|
||||
std::fs::write(&path, "main() (value: Int) {\n value = unknown()\n}\n").unwrap();
|
||||
assert!(Project::load(&path).unwrap().inspect().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_keeps_job_dispatch_and_static_handler_specializations() {
|
||||
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures");
|
||||
let jobs = Project::load(&fixtures.join("job"))
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap();
|
||||
let publish = jobs
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "demo.queue.job.publish")
|
||||
.unwrap();
|
||||
assert!(
|
||||
jobs.calls
|
||||
.iter()
|
||||
.any(|call| call.callee == publish.id && call.kind == "enqueue")
|
||||
);
|
||||
assert!(
|
||||
jobs.calls
|
||||
.iter()
|
||||
.any(|call| call.caller == publish.id && call.kind == "call")
|
||||
);
|
||||
let handlers = Project::load(&fixtures.join("handler.dever"))
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap();
|
||||
let apply = handlers
|
||||
.symbols
|
||||
.iter()
|
||||
.filter(|symbol| symbol.name == "main.apply")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(apply.len(), 1);
|
||||
let targets: std::collections::BTreeSet<_> = handlers
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|call| call.caller == apply[0].id)
|
||||
.map(|call| handlers.symbols[call.callee].name.as_str())
|
||||
.collect();
|
||||
assert!(targets.contains("main.increment"));
|
||||
assert!(targets.contains("main.decrement"));
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use super::Project;
|
||||
use std::{collections::BTreeSet, path::PathBuf};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/fixtures")
|
||||
.join(name)
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn charts(page: &str) -> impl Iterator<Item = &str> {
|
||||
page.split("```mermaid\n")
|
||||
.skip(1)
|
||||
.map(|chart| chart.split("```").next().unwrap())
|
||||
}
|
||||
|
||||
fn node(chart: &str, text: &str) -> String {
|
||||
chart
|
||||
.lines()
|
||||
.find(|line| line.contains(text) && !line.contains(" -->") && !line.contains("click "))
|
||||
.unwrap_or_else(|| panic!("missing {text}:\n{chart}"))
|
||||
.trim()
|
||||
.split(['[', '{', '('])
|
||||
.next()
|
||||
.unwrap()
|
||||
.into()
|
||||
}
|
||||
|
||||
// 按图的边检查可达性,不能把节点声明顺序误当执行顺序。
|
||||
fn reaches(chart: &str, from: &str, to: &str) -> bool {
|
||||
let mut pending = vec![from.to_owned()];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(current) = pending.pop() {
|
||||
if current == to {
|
||||
return true;
|
||||
}
|
||||
if !seen.insert(current.clone()) {
|
||||
continue;
|
||||
}
|
||||
for line in chart.lines() {
|
||||
if line.trim().starts_with(&format!("{current} -->")) {
|
||||
pending.push(line.split_whitespace().last().unwrap().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_flows_follow_authentication_permission_decode_call_and_transaction_order() {
|
||||
let root = fixture("auth");
|
||||
let project = Project::load(&root).unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/account/link.md")];
|
||||
let program = project.check().unwrap();
|
||||
let rust = dever::compiler::emit::application::generate(&program);
|
||||
for (position, entry) in program.http.iter().enumerate() {
|
||||
if !entry.path.starts_with("/demo/account/") {
|
||||
continue;
|
||||
}
|
||||
let chart = charts(page)
|
||||
.find(|chart| chart.contains(&format!("路由 {} {},", entry.method, entry.path)))
|
||||
.unwrap();
|
||||
let decode = node(chart, "解码具体参数");
|
||||
let call = node(
|
||||
chart,
|
||||
&format!(
|
||||
"[[\"demo.account.{}\"",
|
||||
program.functions[entry.function].signature.name
|
||||
),
|
||||
);
|
||||
assert!(reaches(chart, &decode, &call));
|
||||
assert!(!reaches(chart, &node(chart, "失败:保留 HTTP"), &call));
|
||||
assert!(chart.contains("demo.account.verify"));
|
||||
let emitted = rust
|
||||
.split(&format!("async fn api{position}("))
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("struct HttpArgs")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
emitted.find("authentication.prepare_at").unwrap()
|
||||
< emitted.find("let args: HttpArgs").unwrap()
|
||||
);
|
||||
match entry.access {
|
||||
dever::compiler::model::auth::Access::Protected => {
|
||||
let permission = node(chart, "权限检查:");
|
||||
assert!(reaches(chart, &permission, &decode));
|
||||
assert!(!reaches(chart, &decode, &permission));
|
||||
assert!(
|
||||
emitted.find("authorization::authorize_entry").unwrap()
|
||||
< emitted.find("let args: HttpArgs").unwrap()
|
||||
);
|
||||
}
|
||||
dever::compiler::model::auth::Access::Public => {
|
||||
assert!(chart.contains("public 忽略自动 Cookie"));
|
||||
assert!(chart.contains("没有显式凭据:匿名"));
|
||||
assert!(!chart.contains("权限检查:"));
|
||||
}
|
||||
dever::compiler::model::auth::Access::Authenticated => {
|
||||
assert!(!chart.contains("没有显式凭据:匿名"));
|
||||
assert!(!chart.contains("权限检查:"));
|
||||
}
|
||||
}
|
||||
let write = program.functions[entry.function]
|
||||
.signature
|
||||
.effects
|
||||
.database_write
|
||||
&& !entry.long;
|
||||
assert_eq!(chart.contains("进入写入口事务"), write);
|
||||
if write {
|
||||
assert!(reaches(chart, &call, &node(chart, "提交入口拥有的事务")));
|
||||
assert!(reaches(
|
||||
chart,
|
||||
&node(chart, "失败:保留 HTTP"),
|
||||
&node(chart, "回滚入口拥有的事务")
|
||||
));
|
||||
}
|
||||
}
|
||||
let anchors: Vec<_> = page
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("<a id="))
|
||||
.collect();
|
||||
assert_eq!(anchors.len(), anchors.iter().collect::<BTreeSet<_>>().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_flows_link_actual_field_functions_owner_model_and_failure_exits() {
|
||||
let root = fixture("auth");
|
||||
let project = Project::load(&root).unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/account/link.md")];
|
||||
let chart = charts(page)
|
||||
.find(|chart| chart.contains("自动 REST /demo/account/admin/profile"))
|
||||
.unwrap();
|
||||
for label in [
|
||||
"405 + Allow",
|
||||
"Owner",
|
||||
"COUNT + 分页 SELECT",
|
||||
"SELECT 单条",
|
||||
"INSERT RETURNING",
|
||||
"UPDATE RETURNING",
|
||||
"DELETE",
|
||||
"提交入口拥有的事务",
|
||||
"回滚入口拥有的事务",
|
||||
] {
|
||||
assert!(chart.contains(label), "{label}");
|
||||
}
|
||||
let create = node(chart, "model.profile.#rest_create_note");
|
||||
let replace = node(chart, "model.profile.#rest_replace_note");
|
||||
assert!(!reaches(chart, &create, &replace));
|
||||
assert!(!reaches(chart, &replace, &create));
|
||||
assert!(reaches(chart, &create, &node(chart, "INSERT RETURNING")));
|
||||
assert!(!reaches(chart, &node(chart, "失败:保留 HTTP"), &create));
|
||||
let normalize = node(chart, "规范化字段 name:去首尾空白");
|
||||
let validate = chart
|
||||
.lines()
|
||||
.filter(|line| line.contains("检查字段 name 的长度 / 有限数合同"))
|
||||
.map(|line| line.trim().split('[').next().unwrap())
|
||||
.find(|node| reaches(chart, &normalize, node))
|
||||
.unwrap();
|
||||
assert!(reaches(chart, validate, &node(chart, "INSERT RETURNING")));
|
||||
assert!(!reaches(chart, &normalize, &node(chart, "组装分页查询")));
|
||||
assert!(chart.contains("model/profile.dever#L"));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "rest-field"));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "rest-model"));
|
||||
for call in index.calls.iter().filter(|call| call.kind == "rest-field") {
|
||||
let symbol = &index.symbols[call.callee];
|
||||
assert!(!symbol.declarations.is_empty());
|
||||
assert!(
|
||||
index.calls.iter().any(|next| next.caller == symbol.id),
|
||||
"missing field expression calls: {}",
|
||||
symbol.name
|
||||
);
|
||||
}
|
||||
let program = project.check().unwrap();
|
||||
let rust = dever::compiler::emit::application::generate(&program);
|
||||
let rest = rust
|
||||
.split("async fn rest0(")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("struct RestPage1")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
rest.find("authorization::authorize_entry").unwrap()
|
||||
< rest.find("let executor = database_context.on").unwrap()
|
||||
);
|
||||
assert!(rest.contains("database_context.finish(scope,owner,outcome.is_ok())"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_flows_have_exclusive_real_targets_and_setting_source_locations() {
|
||||
let root = fixture("adapters");
|
||||
let index = Project::load(&root).unwrap().inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/value/link.md")];
|
||||
let chart = charts(page)
|
||||
.find(|chart| {
|
||||
chart.contains("demo.value.adapter.local.port.calculate") && chart.contains("启动配置")
|
||||
})
|
||||
.unwrap();
|
||||
let local = node(chart, "demo.value.adapter.local.port.calculate");
|
||||
let remote = node(chart, "demo.value.adapter.remote.port.calculate");
|
||||
assert!(!reaches(chart, &local, &remote));
|
||||
assert!(!reaches(chart, &remote, &local));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "adapter"
|
||||
&& index.symbols[call.callee].name == "demo.value.adapter.remote.port.calculate"));
|
||||
assert!(page.contains("配置字段(不显示值)"));
|
||||
assert!(page.contains("adapter/local.dever#L"));
|
||||
assert!(!page.contains("未展开"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_adapters_end_at_declared_command_or_worker_boundaries() {
|
||||
let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
for (name, label) in [("command", "外部命令边界"), ("external-lib", "Worker 边界")] {
|
||||
let root = examples.join(name);
|
||||
let pages = Project::load(&root)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&root)
|
||||
.unwrap();
|
||||
assert!(pages.values().any(|page| page.contains(label)));
|
||||
assert!(pages.values().all(|page| !page.contains("未展开")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn documentation_heading_positions_preserve_unicode_fences_and_crlf() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/doc/greeting.md");
|
||||
let text = "# 中文说明\r\n\r\n```markdown\r\n## not.a.symbol\r\n```\r\n\r\n## hello.greeting.greet\r\n\r\n真实说明。\r\n";
|
||||
std::fs::write(path, text).unwrap();
|
||||
let index = Project::load(&root).unwrap().inspect().unwrap();
|
||||
assert!(index.documentation_errors.is_empty());
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|description| description.symbol == "hello.greeting.greet")
|
||||
.unwrap();
|
||||
assert_eq!(description.line, 6);
|
||||
assert_eq!(description.markdown, "真实说明。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documentation_binds_clauses_without_entering_program_semantics() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/doc/greeting.md");
|
||||
let original = std::fs::read_to_string(&path).unwrap();
|
||||
let project = Project::load(&root).unwrap();
|
||||
let before = project.api_snapshot().unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
assert!(
|
||||
index.documentation_errors.is_empty(),
|
||||
"{:?}",
|
||||
index.documentation_errors
|
||||
);
|
||||
let symbol = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "hello.greeting.greet")
|
||||
.unwrap();
|
||||
assert_eq!(symbol.signatures, ["greet(name: Text) (message: Text)"]);
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|doc| doc.symbol == symbol.name)
|
||||
.unwrap();
|
||||
assert!(description.markdown.contains("按原样保留"));
|
||||
assert_eq!(
|
||||
index.documentation_files[description.file].path,
|
||||
path.to_string_lossy()
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{original}\n```markdown\n## not.a.symbol\n```\n\n> ## quoted.symbol\n\n- Nested\n\n ## nested.symbol\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(project.inspect().unwrap().documentation_errors.is_empty());
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{original}\n## hello.greeting.missing\n\n过期说明。\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let changed = project.inspect().unwrap();
|
||||
assert!(
|
||||
changed
|
||||
.documentation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("hello.greeting.missing"))
|
||||
);
|
||||
assert_eq!(project.api_snapshot().unwrap(), before);
|
||||
project.check().unwrap();
|
||||
|
||||
std::fs::write(&path, original).unwrap();
|
||||
std::fs::write(
|
||||
root.join("module/hello/greeting/doc/duplicate.md"),
|
||||
"## hello.greeting.greet\n\n重复。\n",
|
||||
)
|
||||
.unwrap();
|
||||
let duplicate = project.inspect().unwrap();
|
||||
assert!(
|
||||
duplicate
|
||||
.documentation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("duplicate documentation"))
|
||||
);
|
||||
assert!(
|
||||
!duplicate
|
||||
.documentation
|
||||
.iter()
|
||||
.any(|doc| doc.symbol == "hello.greeting.greet")
|
||||
);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args(["inspect", root.to_str().unwrap(), "--links", "--check"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("duplicate documentation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_source_format_is_rejected_instead_of_silently_skipped() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/domain.dever.md");
|
||||
std::fs::write(&path, "# Old source\n\n```dever\nunused() () {}\n```\n").unwrap();
|
||||
assert!(Project::load(&root).err().unwrap().contains("doc/"));
|
||||
assert!(Project::load(&path).err().unwrap().contains("project root"));
|
||||
assert!(
|
||||
dever::compiler::format::project(&root, true)
|
||||
.unwrap_err()
|
||||
.contains("doc/")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::{Directory, Project};
|
||||
use std::fs;
|
||||
|
||||
fn page(source: &str) -> String {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("flow project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
fs::write(root.join("module/hello/greeting/app.dever"), source).unwrap();
|
||||
let pages = Project::load(&root)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&root)
|
||||
.unwrap();
|
||||
pages[&root.join("module/hello/greeting/link.md")].clone()
|
||||
}
|
||||
|
||||
fn chart<'a>(page: &'a str, name: &str) -> &'a str {
|
||||
page.split(&format!("### hello.greeting.{name}\n"))
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("```mermaid\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("```")
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn node(chart: &str, label: &str) -> String {
|
||||
chart
|
||||
.lines()
|
||||
.find(|line| line.contains(&format!("\"{label}\"")))
|
||||
.unwrap_or_else(|| panic!("missing node {label}:\n{chart}"))
|
||||
.trim()
|
||||
.split(['[', '{', '('])
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_evaluate_nested_arguments_before_calls_and_keep_clause_branches_separate() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) { message = choose(present(name), decorate(clean(name))) }\npresent(name: Text) (value: Bool) { value = name != \"\" }\nclean(name: Text) (value: Text) { value = name + \"!\" }\ndecorate(name: Text) (value: Text) { value = \"[\" + name + \"]\" }\nchoose(value: true, name: Text) (message: Text) { message = name }\nchoose(value: other, name: Text) (message: Text) { message = \"empty\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let calls: Vec<_> = ["present", "clean", "decorate", "choose"]
|
||||
.iter()
|
||||
.map(|name| node(main, &format!("hello.greeting.{name}")))
|
||||
.collect();
|
||||
for pair in calls.windows(2) {
|
||||
assert!(main.contains(&format!("{} --> {}", pair[0], pair[1])));
|
||||
}
|
||||
let branch = chart(&page, "choose");
|
||||
assert!(branch.contains("按实参匹配分句(互斥)"));
|
||||
let positive = node(branch, "value: true");
|
||||
let negative = node(branch, "value: other");
|
||||
assert!(!branch.contains(&format!("{positive} --> {negative}")));
|
||||
assert!(!branch.contains(&format!("{negative} --> {positive}")));
|
||||
assert!(page.contains("[hello.greeting.choose](link.md#flow-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_short_circuit_and_task_launch_do_not_turn_into_unconditional_calls() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n enabled = present(name) and allowed(name)\n task = run(label(enabled))\n message = wait(task)\n}\npresent(name: Text) (value: Bool) { value = name != \"\" }\nallowed(name: Text) (value: Bool) { value = name != \"blocked\" }\nlabel(value: true) (message: Text) { message = \"yes\" }\nlabel(value: other) (message: Text) { message = \"no\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let condition = node(main, "present(name)");
|
||||
let evaluate = node(main, "求右侧值");
|
||||
let allowed = node(main, "hello.greeting.allowed");
|
||||
assert!(main.contains(&format!("{condition} -->|\"true\"| {evaluate}")));
|
||||
assert!(main.contains(&format!("{evaluate} --> {allowed}")));
|
||||
assert!(main.contains("false:跳过右侧"));
|
||||
let launch = node(main, "启动任务");
|
||||
let child = node(main, "hello.greeting.label");
|
||||
assert!(main.contains(&format!("{launch} -.->|\"子任务独立执行\"| {child}")));
|
||||
assert!(main.contains("返回 Task,父流程继续"));
|
||||
assert!(main.contains("等待任务结果:task"));
|
||||
assert!(!main.contains(&format!("{launch} --> {child}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_collection_has_empty_path_and_repeated_callback_instead_of_one_call() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n values = each(label, [true, false])\n message = name + \"!\"\n}\nlabel(value: true) (text: Text) { text = \"yes\" }\nlabel(value: other) (text: Text) { text = \"no\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let condition = node(main, "还有元素?");
|
||||
let callback = node(main, "hello.greeting.label");
|
||||
assert!(main.contains("否(包括空集合)"));
|
||||
assert!(main.contains(&format!("{callback} -->|\"下一元素\"| {condition}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_timeout_only_calls_fallback_after_cancellation_and_explicit_stop_waits() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n abandoned = run(pause())\n stop(abandoned)\n message = timeout(run(work(name)), 1, fallback)\n}\npause() () { dever.task.sleep(1) }\nwork(name: Text) (message: Text) {\n dever.task.sleep(1)\n message = name\n}\nfallback() (message: Text) { message = \"expired\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let wait = node(main, "限时等待任务");
|
||||
let cancel = node(main, "取消并等待任务退出");
|
||||
let fallback = node(main, "hello.greeting.fallback");
|
||||
assert!(main.contains(&format!("{wait} -->|\"超时\"| {cancel}")));
|
||||
assert!(main.contains(&format!("{cancel} --> {fallback}")));
|
||||
assert!(!main.contains(&format!("{wait} --> {fallback}")));
|
||||
assert!(main.contains("请求取消并等待退出:abandoned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_capture_distinguishes_business_failure_from_fault_and_fail_has_no_success_exit() {
|
||||
let page = page(
|
||||
"type Outcome { Done(value: Text)\n error Failed(message: Text) }\ngreet(name: Text) (message: Bool) recover(\"observe captured failure\") {\n outcome = capture(name)\n message = outcome == Outcome.Failed(name)\n}\ncapture(name: Text) (message: Outcome) { message = result(bad(name)) }\nbad(name: Text) (value: Text) { fail(Outcome.Failed(name)) }\n",
|
||||
);
|
||||
let main = chart(&page, "capture");
|
||||
assert!(main.contains("声明的业务失败"));
|
||||
assert!(main.contains("Fault(不转换为业务失败)"));
|
||||
let failed = chart(&page, "bad");
|
||||
assert!(failed.contains("传播错误"));
|
||||
assert!(!failed.contains("\"返回\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_and_navigation_keep_direct_typed_decode_capture() {
|
||||
let source = "type Outcome { Done(value: Text)\n error Failed(cause: dever.json.JsonResult) }\ngreet(name: Text) (message: Text) { payload: Json = dever.json.encode(name)\n value: Outcome = result(dever.json.decode(payload))\n message = render(value) }\nrender(value: Outcome.Done(text)) (message: Text) recover(\"显示无效输入\") { message = text }\nrender(value: Outcome.Failed(cause)) (message: Text) recover(\"显示无效输入\") { message = \"invalid\" }\n";
|
||||
let page = page(source);
|
||||
let flow = chart(&page, "greet");
|
||||
assert!(flow.contains("类型化 JSON 解码"));
|
||||
assert!(flow.contains("声明的业务失败"));
|
||||
let directory = Directory::new();
|
||||
let file = directory.0.join("main.dever");
|
||||
fs::write(&file, source).unwrap();
|
||||
let index = Project::load(&file).unwrap().inspect().unwrap();
|
||||
let decode = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "dever.json.decode")
|
||||
.unwrap();
|
||||
let calls = index
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|call| call.callee == decode.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].kind, "operation");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::{Command, Directory, Project};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn inspect(root: &Path, check: bool) -> std::process::Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_dever"));
|
||||
command.arg("inspect").arg(root).arg("--links");
|
||||
if check {
|
||||
command.arg("--check");
|
||||
}
|
||||
command.output().unwrap()
|
||||
}
|
||||
|
||||
fn success(output: std::process::Output) -> String {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_check_is_read_only_and_generation_is_stable() {
|
||||
{
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project with spaces");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let link = root.join("module/hello/greeting/link.md");
|
||||
fs::remove_file(&link).unwrap();
|
||||
let missing = inspect(&root, true);
|
||||
assert_eq!(missing.status.code(), Some(1));
|
||||
assert!(!link.exists());
|
||||
assert!(success(inspect(&root, false)).contains("1 changed"));
|
||||
let first = fs::read_to_string(&link).unwrap();
|
||||
assert!(first.contains("CMD [hello.greeting.api.greet]"));
|
||||
assert!(!first.contains(root.to_str().unwrap()));
|
||||
assert!(first.contains("app.dever#L"));
|
||||
let modified = fs::metadata(&link).unwrap().modified().unwrap();
|
||||
assert!(success(inspect(&root, false)).contains("0 changed"));
|
||||
assert_eq!(fs::metadata(&link).unwrap().modified().unwrap(), modified);
|
||||
success(inspect(&root, true));
|
||||
|
||||
let source = root.join("module/hello/greeting/app.dever");
|
||||
let text = fs::read_to_string(&source).unwrap();
|
||||
fs::write(&source, text.replace("Hello, ", "Welcome, ")).unwrap();
|
||||
assert_eq!(inspect(&root, true).status.code(), Some(1));
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), first);
|
||||
success(inspect(&root, false));
|
||||
assert_ne!(fs::read_to_string(&link).unwrap(), first);
|
||||
success(inspect(&root, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_keep_every_cross_domain_call_site_and_refresh_both_ends() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
for domain in ["checkout", "account"] {
|
||||
let owner = root.join("module/shop").join(domain);
|
||||
fs::create_dir_all(&owner).unwrap();
|
||||
fs::write(owner.join("app.dever"), "welcome(name: Text) (message: Text) {\n message = hello.greeting.greet(name) + hello.greeting.greet(\"other\")\n}\n").unwrap();
|
||||
fs::write(owner.join("api.dever"), "cmd welcome = app.welcome\n").unwrap();
|
||||
}
|
||||
let unrelated = root.join("module/empty/types");
|
||||
fs::create_dir_all(&unrelated).unwrap();
|
||||
fs::write(
|
||||
unrelated.join("domain.dever"),
|
||||
"type Label { value: Text }\n",
|
||||
)
|
||||
.unwrap();
|
||||
success(inspect(&root, false));
|
||||
let incoming = root.join("module/hello/greeting/link.md");
|
||||
let first = fs::read_to_string(&incoming).unwrap();
|
||||
let incoming_calls = |text: &str| {
|
||||
text.split("## 外部调用方\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("## 能力与内部调用\n")
|
||||
.next()
|
||||
.unwrap()
|
||||
.matches("| [hello.greeting.greet]")
|
||||
.count()
|
||||
};
|
||||
assert_eq!(incoming_calls(&first), 4);
|
||||
assert!(first.contains("shop.checkout.welcome"));
|
||||
assert!(first.contains("shop.account.welcome"));
|
||||
assert!(first.contains("../../shop/checkout/app.dever#L2"));
|
||||
let outgoing = fs::read_to_string(root.join("module/shop/checkout/link.md")).unwrap();
|
||||
assert!(outgoing.contains("跨领域 [hello.greeting](../../hello/greeting/link.md)"));
|
||||
let untouched = unrelated.join("link.md");
|
||||
let modified = fs::metadata(&untouched).unwrap().modified().unwrap();
|
||||
fs::write(
|
||||
root.join("module/shop/checkout/app.dever"),
|
||||
"welcome(name: Text) (message: Text) {\n message = hello.greeting.greet(name) + \"!\"\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let stale = inspect(&root, true);
|
||||
assert_eq!(stale.status.code(), Some(1));
|
||||
let report = String::from_utf8(stale.stdout).unwrap();
|
||||
assert!(report.contains("stale module/hello/greeting/link.md"));
|
||||
assert!(report.contains("stale module/shop/checkout/link.md"));
|
||||
assert!(!report.contains("stale module/empty/types/link.md"));
|
||||
success(inspect(&root, false));
|
||||
assert_eq!(incoming_calls(&fs::read_to_string(&incoming).unwrap()), 3);
|
||||
assert_eq!(
|
||||
fs::metadata(&untouched).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
fs::remove_file(root.join("module/shop/account/app.dever")).unwrap();
|
||||
fs::remove_file(root.join("module/shop/account/api.dever")).unwrap();
|
||||
let removed = root.join("module/shop/account/link.md");
|
||||
assert_eq!(inspect(&root, true).status.code(), Some(1));
|
||||
assert!(removed.exists());
|
||||
assert!(success(inspect(&root, false)).contains("removed module/shop/account/link.md"));
|
||||
assert!(!removed.exists());
|
||||
assert!(
|
||||
!fs::read_to_string(&incoming)
|
||||
.unwrap()
|
||||
.contains("shop.account.welcome")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_preserve_handwritten_files_and_reject_invalid_source_before_writing() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let link = root.join("module/hello/greeting/link.md");
|
||||
fs::write(&link, "# 手工说明\n").unwrap();
|
||||
let refused = inspect(&root, false);
|
||||
assert!(!refused.status.success());
|
||||
assert!(String::from_utf8_lossy(&refused.stderr).contains("handwritten"));
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), "# 手工说明\n");
|
||||
fs::remove_file(&link).unwrap();
|
||||
success(inspect(&root, false));
|
||||
let first = fs::read_to_string(&link).unwrap();
|
||||
let source = root.join("module/hello/greeting/app.dever");
|
||||
let text = fs::read_to_string(&source).unwrap();
|
||||
fs::write(&source, text.replace("\"Hello, \" + name", "unknown(name)")).unwrap();
|
||||
assert!(!inspect(&root, false).status.success());
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_mark_api_job_and_dispatch_without_inventing_execution_order() {
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/fixtures")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let job = fixtures.join("job");
|
||||
let pages = Project::load(&job)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&job)
|
||||
.unwrap();
|
||||
let text = &pages[&job.join("module/demo/queue/link.md")];
|
||||
assert!(text.contains("Job [demo.queue.job.publish]"));
|
||||
assert!(text.contains("| 入队 |"));
|
||||
assert!(text.contains("按求值顺序"));
|
||||
assert!(text.contains("不是某次运行的日志"));
|
||||
assert!(text.contains("flowchart TD"));
|
||||
assert!(text.contains("由 Worker 后续调度,不等待执行"));
|
||||
let http = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples/http-rest")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let pages = Project::load(&http)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&http)
|
||||
.unwrap();
|
||||
assert!(pages.values().any(|text| text.contains("- API [")));
|
||||
assert!(pages.values().all(|text| text.contains("自动 REST")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_reject_conflicting_options_and_non_project_sources() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.0.join("main.dever");
|
||||
fs::write(&source, "main() (value: Int) { value = 1 }\n").unwrap();
|
||||
let invalid = inspect(&source, false);
|
||||
assert!(!invalid.status.success());
|
||||
assert!(String::from_utf8_lossy(&invalid.stderr).contains("project root"));
|
||||
for flags in [
|
||||
vec!["--check"],
|
||||
vec!["--links", "--json"],
|
||||
vec!["--links", "--symbol", "main.main"],
|
||||
vec!["--links", "--links"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("inspect")
|
||||
.arg(&source)
|
||||
.args(flags)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
use dever_runtime::io::File;
|
||||
|
||||
mod stream;
|
||||
|
||||
#[test]
|
||||
fn file_aliases_share_cursor_and_close_without_truncating_existing_data() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("file");
|
||||
let name = path.to_str().unwrap();
|
||||
let file = File::create(name).unwrap();
|
||||
file.write(b"abcde").unwrap();
|
||||
assert!(File::create(name).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"abcde");
|
||||
file.close().unwrap();
|
||||
let file = File::open(name).unwrap();
|
||||
let alias = file.clone();
|
||||
assert_eq!(file.read(2).unwrap().unwrap(), b"ab");
|
||||
assert_eq!(alias.read(2).unwrap().unwrap(), b"cd");
|
||||
for limit in [0, -1, i64::MAX] {
|
||||
assert!(alias.read(limit).is_err());
|
||||
}
|
||||
assert_eq!(file.read(20).unwrap().unwrap(), b"e");
|
||||
assert_eq!(file.read(2).unwrap(), None);
|
||||
alias.close().unwrap();
|
||||
assert_eq!(file.read(2).unwrap_err(), "file is closed");
|
||||
assert_eq!(file.write(b"").unwrap_err(), "file is closed");
|
||||
assert_eq!(file.close().unwrap_err(), "file is closed");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn last_file_alias_releases_descriptor_on_success_and_error() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("drop");
|
||||
std::fs::write(&path, b"test").unwrap();
|
||||
let open_count = || {
|
||||
std::fs::read_dir("/proc/self/fd")
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| std::fs::read_link(entry.path()).ok().as_ref() == Some(&path))
|
||||
.count()
|
||||
};
|
||||
for _ in 0..32 {
|
||||
let alias = {
|
||||
let file = File::open(path.to_str().unwrap()).unwrap();
|
||||
let alias = file.clone();
|
||||
assert_eq!(open_count(), 1);
|
||||
alias
|
||||
};
|
||||
assert_eq!(open_count(), 1);
|
||||
drop(alias);
|
||||
assert_eq!(open_count(), 0);
|
||||
let failure = || -> Result<(), dever_runtime::io::Error> {
|
||||
let file = File::open(path.to_str().unwrap())?;
|
||||
let alias = file.clone();
|
||||
alias.read(0)?;
|
||||
Ok(())
|
||||
};
|
||||
assert!(failure().is_err());
|
||||
assert_eq!(open_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_file_values_preserve_aliases_and_nominal_failures() {
|
||||
let directory = Directory::new();
|
||||
std::fs::write(directory.0.join("input"), b"abcde").unwrap();
|
||||
let project = Project::load(&fixture().with_file_name("io.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
("missing", Some("OpenResult.Failed")),
|
||||
("invalid", Some("ReadResult.Failed")),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary)
|
||||
.current_dir(&directory.0)
|
||||
.output()
|
||||
.unwrap();
|
||||
if let Some(message) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains(message) && error.contains("lib/dever/io.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"hello\ntrue\n");
|
||||
assert_eq!(
|
||||
std::fs::read(directory.0.join("created")).unwrap(),
|
||||
b"hello"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"print",
|
||||
&directory.0.join("print"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary)
|
||||
.stdout(
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/full")
|
||||
.unwrap(),
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("standard output write failed") && error.contains("lib/dever/io.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::{Directory, File, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn stream_aliases_continue_after_partial_reads_and_close_independently_of_file() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("input");
|
||||
std::fs::write(&path, b"abcdef").unwrap();
|
||||
let file = File::open(path.to_str().unwrap()).unwrap();
|
||||
assert!(file.chunks(0, |value| value).is_err());
|
||||
let stream = file.chunks(2, |value| value).unwrap();
|
||||
let alias = stream.clone();
|
||||
assert_eq!(stream.next().unwrap().unwrap(), b"ab");
|
||||
assert_eq!(alias.next().unwrap().unwrap(), b"cd");
|
||||
alias.close();
|
||||
stream.close();
|
||||
assert!(stream.next().is_none());
|
||||
assert_eq!(file.read(2).unwrap().unwrap(), b"ef");
|
||||
let end = file.chunks(2, |value| value).unwrap();
|
||||
assert!(end.next().is_none());
|
||||
assert!(end.clone().next().is_none());
|
||||
file.close().unwrap();
|
||||
assert!(file.chunks(2, |value| value).is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn terminal_stream_error_releases_source_before_returning_the_event() {
|
||||
let directory = Directory::new();
|
||||
let file = File::open(directory.0.to_str().unwrap()).unwrap();
|
||||
let stream = file.chunks(2, |value| value).unwrap();
|
||||
drop(file);
|
||||
let count = || {
|
||||
std::fs::read_dir("/proc/self/fd")
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| std::fs::read_link(entry.path()).ok().as_ref() == Some(&directory.0))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(count(), 1);
|
||||
assert!(stream.next().unwrap().is_err());
|
||||
assert_eq!(count(), 0);
|
||||
assert!(stream.next().is_none());
|
||||
assert!(stream.clone().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_stream_reduction_preserves_cursor_terminal_error_and_cleanup() {
|
||||
let directory = Directory::new();
|
||||
std::fs::write(directory.0.join("input"), b"abcdef").unwrap();
|
||||
let project = Project::load(&fixture().with_file_name("stream.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("verify"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary)
|
||||
.current_dir(&directory.0)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn json_nominal_documents_typed_encoding_and_failures_work_end_to_end() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("json.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in [
|
||||
"encodeVerify",
|
||||
"manualVerify",
|
||||
"failureVerify",
|
||||
"nativeValueVerify",
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n", "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_library_preserves_raw_numbers_and_property_values() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("json.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("verify"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_document_api_parses_properties_and_stringifies() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("json.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"documentVerify",
|
||||
&directory.0.join("documentVerify"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_library_reports_parse_failure_at_the_intrinsic_call() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("json.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"invalid",
|
||||
&directory.0.join("invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(error.contains("JsonResult.Failed"), "{error}");
|
||||
assert!(error.contains("json.dever"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_record_views_preserve_values_finite_checks_and_initialization_failures() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("json-record.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "faultEntry"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
if entry == "verify" {
|
||||
assert!(output.status.success(), "{error}");
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
} else {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(error.contains("Failure.Second"), "{error}");
|
||||
assert!(!error.contains("Failure.First"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn native_tcp_echo_streams_cancellation_and_aliases() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("net.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("echo", "true\n"),
|
||||
("connections", "true\n"),
|
||||
("cancelVerify", "true\n"),
|
||||
("aliasClose", "true\n"),
|
||||
("nullable", "None\n"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, expected.as_bytes(), "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_failures_preserve_official_source_and_business_identity() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("net.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"invalid",
|
||||
&directory.0.join("invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains("ConnectResult.Failed"), "{error}");
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.path.ends_with("lib/dever/net/socket.dever"))
|
||||
.unwrap();
|
||||
let offset = source
|
||||
.text
|
||||
.find("fail(ConnectResult.Failed(message))")
|
||||
.unwrap()
|
||||
+ "fail(".len();
|
||||
let (line, column) = source.position(offset);
|
||||
assert!(
|
||||
error.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, emit, project::Project};
|
||||
use dever_runtime::value::decimal::Decimal;
|
||||
|
||||
#[test]
|
||||
fn decimal_constant_bytes_preserve_sign_quantum_and_full_representation() {
|
||||
const ONE: Decimal = Decimal::from_be_bytes([34, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
const NEGATIVE_ZERO: Decimal =
|
||||
Decimal::from_be_bytes([162, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
ONE.to_be_bytes(),
|
||||
Decimal::parse("1").unwrap().to_be_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
NEGATIVE_ZERO.to_be_bytes(),
|
||||
Decimal::parse("-0").unwrap().to_be_bytes()
|
||||
);
|
||||
|
||||
// 除数值相等外,还核对编码;不能丢掉负零、尾零或极端指数。
|
||||
for text in [
|
||||
"-0.00",
|
||||
"0E+100",
|
||||
"1.2300",
|
||||
"-1.000000000000000000000000000000001",
|
||||
"1E-6176",
|
||||
"9.999999999999999999999999999999999E+6144",
|
||||
] {
|
||||
let original = Decimal::parse(text).unwrap();
|
||||
let restored = Decimal::from_be_bytes(original.to_be_bytes());
|
||||
assert_eq!(restored, original, "{text}");
|
||||
assert_eq!(restored.to_be_bytes(), original.to_be_bytes(), "{text}");
|
||||
}
|
||||
for seed in 0..256u16 {
|
||||
let bytes = std::array::from_fn(|index| (seed + index as u16 * 17) as u8);
|
||||
let original = dec::Decimal128::from_be_bytes(bytes);
|
||||
let restored = Decimal::from_be_bytes(bytes);
|
||||
assert_eq!(restored.to_be_bytes(), original.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_clauses_execute_with_nullable_values_nan_infinity_and_signed_zero() {
|
||||
let directory = Directory::new();
|
||||
let path = fixture().with_file_name("numeric.dever");
|
||||
let project = Project::load(&path).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let source = emit::generate(
|
||||
&program,
|
||||
&project.sources,
|
||||
&(0..program.functions.len()).collect(),
|
||||
);
|
||||
assert!(source.contains("Decimal::from_be_bytes"));
|
||||
assert!(!source.contains("Decimal::parse"));
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("numeric"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_decimal_conversion_preserves_full_encoding_at_narrowing_boundaries() {
|
||||
let verify = |value| {
|
||||
assert_eq!(
|
||||
Decimal::from_int(value).to_be_bytes(),
|
||||
dec::Decimal128::from(value).to_be_bytes(),
|
||||
"{value}"
|
||||
);
|
||||
};
|
||||
for value in [
|
||||
i64::MIN,
|
||||
i64::MAX,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
i64::from(i32::MIN) - 1,
|
||||
i64::from(i32::MIN),
|
||||
i64::from(i32::MIN) + 1,
|
||||
i64::from(i32::MAX) - 1,
|
||||
i64::from(i32::MAX),
|
||||
i64::from(i32::MAX) + 1,
|
||||
i64::from(u32::MAX) - 1,
|
||||
i64::from(u32::MAX),
|
||||
i64::from(u32::MAX) + 1,
|
||||
] {
|
||||
verify(value);
|
||||
}
|
||||
let mut state = 0x9e3779b97f4a7c15u64;
|
||||
for _ in 0..256 {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
verify(state as i64);
|
||||
verify(i64::from(state as i32));
|
||||
verify(i64::from(state as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_sum_preserves_rounding_first_fault_and_context_independence() {
|
||||
let maximum = "9.999999999999999999999999999999999E+6144";
|
||||
for texts in [
|
||||
vec![],
|
||||
vec!["-0.00"],
|
||||
vec!["0.1", "0.2", "-0.05"],
|
||||
vec!["1E-6176", "1E-6176"],
|
||||
vec![
|
||||
"1000000000000000000000000000000000",
|
||||
"0.1",
|
||||
"-1000000000000000000000000000000000",
|
||||
],
|
||||
vec![
|
||||
"1000000000000000000000000000000000",
|
||||
"-1000000000000000000000000000000000",
|
||||
"0.1",
|
||||
],
|
||||
vec![maximum, "1"],
|
||||
vec![
|
||||
maximum,
|
||||
maximum,
|
||||
"-9.999999999999999999999999999999999E+6144",
|
||||
],
|
||||
] {
|
||||
let values = texts
|
||||
.iter()
|
||||
.map(|text| Decimal::parse(text).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let before = values
|
||||
.iter()
|
||||
.map(|value| value.to_be_bytes())
|
||||
.collect::<Vec<_>>();
|
||||
let mut context = dec::Context::<dec::Decimal128>::default();
|
||||
let expected = values
|
||||
.iter()
|
||||
.try_fold(dec::Decimal128::ZERO, |total, value| {
|
||||
context.clear_status();
|
||||
let value = context.add(total, dec::Decimal128::from_be_bytes(value.to_be_bytes()));
|
||||
(value.is_finite()
|
||||
&& !context.status().overflow()
|
||||
&& !context.status().invalid_operation())
|
||||
.then_some(value)
|
||||
});
|
||||
assert_eq!(
|
||||
Decimal::sum(&values).map(Decimal::to_be_bytes),
|
||||
expected.map(|value| value.to_be_bytes()),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.to_be_bytes())
|
||||
.collect::<Vec<_>>(),
|
||||
before
|
||||
);
|
||||
}
|
||||
let one = Decimal::from_int(1);
|
||||
let mut context = Decimal::context();
|
||||
assert!(
|
||||
one.checked_div(Decimal::from_int(0), &mut context)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(Decimal::sum(&[one, one]), Some(Decimal::from_int(2)));
|
||||
assert_eq!(
|
||||
one.checked_add(one, &mut context),
|
||||
Some(Decimal::from_int(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_sum_executes_through_generated_code() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("decimal.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("decimal"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_operations_keep_local_status_across_threads() {
|
||||
let start = std::sync::Barrier::new(4);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..4 {
|
||||
scope.spawn(|| {
|
||||
start.wait();
|
||||
let one = Decimal::from_int(1);
|
||||
let two = Decimal::from_int(2);
|
||||
let maximum = Decimal::parse("9.999999999999999999999999999999999E+6144").unwrap();
|
||||
let mut context = Decimal::context();
|
||||
for _ in 0..32 {
|
||||
assert!(maximum.checked_mul(two, &mut context).is_none());
|
||||
assert_eq!(one.checked_add(one, &mut context), Some(two));
|
||||
assert!(one.checked_div(Decimal::ZERO, &mut context).is_none());
|
||||
assert_eq!(two.checked_sub(one, &mut context), Some(one));
|
||||
assert!(Decimal::parse("0.12345678901234567890123456789012345").is_err());
|
||||
assert_eq!(
|
||||
Decimal::parse("1.25").unwrap(),
|
||||
one.checked_div(Decimal::parse("0.8").unwrap(), &mut context)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(Decimal::sum(&[one, one]), Some(two));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_status_matches_finite_results_at_arithmetic_boundaries() {
|
||||
let texts = [
|
||||
"0",
|
||||
"-0.00",
|
||||
"1",
|
||||
"-1",
|
||||
"2",
|
||||
"3",
|
||||
"0.1",
|
||||
"-0.1",
|
||||
"1.000000000000000000000000000000001",
|
||||
"0.9999999999999999999999999999999999",
|
||||
"9999999999999999999999999999999999",
|
||||
"1E-6176",
|
||||
"-1E-6176",
|
||||
"1E-6143",
|
||||
"-1E-6143",
|
||||
"1E+6144",
|
||||
"-1E+6144",
|
||||
"9.999999999999999999999999999999999E+6144",
|
||||
"-9.999999999999999999999999999999999E+6144",
|
||||
];
|
||||
let values = texts.map(|text| Decimal::parse(text).unwrap());
|
||||
let mut actual_context = Decimal::context();
|
||||
let mut reference_context = dec::Context::<dec::Decimal128>::default();
|
||||
for left in values {
|
||||
for right in values {
|
||||
for operation in 0..4 {
|
||||
reference_context.clear_status();
|
||||
let native_left = dec::Decimal128::from_be_bytes(left.to_be_bytes());
|
||||
let native_right = dec::Decimal128::from_be_bytes(right.to_be_bytes());
|
||||
let expected = match operation {
|
||||
0 => reference_context.add(native_left, native_right),
|
||||
1 => reference_context.sub(native_left, native_right),
|
||||
2 => reference_context.mul(native_left, native_right),
|
||||
3 => reference_context.div(native_left, native_right),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let expected = (expected.is_finite()
|
||||
&& !reference_context.status().overflow()
|
||||
&& !reference_context.status().invalid_operation())
|
||||
.then(|| expected.to_be_bytes());
|
||||
let actual = match operation {
|
||||
0 => left.checked_add(right, &mut actual_context),
|
||||
1 => left.checked_sub(right, &mut actual_context),
|
||||
2 => left.checked_mul(right, &mut actual_context),
|
||||
3 => left.checked_div(right, &mut actual_context),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(
|
||||
actual.map(Decimal::to_be_bytes),
|
||||
expected,
|
||||
"{left} operation {operation} {right}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for places in [-6112, -6111, -614, -1, 0, 1, 33, 6143, 6176, 6177] {
|
||||
reference_context.clear_status();
|
||||
let expected = if (-6176..=6111).contains(&-places) {
|
||||
let mut quantum = dec::Decimal128::ONE;
|
||||
reference_context.set_exponent(&mut quantum, -places);
|
||||
let value = reference_context
|
||||
.quantize(dec::Decimal128::from_be_bytes(left.to_be_bytes()), quantum);
|
||||
(value.is_finite()
|
||||
&& !reference_context.status().overflow()
|
||||
&& !reference_context.status().invalid_operation()
|
||||
&& !reference_context.status().underflow()
|
||||
&& !reference_context.status().invalid_context())
|
||||
.then(|| value.to_be_bytes())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
assert_eq!(
|
||||
left.round(i64::from(places)).map(Decimal::to_be_bytes),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
for invalid in ["NaN", "sNaN", "Infinity", "-Infinity", "1E+6145"] {
|
||||
assert!(Decimal::parse(invalid).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn range_contracts_execute_without_runtime_wrappers() {
|
||||
let directory = super::Directory::new();
|
||||
let project =
|
||||
dever::compiler::project::Project::load(&super::fixture().with_file_name("ranges.dever"))
|
||||
.unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = dever::compiler::build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("ranges"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, emit, project::Project};
|
||||
use std::path::Path;
|
||||
|
||||
fn document() -> String {
|
||||
"verify() (answer: Bool) { answer = true }
|
||||
"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, contents: impl AsRef<[u8]>) {
|
||||
let path = root.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn load_error(path: &Path, needle: &str) {
|
||||
let error = Project::load(path).err().expect("invalid source accepted");
|
||||
assert!(error.contains(needle), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_reachability_excludes_unused_task_code_but_keeps_checks() {
|
||||
for (sample, entry, features) in [
|
||||
("core.dever", "verify", vec![]),
|
||||
("handler.dever", "verify", vec![]),
|
||||
("task.dever", "verify", vec!["task"]),
|
||||
("task.dever", "yes", vec![]),
|
||||
] {
|
||||
let project = Project::load(&fixture().with_file_name(sample)).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let root = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.source == 0 && function.signature.name == entry)
|
||||
.unwrap();
|
||||
let functions = program.reachable([root]);
|
||||
assert!(functions.contains(&root));
|
||||
assert!(functions.len() < program.functions.len());
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &functions),
|
||||
features,
|
||||
"{sample}/{entry}"
|
||||
);
|
||||
let source = emit::generate(&program, &project.sources, &functions);
|
||||
if features.is_empty() {
|
||||
assert!(
|
||||
!source.contains("dever_runtime::task::"),
|
||||
"{sample}/{entry}"
|
||||
);
|
||||
}
|
||||
for index in 0..program.functions.len() {
|
||||
if !functions.contains(&index) {
|
||||
assert!(
|
||||
!source.contains(&format!("fn function{index}(")),
|
||||
"{sample}/{entry}: {index}"
|
||||
);
|
||||
assert!(
|
||||
!source.contains(&format!("as function{index};")),
|
||||
"{sample}/{entry}: {index}"
|
||||
);
|
||||
assert!(
|
||||
!source.contains(&format!("enum Failure{index} {{")),
|
||||
"{sample}/{entry}: {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut project = Project::load(&fixture()).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let text = format!(
|
||||
"{}\nunused() (answer: Int) {{ answer = true }}\n",
|
||||
source.text
|
||||
);
|
||||
project.sources[0] = dever::syntax::source::Source::new(source.id, source.path.clone(), text);
|
||||
assert!(project.check().unwrap_err().contains("C005"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_execute_with_original_cross_file_failure_locations() {
|
||||
let directory = Directory::new();
|
||||
let path = fixture().with_file_name("package");
|
||||
let project = Project::load(&path).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert_eq!(
|
||||
&program.packages[..3],
|
||||
&["calc.batch", "calc.value", "main"]
|
||||
);
|
||||
let second = Project::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
emit::generate(
|
||||
&program,
|
||||
&project.sources,
|
||||
&(0..program.functions.len()).collect()
|
||||
),
|
||||
emit::generate(
|
||||
&second.check().unwrap(),
|
||||
&second.sources,
|
||||
&(0..program.functions.len()).collect()
|
||||
)
|
||||
);
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
("calc.batch.verify", None),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Problem.Rejected", "Problem.Rejected(\"negative\")")),
|
||||
),
|
||||
(
|
||||
"faultEntry",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = expected {
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let stderr = String::from_utf8(result.stderr).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.path.ends_with("calc/value.dever"))
|
||||
.unwrap();
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"calc.missing",
|
||||
&directory.0.join("missing")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!directory.0.join("missing").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_records_preserve_cross_package_value_semantics() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("private")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("private"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_are_authoritative_and_discovery_is_stable() {
|
||||
let directory = Directory::new();
|
||||
write(&directory.0, "zeta/value.dever", document());
|
||||
write(&directory.0, "alpha.dever", document());
|
||||
write(&directory.0, "README.md", "not code");
|
||||
write(&directory.0, ".hidden/invalid.dever", "not code");
|
||||
let project = Project::load(&directory.0).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert_eq!(&program.packages[..2], &["alpha", "zeta.value"]);
|
||||
assert_eq!(
|
||||
program.packages,
|
||||
Project::load(&directory.0)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap()
|
||||
.packages
|
||||
);
|
||||
write(
|
||||
&directory.0,
|
||||
"zeta/value.dever",
|
||||
document().replace("answer = true", "answer = 1"),
|
||||
);
|
||||
let error = Project::load(&directory.0).unwrap().check().unwrap_err();
|
||||
assert!(
|
||||
error.contains("zeta/value.dever:") && error.contains("C005"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_loader_rejects_collisions_names_and_invalid_sources() {
|
||||
for (name, needle) in [
|
||||
("Wrong.dever", "lower_snake_case"),
|
||||
("user..bad.dever", "lower_snake_case"),
|
||||
("dever/custom.dever", "reserved standard"),
|
||||
("shop/account/app.dever", "application role"),
|
||||
] {
|
||||
let directory = Directory::new();
|
||||
write(&directory.0, name, document());
|
||||
load_error(&directory.0, needle);
|
||||
}
|
||||
let directory = Directory::new();
|
||||
load_error(&directory.0, "no Dever sources");
|
||||
write(&directory.0, "sample.dever", document());
|
||||
write(&directory.0, "sample.dever.md", "verify() () {}");
|
||||
load_error(&directory.0, "use .dever source");
|
||||
let directory = Directory::new();
|
||||
write(&directory.0, "bad.dever", [0xff]);
|
||||
load_error(&directory.0, "bad.dever");
|
||||
let directory = Directory::new();
|
||||
write(&directory.0, "module/shop/account/app.dever", document());
|
||||
let error = Project::load(&directory.0).unwrap().check().unwrap_err();
|
||||
assert!(
|
||||
error.contains("application has no supported API/CMD entry"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_loader_does_not_follow_source_links() {
|
||||
let directory = Directory::new();
|
||||
write(&directory.0, "sources/value.dever", document());
|
||||
let source = directory.0.join("sources");
|
||||
std::os::unix::fs::symlink(source.join("value.dever"), source.join("alias.dever")).unwrap();
|
||||
load_error(&source, "source links");
|
||||
load_error(&source.join("alias.dever"), "source links");
|
||||
std::os::unix::fs::symlink(&source, directory.0.join("linked")).unwrap();
|
||||
load_error(&directory.0.join("linked/value.dever"), "source links");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
use std::process::Command;
|
||||
|
||||
mod settings;
|
||||
|
||||
#[test]
|
||||
fn native_ports_execute_file_io_and_preserve_capture_and_faults() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("ports")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let connect = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "connect")
|
||||
.unwrap();
|
||||
assert!(connect.signature.effects.network && connect.signature.effects.suspend);
|
||||
assert!(!connect.signature.effects.blocking && !connect.signature.effects.unmediated_io);
|
||||
let binary =
|
||||
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
|
||||
let file = directory.0.join("input.txt");
|
||||
std::fs::write(&file, "owned Port test file").unwrap();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
for (name, input, expected) in [
|
||||
("open", serde_json::json!({"path": file}).to_string(), "1"),
|
||||
("adjust", "{\"value\":41}".into(), "42"),
|
||||
("captured", "{\"value\":41}".into(), "true"),
|
||||
("captured", "{\"value\":-1}".into(), "false"),
|
||||
(
|
||||
"connect",
|
||||
serde_json::json!({"host":"127.0.0.1", "port":address.port()}).to_string(),
|
||||
"true",
|
||||
),
|
||||
] {
|
||||
let result = Command::new(&binary)
|
||||
.args([format!("demo.files.{name}"), input])
|
||||
.output()
|
||||
.unwrap();
|
||||
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\":{expected}}}\n")
|
||||
);
|
||||
}
|
||||
// TCP 握手可先进入监听队列;非阻塞 accept 确认应用确实建立了连接。
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let (peer, _) = listener.accept().unwrap();
|
||||
drop(peer);
|
||||
for (name, input, expected, source) in [
|
||||
(
|
||||
"open",
|
||||
serde_json::json!({"path": directory.0.join("missing")}).to_string(),
|
||||
"OpenResult.Failed",
|
||||
"lib/dever/io.dever:",
|
||||
),
|
||||
(
|
||||
"adjust",
|
||||
"{\"value\":-1}".into(),
|
||||
"Problem.Missing",
|
||||
"adapter.dever:",
|
||||
),
|
||||
(
|
||||
"captured",
|
||||
"{\"value\":9223372036854775807}".into(),
|
||||
"integer arithmetic fault",
|
||||
"adapter.dever:",
|
||||
),
|
||||
] {
|
||||
let result = Command::new(&binary)
|
||||
.args([format!("demo.files.{name}"), input])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains(expected) && error.contains(source),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_selection_remains_runtime_configuration() {
|
||||
let directory = Directory::new();
|
||||
let module = directory.0.join("module/demo/files");
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
std::fs::write(module.join("app.dever"), "not parsed before settings").unwrap();
|
||||
let config = directory.0.join("config");
|
||||
std::fs::create_dir(&config).unwrap();
|
||||
for text in [
|
||||
r#"{"adapter":{"demo.files":{"use":"default"}}}"#,
|
||||
r#"{"adapter":null}"#,
|
||||
] {
|
||||
std::fs::write(config.join("setting.json"), text).unwrap();
|
||||
assert!(Project::load(&directory.0).is_ok(), "{text}");
|
||||
}
|
||||
std::fs::write(
|
||||
config.join("setting.json"),
|
||||
r#"{"adapter":{},"adapter":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Project::load(&directory.0).is_err());
|
||||
std::fs::write(
|
||||
config.join("setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.test","use":[],"fixture-only-secret":true}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = Project::load(&directory.0).err().unwrap();
|
||||
assert!(error.contains("invalid project configuration JSON"));
|
||||
assert!(!error.contains("fixture-only-secret"));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
use std::{path::Path, process::Command};
|
||||
|
||||
fn execute(binary: &Path, name: &str, input: serde_json::Value) -> std::process::Output {
|
||||
Command::new(binary)
|
||||
.args([format!("demo.value.{name}"), input.to_string()])
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_adapter_selection_settings_and_tasks_share_one_binary() {
|
||||
let directory = Directory::new();
|
||||
let source = fixture().with_file_name("adapters");
|
||||
let project = Project::load(&source).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary =
|
||||
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
|
||||
let original = std::fs::read(&binary).unwrap();
|
||||
let config = directory.0.join("config");
|
||||
std::fs::create_dir(&config).unwrap();
|
||||
let path = config.join("setting.json");
|
||||
let local = serde_json::json!({"adapter":{"demo.value":{"use":"local","setting":{"offset":1,"prefix":"local:","token":"key"}}}});
|
||||
let remote = serde_json::json!({"adapter":{"demo.value":{"use":"remote"}}});
|
||||
for (setting, number, prefix, sign) in
|
||||
[(&local, 42, "local:", 247), (&remote, 141, "remote:", 82)]
|
||||
{
|
||||
std::fs::write(&path, setting.to_string()).unwrap();
|
||||
for name in [
|
||||
"calculate",
|
||||
"worker",
|
||||
"nested",
|
||||
"mapped",
|
||||
"parallel",
|
||||
"scheduled",
|
||||
"cancelled",
|
||||
] {
|
||||
let result = execute(&binary, name, serde_json::json!({"value":41}));
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{name}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let actual: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
|
||||
assert_eq!(
|
||||
actual["data"],
|
||||
if name == "mapped" { number * 2 } else { number }
|
||||
);
|
||||
}
|
||||
for (name, value, expected) in [
|
||||
("echo", "雪", serde_json::json!(format!("{prefix}雪"))),
|
||||
(
|
||||
"sign",
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
serde_json::json!(sign),
|
||||
),
|
||||
] {
|
||||
let result = execute(&binary, name, serde_json::json!({"value":value}));
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{name}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let actual: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
|
||||
assert_eq!(actual["data"], expected);
|
||||
}
|
||||
}
|
||||
for name in ["parallel", "scheduled"] {
|
||||
let result = execute(&binary, name, serde_json::json!({"value":-1}));
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(
|
||||
error.contains("Problem.Missing") && error.contains("remote.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
for setting in [
|
||||
"{}",
|
||||
"null",
|
||||
"[]",
|
||||
"{",
|
||||
r#"{"adapter":{"demo.value":{"use":"absent"}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local"}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","setting":{"offset":"fixture-only-secret","prefix":"x","token":"x"}}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","setting":[1,"x","fixture-only-secret"]}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"remote","setting":{"token":"fixture-only-secret"}}}}"#,
|
||||
r#"{"adapter":{"demo.value":{"use":"local","setting":{"offset":1,"prefix":"x","token":"x","token":"fixture-only-secret"}}}}"#,
|
||||
] {
|
||||
std::fs::write(&path, setting).unwrap();
|
||||
let result = execute(&binary, "calculate", serde_json::json!({"value":41}));
|
||||
assert!(!result.status.success(), "{setting}");
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(
|
||||
error.contains("Adapter") && !error.contains("fixture-only-secret"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
// 同一产物迁移到新目录;配置路径属于部署,不进入编译缓存或二进制。
|
||||
let relocated = directory.0.join("relocated");
|
||||
std::fs::create_dir(&relocated).unwrap();
|
||||
let copy = relocated.join("app");
|
||||
std::fs::copy(&binary, ©).unwrap();
|
||||
std::fs::write(&path, local.to_string()).unwrap();
|
||||
let result = Command::new(©)
|
||||
.arg("--config")
|
||||
.arg(&path)
|
||||
.args(["demo.value.calculate", r#"{"value":41}"#])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
|
||||
42
|
||||
);
|
||||
assert_eq!(std::fs::read(&binary).unwrap(), original);
|
||||
|
||||
// run 使用项目配置;只复制此测试源码到自有临时目录。
|
||||
let project_root = directory.0.join("project");
|
||||
let module = project_root.join("module/demo/value");
|
||||
std::fs::create_dir_all(module.join("adapter")).unwrap();
|
||||
for relative in [
|
||||
"app.dever",
|
||||
"port.dever",
|
||||
"api.dever",
|
||||
"adapter/local.dever",
|
||||
"adapter/remote.dever",
|
||||
] {
|
||||
std::fs::copy(
|
||||
source.join("module/demo/value").join(relative),
|
||||
module.join(relative),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir(project_root.join("config")).unwrap();
|
||||
std::fs::write(project_root.join("config/setting.json"), local.to_string()).unwrap();
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("run")
|
||||
.arg(&project_root)
|
||||
.args(["--", "demo.value.calculate", r#"{"value":41}"#])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
|
||||
42
|
||||
);
|
||||
// 单实现省略 use,只保留字段输入;无需多实现选择分支。
|
||||
let local_source = std::fs::read_to_string(module.join("adapter/local.dever")).unwrap();
|
||||
std::fs::write(
|
||||
module.join("adapter.dever"),
|
||||
local_source.replace("demo.value.adapter.local", "demo.value.adapter"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(module.join("adapter")).unwrap();
|
||||
std::fs::write(project_root.join("config/setting.json"), serde_json::json!({"adapter":{"demo.value":{"setting":{"offset":2,"prefix":"one:","token":"key"}}}}).to_string()).unwrap();
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("run")
|
||||
.arg(&project_root)
|
||||
.args(["--", "demo.value.worker", r#"{"value":41}"#])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&result.stdout).unwrap()["data"],
|
||||
43
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn nested_calls_keep_earlier_borrowed_arguments_live() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("borrowed_call.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("borrowed"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_record_updates_preserve_old_fields_aliases_and_faults() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("record_update.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "faultEntry"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if entry == "verify" {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
} else {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find("state.count + delta").unwrap());
|
||||
assert!(stderr.contains("integer arithmetic fault"), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn native_text_numeric_and_math_interfaces_execute_without_a_runtime() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("standard.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["textVerify", "numericVerify"] {
|
||||
let index = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == entry)
|
||||
.unwrap();
|
||||
assert!(build::runtime_features(&program, &program.reachable([index])).is_empty());
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n", "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_round_fault_keeps_the_original_library_position() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("standard.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"invalidRound",
|
||||
&directory.0.join("invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains("Decimal rounding fault"), "{error}");
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.path.ends_with("lib/decimal.dever"))
|
||||
.unwrap();
|
||||
let (line, column) = source.position(
|
||||
source
|
||||
.text
|
||||
.find("dever.system.decimalRound(value, places)")
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
error.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_packages_cannot_claim_standard_namespaces() {
|
||||
for name in ["text", "int", "decimal", "float", "math", "uuid"] {
|
||||
let directory = Directory::new();
|
||||
std::fs::write(directory.0.join(format!("{name}.dever")), "unused").unwrap();
|
||||
let result = Project::load(&directory.0);
|
||||
let error = match result {
|
||||
Ok(_) => panic!("reserved package accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.contains("reserved standard namespace"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn map_order_value_copies_and_constructor_faults() {
|
||||
check(
|
||||
"storage.dever",
|
||||
&[
|
||||
("verify", None),
|
||||
(
|
||||
"duplicateEntry",
|
||||
Some(("duplicate Map literal key", "{ first_key = 1")),
|
||||
),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"faultEntry",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
(
|
||||
"lookupFailure",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"lookupFault",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
(
|
||||
"staticLookupFailure",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"staticMissingFailure",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_collections_evaluate_values_in_source_order() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("storage.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"traceLiteralLookup",
|
||||
&directory.0.join("trace"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(output.status.success(), "{:?}", output.stderr);
|
||||
assert_eq!(
|
||||
output.stdout,
|
||||
b"first\nsecond\nthird\nfourth\nfifth\ntrue\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_accumulators_preserve_aliases_stop_order_and_failures() {
|
||||
check(
|
||||
"owned_state.dever",
|
||||
&[
|
||||
("verify", None),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"owned state\")")),
|
||||
),
|
||||
("faultEntry", Some(("integer arithmetic fault", "item + 1"))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_reduction_preserves_seed_reads_and_argument_failure_order() {
|
||||
check(
|
||||
"map_update.dever",
|
||||
&[
|
||||
("verify", None),
|
||||
(
|
||||
"keyFailure",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"key first\")")),
|
||||
),
|
||||
(
|
||||
"valueFault",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
(
|
||||
"seedFailure",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"key first\")")),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
type Expected<'text> = (&'text str, Option<(&'text str, &'text str)>);
|
||||
|
||||
#[test]
|
||||
fn map_entries_keep_owned_and_borrowed_nested_values() {
|
||||
check("map-values.dever", &[("verify", None)]);
|
||||
}
|
||||
|
||||
fn check(name: &str, cases: &[Expected<'_>]) {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name(name)).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for &(entry, expected) in cases {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("{name}:{line}:{column}:")),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn async_stream_consumers_keep_values_and_close_all_aliases() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("async-stream.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", "true\n"),
|
||||
("cleanup", "true\n"),
|
||||
("creation", "AsyncStream\n"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, expected.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_tick_period_keeps_its_official_library_location() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("async-stream.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"invalid",
|
||||
&directory.0.join("invalid"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains("tick period must be positive"), "{error}");
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.path.ends_with("lib/dever/task.dever"))
|
||||
.unwrap();
|
||||
let (line, column) =
|
||||
source.position(source.text.find("dever.system.taskTicks(millis)").unwrap());
|
||||
assert!(
|
||||
error.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn native_channels_transfer_values_and_share_close_state() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("channel.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", "true\n"),
|
||||
("creation", "Channel\n"),
|
||||
("nullable", "None\n"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, expected.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_faults_keep_original_source_locations() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("channel.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, needle, message) in [
|
||||
(
|
||||
"invalid",
|
||||
"channel(Int, 0)",
|
||||
"channel capacity must be within 1..65536",
|
||||
),
|
||||
("closed", "send(values, 42)", "channel is closed"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains(message), "{error}");
|
||||
assert!(
|
||||
error.contains(&format!("channel.dever:{line}:{column}:")),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn groups_execute_empty_reused_and_mixed_tasks() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-group.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["verify", "verifyLocal"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_preserve_child_and_argument_fault_origins() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-group.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, needle, message) in [
|
||||
("failedGroup", "Problem.Text(\"worker\")", "Problem.Text"),
|
||||
("failedAdmission", "Problem.Number(42)", "Problem.Number"),
|
||||
(
|
||||
"failedArgument",
|
||||
"Problem.Text(\"argument\")",
|
||||
"Problem.Text",
|
||||
),
|
||||
("invalidGroup", "group(0)", "group limit must be within"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains(message), "{entry}: {error}");
|
||||
assert!(
|
||||
error.contains(&format!("task-group.dever:{line}:{column}:")),
|
||||
"{entry}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn suspending_arguments_release_nontransferable_record_fields_before_await() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-liveness.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("verify"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
mod async_stream;
|
||||
mod channel;
|
||||
mod group;
|
||||
mod liveness;
|
||||
mod parallel;
|
||||
mod select;
|
||||
|
||||
#[test]
|
||||
fn asynchronous_clauses_keep_nullable_payloads_axes_and_failure_cleanup() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-clauses.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("clauses"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tasks_transfer_values_wait_cancel_and_preserve_error_origins() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
("sleepFailure", Some("SleepResult.Failed")),
|
||||
("faultEntry", Some("integer arithmetic fault")),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some(message) = expected {
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains(message), "{error}");
|
||||
if entry == "faultEntry" {
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.rfind("value + 1").unwrap());
|
||||
assert!(
|
||||
error.contains(&format!("task.dever:{line}:{column}:")),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(error.contains("lib/dever/time.dever:"), "{error}");
|
||||
}
|
||||
} else {
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_commands_keep_typed_inputs_outputs_and_synchronous_peers() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("application")).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("greeting/app.dever"))
|
||||
.unwrap();
|
||||
let text = source
|
||||
.text
|
||||
.replace(
|
||||
"message = utility.decorate(domain.greet(name))",
|
||||
"dever.task.sleep(0)\n message = utility.decorate(domain.greet(name))",
|
||||
)
|
||||
.replace(
|
||||
"{ first = value\n second = value + 1 }",
|
||||
"{ dever.task.sleep(0)\n first = value\n second = value + 1 }",
|
||||
);
|
||||
*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("commands"),
|
||||
)
|
||||
.unwrap();
|
||||
for (command, input, expected) in [
|
||||
("greet", r#"{"name":"雪"}"#, r#""[Hello, 雪]""#),
|
||||
("pair", r#"{"value":41}"#, r#"{"first":41,"second":42}"#),
|
||||
("double", r#"{"value":21}"#, "42"),
|
||||
] {
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args([&format!("demo.greeting.{command}"), input])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{expected}}}\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_entry_preserves_business_failure_origin() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("task.dever")).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let text = format!(
|
||||
"{}\nasyncFailure() () {{ dever.task.sleep(-1) }}\n",
|
||||
source.text
|
||||
);
|
||||
project.sources[0] = dever::syntax::source::Source::new(source.id, source.path.clone(), text);
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"asyncFailure",
|
||||
&directory.0.join("failure"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let source = project
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.path.ends_with("lib/dever/task.dever"))
|
||||
.unwrap();
|
||||
let offset = source
|
||||
.text
|
||||
.find("fail(dever.time.SleepResult.Failed(message))")
|
||||
.unwrap()
|
||||
+ "fail(".len();
|
||||
let (line, column) = source.position(offset);
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains("SleepResult.Failed"), "{error}");
|
||||
assert!(
|
||||
error.contains(&format!("{}:{line}:{column}:", source.path.display())),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_entries_only_create_task_scopes_when_they_schedule() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("task-performance.dever")).unwrap();
|
||||
let cases = [
|
||||
("syncEntry", "answer = total([1, 2])", false, false, "3\n"),
|
||||
(
|
||||
"leafEntry",
|
||||
"answer = asyncTotal([1, 2], false)",
|
||||
true,
|
||||
false,
|
||||
"3\n",
|
||||
),
|
||||
("forwardEntry", "answer = leafEntry()", true, false, "3\n"),
|
||||
(
|
||||
"scheduledEntry",
|
||||
"answer = asyncPair([1, 2])",
|
||||
true,
|
||||
true,
|
||||
"6\n",
|
||||
),
|
||||
(
|
||||
"blockingEntry",
|
||||
"answer = blockingSum([1, 2])",
|
||||
true,
|
||||
true,
|
||||
"3\n",
|
||||
),
|
||||
("cancelEntry", "answer = cancel()", true, true, "1\n"),
|
||||
];
|
||||
let mut text = project.sources[0].text.clone();
|
||||
for (name, body, _, _, _) in cases {
|
||||
text.push_str(&format!("{name}() (answer: Int) {{ {body} }}\n"));
|
||||
}
|
||||
let source = &project.sources[0];
|
||||
project.sources[0] = dever::syntax::source::Source::new(source.id, source.path.clone(), text);
|
||||
let program = project.check().unwrap();
|
||||
for (entry, _, suspend, schedule, expected) in cases {
|
||||
let signature = &program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == entry)
|
||||
.unwrap()
|
||||
.signature;
|
||||
assert_eq!(
|
||||
(signature.effects.suspend, signature.effects.schedule),
|
||||
(suspend, schedule),
|
||||
"{entry}"
|
||||
);
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, expected.as_bytes(), "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_tasks_keep_cleanup_on_each_failure_exit() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("task.dever")).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let mut text = source.text.clone();
|
||||
for code in [
|
||||
"explicitFailure() () { pending = run(dever.task.sleep(60000))\n fail(dever.time.SleepResult.Failed(\"explicit\")) }",
|
||||
"explicitVerify() (answer: Bool) { answer = failed(result(explicitFailure())) }",
|
||||
"arithmeticSleep(value: Int) () { next = value + 1\n dever.task.sleep(next) }",
|
||||
"capturedFault(value: Int) (answer: dever.time.SleepResult) { pending = run(dever.task.sleep(60000))\n answer = result(arithmeticSleep(value))\n stop(pending) }",
|
||||
"captureFaultEntry() (answer: dever.time.SleepResult) { answer = capturedFault(9223372036854775807) }",
|
||||
"sumWithPending(values: List<Int>) (answer: Int) { pending = run(dever.task.sleep(60000))\n answer = sum(values)\n stop(pending) }",
|
||||
"sumCleanupEntry() (answer: Int) { answer = sumWithPending([9223372036854775807, 1]) }",
|
||||
"mapWithPending(key: Text) (answer: Map<Text, Int>) { pending = run(dever.task.sleep(60000))\n answer = { key = 1\n \"same\" = 2 }\n stop(pending) }",
|
||||
"mapCleanupEntry() (answer: Map<Text, Int>) { answer = mapWithPending(\"same\") }",
|
||||
] {
|
||||
text.push_str(&format!("{code}\n"));
|
||||
}
|
||||
project.sources[0] = dever::syntax::source::Source::new(source.id, source.path.clone(), text);
|
||||
let program = project.check().unwrap();
|
||||
for (entry, failure) in [
|
||||
("explicitVerify", None),
|
||||
(
|
||||
"captureFaultEntry",
|
||||
Some(("value + 1", "integer arithmetic fault")),
|
||||
),
|
||||
("sumCleanupEntry", Some(("sum(values)", "integer overflow"))),
|
||||
(
|
||||
"mapCleanupEntry",
|
||||
Some(("{ key = 1", "duplicate Map literal key")),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((needle, message)) = failure {
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.rfind(needle).unwrap());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains(&format!("task.dever:{line}:{column}:")),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains(message), "{error}");
|
||||
} else {
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
use dever::syntax::source::Source;
|
||||
|
||||
fn project(directory: &Directory) -> Project {
|
||||
let mut project = Project::load(&fixture().with_file_name("parallel.dever")).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let text = source.text.replace(
|
||||
"PARALLEL_FIXTURE_PATH",
|
||||
directory.0.join("stream.data").to_str().unwrap(),
|
||||
);
|
||||
project.sources[0] = Source::new(source.id, source.path.clone(), text);
|
||||
project
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_parallel_consumers_cover_threads_workers_tasks_and_streams() {
|
||||
let directory = Directory::new();
|
||||
let project = project(&directory);
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("syncVerify", "sync\nsync\nsync\nsync\nbyte\nbyte\ntrue\n"),
|
||||
(
|
||||
"workerVerify",
|
||||
"worker\nworker\nworker\nworker\n3\n3\ntrue\n",
|
||||
),
|
||||
("streamVerify", "chunk\nchunk\nchunk\nchunk\ntrue\n"),
|
||||
("asyncVerify", "true\n"),
|
||||
("nestedVerify", "true\n"),
|
||||
("sourceFailure", "true\n"),
|
||||
("cancelVerify", "true\n"),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, expected.as_bytes(), "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_failures_and_limits_keep_original_locations() {
|
||||
let directory = Directory::new();
|
||||
let project = project(&directory);
|
||||
let program = project.check().unwrap();
|
||||
for (entry, needle, message) in [
|
||||
(
|
||||
"syncFailure",
|
||||
"dever.io.ReadEvent.Failed(\"sync worker\")",
|
||||
"ReadEvent.Failed",
|
||||
),
|
||||
(
|
||||
"syncInvalid",
|
||||
"parallelEach(report, [], 0",
|
||||
"parallel concurrency must be within 1..256",
|
||||
),
|
||||
(
|
||||
"asyncInvalid",
|
||||
"parallelEach(sendOne, [], 65537",
|
||||
"group limit must be within 1..65536",
|
||||
),
|
||||
(
|
||||
"workerInvalid",
|
||||
"parallelEach(report, [], 257",
|
||||
"parallel concurrency must be within 1..256",
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success(), "{entry}");
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(error.contains(message), "{entry}: {error}");
|
||||
assert!(
|
||||
error.contains(&format!("parallel.dever:{line}:{column}:")),
|
||||
"{entry}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn selection_executes_nullable_multi_output_unit_and_expired_branches() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-select.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("selection"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_preserves_fault_sources_and_argument_failures() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("task-select.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, needle, message) in [
|
||||
(
|
||||
"timeoutFault",
|
||||
"ValueResult.Failed(\"worker\")",
|
||||
"ValueResult.Failed",
|
||||
),
|
||||
(
|
||||
"raceFault",
|
||||
"ValueResult.Failed(\"worker\")",
|
||||
"ValueResult.Failed",
|
||||
),
|
||||
(
|
||||
"argumentFault",
|
||||
"ValueResult.Failed(\"argument\")",
|
||||
"ValueResult.Failed",
|
||||
),
|
||||
(
|
||||
"raceArgumentFault",
|
||||
"ValueResult.Failed(\"argument\")",
|
||||
"ValueResult.Failed",
|
||||
),
|
||||
(
|
||||
"invalidTimeout",
|
||||
"timeout(run(dever.task.sleep(60000)), 0",
|
||||
"timeout duration must be positive",
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(error.contains(message), "{entry}: {error}");
|
||||
assert!(
|
||||
error.contains(&format!("task-select.dever:{line}:{column}:")),
|
||||
"{entry}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
use super::Directory;
|
||||
use dever::compiler::testing::{self, Suite};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "testing/process.rs"]
|
||||
mod process;
|
||||
|
||||
fn project() -> Directory {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("module/demo/value");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
for (name, text) in [
|
||||
(
|
||||
"app",
|
||||
include_str!("../fixtures/testing/module/demo/value/app.dever"),
|
||||
),
|
||||
(
|
||||
"port",
|
||||
include_str!("../fixtures/testing/module/demo/value/port.dever"),
|
||||
),
|
||||
(
|
||||
"adapter",
|
||||
include_str!("../fixtures/testing/module/demo/value/adapter.dever"),
|
||||
),
|
||||
(
|
||||
"api",
|
||||
include_str!("../fixtures/testing/module/demo/value/api.dever"),
|
||||
),
|
||||
] {
|
||||
std::fs::write(root.join(format!("{name}.dever")), text).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(directory.0.join("test/demo/value")).unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
fn case(root: &Path, name: &str, body: &str, fake: Option<&str>) {
|
||||
let mut text = format!("{name}() () {{\n{body}\n}}\n");
|
||||
if let Some(fake) = fake {
|
||||
text.push_str(&format!(
|
||||
"port.read(value: Int) (answer: Int) {{\n{fake}\n}}\n"
|
||||
));
|
||||
if fake.contains("echo") {
|
||||
text.push_str("echo(value: Int) (answer: Int) { answer = value + 10 }\n");
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join(format!("test/demo/value/{name}.dever")), text).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_test_suite_isolates_fake_effects_reports_failures_and_continues_after_timeout() {
|
||||
let directory = project();
|
||||
case(
|
||||
&directory.0,
|
||||
"first",
|
||||
"assertEq(app.read(4), 5)",
|
||||
Some("answer = value + 1"),
|
||||
);
|
||||
case(
|
||||
&directory.0,
|
||||
"second",
|
||||
"assertEq(app.read(4), 14)",
|
||||
Some("answer = wait(run(echo(value)))"),
|
||||
);
|
||||
case(
|
||||
&directory.0,
|
||||
"third",
|
||||
"pending = run(dever.task.sleep(1000))\nassertEq(app.read(4), 99)\nwait(pending)",
|
||||
Some("answer = value"),
|
||||
);
|
||||
case(&directory.0, "timeout", "dever.time.sleep(500)", None);
|
||||
case(
|
||||
&directory.0,
|
||||
"valid",
|
||||
"key = secret(\"case input\")\ndigest = dever.crypto.hmacSha256(key, dever.bytes.fromText(\"x\"))\nassertEq(dever.bytes.length(digest), 32)\nassertEq(dever.test.drainJobs(10), 0)",
|
||||
None,
|
||||
);
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
assert_eq!(suite.select(Some("demo.value.second")).unwrap(), [1]);
|
||||
assert!(suite.select(Some("demo.valu")).is_err());
|
||||
let report = testing::run(&suite, None, Duration::from_millis(150)).unwrap();
|
||||
assert!(!report.success());
|
||||
assert_eq!(
|
||||
report
|
||||
.cases
|
||||
.iter()
|
||||
.map(|case| case.success)
|
||||
.collect::<Vec<_>>(),
|
||||
[true, true, false, false, true]
|
||||
);
|
||||
assert!(report.cases[2].stderr.contains("actual: 4\nexpected: 99"));
|
||||
assert!(report.cases[2].stderr.contains("third.dever:"));
|
||||
assert!(report.cases[3].timed_out);
|
||||
assert!(!directory.0.join("data").exists());
|
||||
let filtered = testing::run(&suite, Some("demo.value.second"), Duration::from_secs(3)).unwrap();
|
||||
assert!(filtered.success());
|
||||
assert_eq!(filtered.not_run, 4);
|
||||
assert_eq!(filtered.cases[0].index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_rejects_missing_fake_and_bad_unselected_case() {
|
||||
let directory = project();
|
||||
case(&directory.0, "first", "assertEq(app.read(4), 5)", None);
|
||||
let error = Suite::load(&directory.0).err().unwrap();
|
||||
assert!(
|
||||
error.contains("fake") || error.contains("production Adapter"),
|
||||
"{error}"
|
||||
);
|
||||
case(
|
||||
&directory.0,
|
||||
"first",
|
||||
"assertEq(app.read(4), 5)",
|
||||
Some("answer = value + 1"),
|
||||
);
|
||||
case(&directory.0, "second", "assert(42)", None);
|
||||
assert!(Suite::load(&directory.0).is_err());
|
||||
case(&directory.0, "second", "assertEq(1 +, 2)", None);
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("test")
|
||||
.arg(&directory.0)
|
||||
.args(["--filter", "demo.value.first"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("second.dever:"));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("PASS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooling_cli_formats_checks_and_executes_without_deployment_settings() {
|
||||
let directory = project();
|
||||
case(&directory.0, "simple", "assertEq(1 + 1, 2)", None);
|
||||
std::fs::create_dir(directory.0.join("config")).unwrap();
|
||||
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"postgres","host":"invalid.test","database":"production"}}}"#).unwrap();
|
||||
let cli = |arguments: &[&str]| {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg(arguments[0])
|
||||
.arg(&directory.0)
|
||||
.args(&arguments[1..])
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
assert!(!cli(&["fmt", "--check"]).status.success());
|
||||
let formatted = cli(&["fmt"]);
|
||||
assert!(
|
||||
formatted.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&formatted.stderr)
|
||||
);
|
||||
assert!(cli(&["fmt", "--check"]).status.success());
|
||||
let tested = cli(&[
|
||||
"test",
|
||||
"--filter",
|
||||
"demo.value.simple",
|
||||
"--timeout-ms",
|
||||
"3000",
|
||||
]);
|
||||
assert!(
|
||||
tested.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&tested.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&tested.stdout).contains("1 passed; 0 failed; 0 not run"));
|
||||
assert!(!cli(&["test", "--filter", "demo.missing"]).status.success());
|
||||
assert!(!cli(&["test", "--timeout-ms", "0"]).status.success());
|
||||
assert!(!directory.0.join("data").exists());
|
||||
std::fs::write(directory.0.join("dever.api"), "{}").unwrap();
|
||||
assert!(
|
||||
Suite::load(&directory.0).is_err(),
|
||||
"tests must honor the API baseline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helpers_cannot_leak_into_production_or_call_ports_directly() {
|
||||
let directory = project();
|
||||
case(
|
||||
&directory.0,
|
||||
"direct",
|
||||
"assertEq(port.read(4), 5)",
|
||||
Some("answer = value + 1"),
|
||||
);
|
||||
assert!(Suite::load(&directory.0).is_err());
|
||||
let source =
|
||||
dever::syntax::source::Source::new(0, "case.dever", "main() () { assert(true) }".into());
|
||||
let error = dever::compiler::check::check(
|
||||
vec![dever::syntax::parse(&source).unwrap()],
|
||||
&["main".into()],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_choice_helpers_use_the_same_type_contract() {
|
||||
let directory = project();
|
||||
case(
|
||||
&directory.0,
|
||||
"local",
|
||||
"assertEq(choose(State.Idle), 0)\nassertEq(choose(State.Ready), 1)",
|
||||
None,
|
||||
);
|
||||
let path = directory.0.join("test/demo/value/local.dever");
|
||||
let mut text = std::fs::read_to_string(&path).unwrap();
|
||||
text.push_str("type State { Idle\nReady }\n\nchoose(value: State.Idle) (answer: Int) { answer = 0 }\nchoose(value: State.Ready) (answer: Int) { answer = 1 }\n");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
assert_eq!(suite.cases.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_can_implement_multiple_ports_but_must_complete_each_contract() {
|
||||
let directory = project();
|
||||
let module = directory.0.join("module/demo/value");
|
||||
let port = std::fs::read_to_string(module.join("port.dever")).unwrap();
|
||||
let adapter = std::fs::read_to_string(module.join("adapter.dever")).unwrap();
|
||||
for role in ["port", "adapter"] {
|
||||
std::fs::remove_file(module.join(format!("{role}.dever"))).unwrap();
|
||||
std::fs::create_dir(module.join(role)).unwrap();
|
||||
}
|
||||
for topic in ["first", "second"] {
|
||||
std::fs::write(
|
||||
module.join(format!("port/{topic}.dever")),
|
||||
port.replace("demo.value.port`", &format!("demo.value.port.{topic}`")),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
module.join(format!("adapter/{topic}.dever")),
|
||||
adapter
|
||||
.replace(
|
||||
"demo.value.adapter`",
|
||||
&format!("demo.value.adapter.{topic}`"),
|
||||
)
|
||||
.replace("port.read", &format!("port.{topic}.read")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let app = module.join("app.dever");
|
||||
let text = std::fs::read_to_string(&app).unwrap();
|
||||
std::fs::write(
|
||||
&app,
|
||||
text.replace(
|
||||
"port.read(value)",
|
||||
"port.first.read(value) + port.second.read(value)",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
case(
|
||||
&directory.0,
|
||||
"multiple",
|
||||
"assertEq(app.read(4), 11)",
|
||||
Some("answer = value + 1"),
|
||||
);
|
||||
let path = directory.0.join("test/demo/value/multiple.dever");
|
||||
let first = std::fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.replace("port.read", "port.first.read");
|
||||
std::fs::write(&path, &first).unwrap();
|
||||
assert!(
|
||||
Suite::load(&directory.0).is_err(),
|
||||
"one missing fake must not use production"
|
||||
);
|
||||
let section = first[first.find("port.first.read(").unwrap()..]
|
||||
.replace("port.first.read", "port.second.read")
|
||||
.replace("value + 1", "value + 2");
|
||||
std::fs::write(&path, format!("{first}\n{section}")).unwrap();
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
let report = testing::run(&suite, None, Duration::from_secs(5)).unwrap();
|
||||
assert!(report.success(), "{}", report.cases[0].stderr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_test_jobs_use_isolated_databases_virtual_clock_and_production_rollback() {
|
||||
let directory = Directory::new();
|
||||
std::fs::create_dir(directory.0.join("config")).unwrap();
|
||||
std::fs::write(directory.0.join("config/setting.json"), r#"{"database":{"default":{"type":"postgres","host":"invalid.test","database":"production"}}}"#).unwrap();
|
||||
let source_root =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/job/module/demo/queue");
|
||||
let module = directory.0.join("module/demo/queue");
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
for name in ["api", "app", "job", "model"] {
|
||||
std::fs::copy(
|
||||
source_root.join(format!("{name}.dever")),
|
||||
module.join(format!("{name}.dever")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(directory.0.join("test/demo/queue")).unwrap();
|
||||
std::fs::write(
|
||||
directory.0.join("test/demo/queue/idle.dever"),
|
||||
"idle() () { assertEq(app.count(), 0) }\n",
|
||||
)
|
||||
.unwrap();
|
||||
for topic in ["first", "second"] {
|
||||
let body = "assertEq(app.count(), 0)\nfirst = app.submit(\"ok\", \"one\")\nsecond = app.submit(\"fail\", \"bad\")\nassert(first != second)\nassertEq(dever.test.drainJobs(10), 2)\nassertEq(app.count(), 1)\nassertEq(dever.test.drainJobs(10), 0)\nbefore = dever.time.now()\ndever.test.advanceClock(1000)\nassert(dever.time.now() != before)\nassertEq(dever.test.drainJobs(10), 1)\nassertEq(app.count(), 1)";
|
||||
let text = format!("{topic}() () {{\n{body}\n}}\n");
|
||||
std::fs::write(
|
||||
directory.0.join(format!("test/demo/queue/{topic}.dever")),
|
||||
text,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
let report = testing::run(&suite, None, Duration::from_secs(10)).unwrap();
|
||||
for case in &report.cases {
|
||||
assert!(case.success, "{}: {}", case.name, case.stderr);
|
||||
}
|
||||
assert_eq!(report.cases.len(), 3);
|
||||
assert!(!directory.0.join("data").exists());
|
||||
for topic in ["first", "idle", "second"] {
|
||||
std::fs::remove_file(directory.0.join(format!("test/demo/queue/{topic}.dever"))).unwrap();
|
||||
}
|
||||
let job = module.join("job.dever");
|
||||
let source = std::fs::read_to_string(&job).unwrap();
|
||||
std::fs::write(job, format!("{source}\njob scheduled() () retry(1) timeout(1000) {{ app.apply(app.Payload {{ name = \"scheduled\" }}) }}\n\nschedule scheduled = \"* * * * *\"\n")).unwrap();
|
||||
let app = module.join("app.dever");
|
||||
let source = std::fs::read_to_string(&app).unwrap();
|
||||
std::fs::write(app, format!("{source}\nsubmitEmpty(key: Text) (id: Id) {{ id = dever.job.enqueue(job.scheduled, key) }}\n")).unwrap();
|
||||
std::fs::write(directory.0.join("test/demo/queue/cron.dever"), "cron() () {\n assertEq(dever.test.drainJobs(1), 1)\n assertEq(dever.test.drainJobs(1), 0)\n dever.test.advanceClock(60000)\n assertEq(dever.test.drainJobs(1), 1)\n assertEq(app.count(), 2)\n dever.test.advanceClock(180000)\n assertEq(dever.test.drainJobs(2), 2)\n assertEq(dever.test.drainJobs(2), 1)\n assertEq(dever.test.drainJobs(2), 0)\n assertEq(app.count(), 5)\n first = app.submitEmpty(\"manual\")\n second = app.submitEmpty(\"manual\")\n assertEq(first, second)\n assertEq(dever.test.drainJobs(2), 1)\n assertEq(app.count(), 6)\n}\n").unwrap();
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
let report = testing::run(&suite, None, Duration::from_secs(10)).unwrap();
|
||||
assert!(report.success(), "{}", report.cases[0].stderr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_test_jobs_from_protected_http_application_use_system_identity() {
|
||||
let directory = Directory::new();
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/auth/module");
|
||||
let mut pending = vec![fixture.clone()];
|
||||
while let Some(source) = pending.pop() {
|
||||
let destination = directory
|
||||
.0
|
||||
.join("module")
|
||||
.join(source.strip_prefix(&fixture).unwrap());
|
||||
std::fs::create_dir_all(&destination).unwrap();
|
||||
for entry in std::fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else {
|
||||
std::fs::copy(entry.path(), destination.join(entry.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let domain = directory.0.join("module/demo/account");
|
||||
let jobs = domain.join("app/jobs.dever");
|
||||
let source = std::fs::read_to_string(&jobs).unwrap();
|
||||
std::fs::write(
|
||||
&jobs,
|
||||
source.replace("seed(input.subject,", "seed(port.identity(input.subject),"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
domain.join("port.dever"),
|
||||
"identity(value: Text) (answer: Text) fails dever.api.Error\n",
|
||||
)
|
||||
.unwrap();
|
||||
let implementation = "port.identity(value: Text) (answer: Text) { answer = value }\n";
|
||||
std::fs::write(domain.join("adapter.dever"), implementation).unwrap();
|
||||
let production = dever::compiler::project::Project::load(&directory.0)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
assert!(production.has_roles());
|
||||
assert!(!production.jobs.is_empty());
|
||||
std::fs::create_dir_all(directory.0.join("test/demo/account")).unwrap();
|
||||
std::fs::write(directory.0.join("test/demo/account/jobs.dever"), "jobs() () {\n assertEq(app.jobCount(), 0)\n first = app.jobSubmit(\"from-test\", \"once\")\n second = app.jobSubmit(\"from-test\", \"once\")\n assertEq(first, second)\n assertEq(dever.test.drainJobs(3), 1)\n assertEq(app.jobCount(), 1)\n}\n").unwrap();
|
||||
let test = directory.0.join("test/demo/account/jobs.dever");
|
||||
let source = std::fs::read_to_string(&test).unwrap().replace("assertEq(app.jobCount(), 1)", "assertEq(app.jobCount(), 1)\n verified = app.verify(dever.auth.Claims { subject = \"fake-from-test\", session = \"from-job\", site = \"unused\", tenant = null })\n assertEq(verified.id, \"fake-from-test\")");
|
||||
std::fs::write(
|
||||
test,
|
||||
format!(
|
||||
"{source}\n{}",
|
||||
implementation.replace("answer = value", "answer = \"fake-\" + value")
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let suite = Suite::load(&directory.0).unwrap();
|
||||
let report = testing::run(&suite, None, Duration::from_secs(10)).unwrap();
|
||||
assert!(report.success(), "{}", report.cases[0].stderr);
|
||||
assert!(!directory.0.join("data").exists());
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 真子进程覆盖输出上限、后代持有管道和正常/超时两种回收路径。
|
||||
|
||||
use super::Directory;
|
||||
use dever::process::testing::capture;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_process_timeout_reaps_only_its_owned_group() {
|
||||
let directory = Directory::new();
|
||||
let mut unrelated = std::process::Command::new("/bin/sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let output = capture(
|
||||
Path::new("/bin/sh"),
|
||||
&[
|
||||
"-c".into(),
|
||||
"/bin/sh -c 'sleep 30 & echo $!; wait' & echo $!; wait".into(),
|
||||
],
|
||||
&directory.0,
|
||||
Duration::from_millis(250),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(output.timed_out);
|
||||
assert_eq!(output.stdout.lines().count(), 2);
|
||||
for descendant in output.stdout.lines() {
|
||||
let descendant: u32 = descendant.parse().unwrap();
|
||||
assert!(!Path::new(&format!("/proc/{descendant}")).exists());
|
||||
}
|
||||
assert!(unrelated.try_wait().unwrap().is_none());
|
||||
unrelated.kill().unwrap();
|
||||
unrelated.wait().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_success_reaps_descendants_before_joining_output_readers() {
|
||||
let directory = Directory::new();
|
||||
let output = capture(
|
||||
Path::new("/bin/sh"),
|
||||
&["-c".into(), "/bin/sh -c 'sleep 30 & echo $! > grandchild; wait' & echo $!; while [ ! -s grandchild ]; do :; done; /bin/cat grandchild".into()],
|
||||
&directory.0,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(!output.timed_out);
|
||||
assert_eq!(output.stdout.lines().count(), 2);
|
||||
for descendant in output.stdout.lines() {
|
||||
let descendant: u32 = descendant.parse().unwrap();
|
||||
assert!(!Path::new(&format!("/proc/{descendant}")).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_noisy_output_stays_bounded_without_disk_capture() {
|
||||
let directory = Directory::new();
|
||||
let output = capture(
|
||||
Path::new("/bin/sh"),
|
||||
&[
|
||||
"-c".into(),
|
||||
"echo $$; /bin/cat /dev/zero & /bin/cat /dev/zero >&2 & wait".into(),
|
||||
],
|
||||
&directory.0,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(output.timed_out);
|
||||
let marker = "\n[output truncated after 1 MiB]\n";
|
||||
for stream in [&output.stdout, &output.stderr] {
|
||||
assert_eq!(stream.len(), 1_048_576 + marker.len());
|
||||
assert!(stream.ends_with(marker));
|
||||
}
|
||||
let descendant: u32 = output.stdout.lines().next().unwrap().parse().unwrap();
|
||||
assert!(!Path::new(&format!("/proc/{descendant}")).exists());
|
||||
assert_eq!(std::fs::read_dir(&directory.0).unwrap().count(), 0);
|
||||
// 启动失败也释放进程监管状态,同一目录可继续执行下一例。
|
||||
assert!(
|
||||
capture(
|
||||
Path::new("/missing-dever-test"),
|
||||
&[],
|
||||
&directory.0,
|
||||
Duration::from_secs(1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let next = capture(
|
||||
Path::new("/bin/true"),
|
||||
&[],
|
||||
&directory.0,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(next.status.success());
|
||||
assert!(!next.timed_out);
|
||||
assert!(next.stdout.is_empty() && next.stderr.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::compiler::{build, project::Project};
|
||||
|
||||
#[test]
|
||||
fn concatenation_preserves_values_and_failure_order() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("text.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for (entry, expected) in [
|
||||
("verify", None),
|
||||
(
|
||||
"recordFailureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"failureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"faultEntry",
|
||||
Some(("integer arithmetic fault", "value + 1")),
|
||||
),
|
||||
(
|
||||
"argumentFailureEntry",
|
||||
Some(("Problem.Denied", "Problem.Denied(\"first\")")),
|
||||
),
|
||||
(
|
||||
"identityFaultEntry",
|
||||
Some((
|
||||
"integer arithmetic fault",
|
||||
"9223372036854775807 + length(value)",
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let output = std::process::Command::new(binary).output().unwrap();
|
||||
if let Some((message, needle)) = expected {
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let source = &project.sources[0];
|
||||
let (line, column) = source.position(source.text.find(needle).unwrap());
|
||||
assert!(stderr.contains(message), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(&format!("text.dever:{line}:{column}:")),
|
||||
"{stderr}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::Directory;
|
||||
use dever::compiler::{project::Project, toolchain::BuildTarget};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
mod packaging;
|
||||
mod skill;
|
||||
|
||||
fn cli(arguments: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn success(output: Output) -> String {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_plain_source_and_separate_docs_without_overwriting() {
|
||||
let directory = Directory::new();
|
||||
for name in ["plain", "second"] {
|
||||
let root = directory.0.join(name);
|
||||
let text = root.to_str().unwrap();
|
||||
let arguments = vec!["new", text];
|
||||
success(cli(&arguments));
|
||||
let suffix = "dever";
|
||||
assert!(
|
||||
root.join(format!("module/hello/greeting/app.{suffix}"))
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
root.join(format!("test/hello/greeting/greet.{suffix}"))
|
||||
.is_file()
|
||||
);
|
||||
assert!(root.join("module/hello/greeting/README.md").is_file());
|
||||
assert!(root.join("module/hello/greeting/doc/greeting.md").is_file());
|
||||
let project = Project::load(&root).unwrap();
|
||||
assert_eq!(project.check().unwrap().commands.len(), 1);
|
||||
success(cli(&["check", text]));
|
||||
success(cli(&["fmt", text, "--check"]));
|
||||
let snapshot = std::fs::read(root.join("README.md")).unwrap();
|
||||
assert!(!cli(&arguments).status.success());
|
||||
assert_eq!(std::fs::read(root.join("README.md")).unwrap(), snapshot);
|
||||
}
|
||||
let path = directory.0.join("invalid");
|
||||
assert!(
|
||||
!cli(&["new", path.to_str().unwrap(), "--markdown"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(!path.exists());
|
||||
let link = directory.0.join("link");
|
||||
std::os::unix::fs::symlink(directory.0.join("plain"), &link).unwrap();
|
||||
let rejected = cli(&["new", link.to_str().unwrap()]);
|
||||
assert!(!rejected.status.success());
|
||||
assert!(String::from_utf8_lossy(&rejected.stderr).contains(link.to_str().unwrap()));
|
||||
assert_eq!(
|
||||
std::fs::read_link(&link).unwrap(),
|
||||
directory.0.join("plain")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_runs_tests_and_builds_a_real_native_executable_for_the_selected_target() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let root = root.to_str().unwrap();
|
||||
success(cli(&["test", root]));
|
||||
let result = success(cli(&[
|
||||
"run",
|
||||
root,
|
||||
"--",
|
||||
"hello.greeting.greet",
|
||||
r#"{"name":"Dever"}"#,
|
||||
]));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap()["data"],
|
||||
"Hello, Dever"
|
||||
);
|
||||
let binary = directory.0.join("hello");
|
||||
success(cli(&[
|
||||
"build",
|
||||
root,
|
||||
"--output",
|
||||
binary.to_str().unwrap(),
|
||||
"--target",
|
||||
BuildTarget::host().unwrap().name(),
|
||||
]));
|
||||
BuildTarget::host()
|
||||
.unwrap()
|
||||
.validate_executable(&std::fs::read(&binary).unwrap())
|
||||
.unwrap();
|
||||
let result = success(
|
||||
Command::new(&binary)
|
||||
.args(["hello.greeting.greet", r#"{"name":"native"}"#])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap()["data"],
|
||||
"Hello, native"
|
||||
);
|
||||
let before = std::fs::read(&binary).unwrap();
|
||||
assert!(
|
||||
!cli(&["build", root, "--output", binary.to_str().unwrap()])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(std::fs::read(&binary).unwrap(), before);
|
||||
assert!(
|
||||
!cli(&["run", root, "--target", "linux-aarch64"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
!cli(&[
|
||||
"build",
|
||||
root,
|
||||
"--output",
|
||||
binary.to_str().unwrap(),
|
||||
"--target",
|
||||
"unsupported"
|
||||
])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elf_validation_checks_architecture_and_executable_type() {
|
||||
let mut header = vec![0_u8; 64];
|
||||
header[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
header[16..18].copy_from_slice(&2_u16.to_le_bytes());
|
||||
header[18..20].copy_from_slice(&183_u16.to_le_bytes());
|
||||
assert!(
|
||||
BuildTarget::LinuxAarch64
|
||||
.validate_executable(&header)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
BuildTarget::LinuxX86_64
|
||||
.validate_executable(&header)
|
||||
.is_err()
|
||||
);
|
||||
header[16..18].copy_from_slice(&1_u16.to_le_bytes());
|
||||
assert!(
|
||||
BuildTarget::LinuxAarch64
|
||||
.validate_executable(&header)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
BuildTarget::LinuxAarch64
|
||||
.validate_executable(b"short")
|
||||
.is_err()
|
||||
);
|
||||
assert!(BuildTarget::parse("linux-x86_64").is_ok());
|
||||
assert!(BuildTarget::parse("esp32").is_err());
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, destination: &Path) {
|
||||
if source.is_dir() {
|
||||
std::fs::create_dir_all(destination).unwrap();
|
||||
for entry in std::fs::read_dir(source)
|
||||
.unwrap_or_else(|error| panic!("{}: {error}", source.display()))
|
||||
{
|
||||
let entry = entry.unwrap();
|
||||
copy_tree(&entry.path(), &destination.join(entry.file_name()));
|
||||
}
|
||||
} else {
|
||||
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
std::fs::copy(source, destination).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::{BuildTarget, Directory, copy_tree, success};
|
||||
use dever::compiler::toolchain::packaging;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn unsigned_bootstrap_relocates_builds_and_rejects_changed_inputs() {
|
||||
let directory = Directory::new();
|
||||
let author = directory.0.join("author");
|
||||
let source = author.join("source");
|
||||
let checkout = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
for path in [
|
||||
"rust/Cargo.toml",
|
||||
"rust/Cargo.lock",
|
||||
"rust/rust-toolchain.toml",
|
||||
"rust/src",
|
||||
"lib",
|
||||
"skills",
|
||||
"LANGUAGE.md",
|
||||
"DOCUMENTATION.md",
|
||||
] {
|
||||
copy_tree(&checkout.join(path), &source.join(path));
|
||||
}
|
||||
copy_tree(
|
||||
Path::new(env!("CARGO_BIN_EXE_dever")),
|
||||
&author.join("bin/dever"),
|
||||
);
|
||||
std::fs::create_dir(author.join("config")).unwrap();
|
||||
let compiler = std::fs::read(author.join("bin/dever")).unwrap();
|
||||
let mut input = serde_json::json!({
|
||||
"format": "dever-local-bootstrap-input-v1", "version": env!("CARGO_PKG_VERSION"),
|
||||
"target": BuildTarget::host().unwrap().name(), "source": "source",
|
||||
"core": {"source": "bin/dever", "sha256": format!("{:x}", Sha256::digest(&compiler))}
|
||||
});
|
||||
let settings = author.join("config/setting.json");
|
||||
std::fs::write(&settings, serde_json::to_vec(&input).unwrap()).unwrap();
|
||||
let bundle = directory.0.join("bootstrap");
|
||||
success(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args([
|
||||
"release",
|
||||
author.to_str().unwrap(),
|
||||
"--output",
|
||||
bundle.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
let manifest = packaging::verify(&bundle).unwrap();
|
||||
assert!(!manifest.signed);
|
||||
assert!(manifest.artifacts.len() > 100);
|
||||
assert!(!bundle.join("config").exists());
|
||||
assert!(packaging::create(&author, &source.join("rust/src/recursive")).is_err());
|
||||
assert!(!source.join("rust/src/recursive").exists());
|
||||
assert!(packaging::create(&author, &bundle).is_err());
|
||||
packaging::verify(&bundle).unwrap();
|
||||
|
||||
let rejected = directory.0.join("rejected");
|
||||
input["core"]["sha256"] = "0".repeat(64).into();
|
||||
std::fs::write(&settings, serde_json::to_vec(&input).unwrap()).unwrap();
|
||||
assert!(packaging::create(&author, &rejected).is_err());
|
||||
assert!(!rejected.exists());
|
||||
input["core"]["sha256"] = format!("{:x}", Sha256::digest(&compiler)).into();
|
||||
input["source"] = "../author/source".into();
|
||||
std::fs::write(&settings, serde_json::to_vec(&input).unwrap()).unwrap();
|
||||
assert!(packaging::create(&author, &rejected).is_err());
|
||||
|
||||
// 作者输入移除并移动包后,实际 CLI 构建必须使用包内 runtime。
|
||||
std::fs::remove_dir_all(&author).unwrap();
|
||||
let moved = directory.0.join("moved");
|
||||
std::fs::rename(&bundle, &moved).unwrap();
|
||||
let cli = moved.join("bin/dever");
|
||||
let project = directory.0.join("project");
|
||||
success(
|
||||
Command::new(&cli)
|
||||
.args(["new", project.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
success(
|
||||
Command::new(&cli)
|
||||
.args(["check", project.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
let selected = success(
|
||||
Command::new(&cli)
|
||||
.args(["skill", "path", project.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(selected.trim(), moved.join("skills").to_str().unwrap());
|
||||
let agents = project.join("AGENTS.md");
|
||||
std::fs::write(&agents, "# 保留项目规则\n").unwrap();
|
||||
success(
|
||||
Command::new(&cli)
|
||||
.current_dir(&project)
|
||||
.args(["skill", "install"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
let installed = std::fs::read(&agents).unwrap();
|
||||
assert!(String::from_utf8_lossy(&installed).starts_with("# 保留项目规则\n"));
|
||||
success(
|
||||
Command::new(&cli)
|
||||
.current_dir(&project)
|
||||
.args(["skill", "install"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(std::fs::read(&agents).unwrap(), installed);
|
||||
// 包内规则被篡改或移除时不能回退到作者源码,也不能修改项目文件。
|
||||
let template = moved.join("skills/assets/template/AGENTS.md");
|
||||
let original = std::fs::read(&template).unwrap();
|
||||
std::fs::write(&template, "tampered").unwrap();
|
||||
let failed = Command::new(&cli)
|
||||
.current_dir(&project)
|
||||
.args(["skill", "install"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("artifact changed"));
|
||||
assert_eq!(std::fs::read(&agents).unwrap(), installed);
|
||||
std::fs::remove_file(&template).unwrap();
|
||||
assert!(
|
||||
!Command::new(&cli)
|
||||
.current_dir(&project)
|
||||
.args(["skill", "path", "."])
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(std::fs::read(&agents).unwrap(), installed);
|
||||
std::fs::write(&template, original).unwrap();
|
||||
let binary = directory.0.join("hello");
|
||||
success(
|
||||
Command::new(&cli)
|
||||
.args([
|
||||
"build",
|
||||
project.to_str().unwrap(),
|
||||
"--output",
|
||||
binary.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(moved.join("rust/target/generated").is_dir());
|
||||
let output = success(
|
||||
Command::new(&binary)
|
||||
.args(["hello.greeting.greet", r#"{"name":"relocated"}"#])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&output).unwrap()["data"],
|
||||
"Hello, relocated"
|
||||
);
|
||||
packaging::verify(&moved).unwrap();
|
||||
// 连同已生成的 Cargo 缓存再次搬移,不能保留旧 runtime 绝对路径。
|
||||
let relocated = directory.0.join("relocated");
|
||||
std::fs::rename(&moved, &relocated).unwrap();
|
||||
success(
|
||||
Command::new(relocated.join("bin/dever"))
|
||||
.args(["build", project.to_str().unwrap(), "--output"])
|
||||
.arg(directory.0.join("rebuilt"))
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
std::fs::rename(&relocated, &moved).unwrap();
|
||||
std::fs::write(moved.join("rust/src/runtime/lib.rs"), "tampered").unwrap();
|
||||
assert!(packaging::verify(&moved).is_err());
|
||||
let failed = Command::new(&cli)
|
||||
.args([
|
||||
"build",
|
||||
project.to_str().unwrap(),
|
||||
"--output",
|
||||
directory.0.join("tampered").to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("artifact changed"));
|
||||
assert!(!directory.0.join("tampered").exists());
|
||||
std::fs::remove_file(moved.join("bootstrap.json")).unwrap();
|
||||
let missing = Command::new(&cli)
|
||||
.args([
|
||||
"build",
|
||||
project.to_str().unwrap(),
|
||||
"--output",
|
||||
directory.0.join("missing").to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(String::from_utf8_lossy(&missing.stderr).contains("bootstrap.json"));
|
||||
assert!(!directory.0.join("missing").exists());
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 通过真实 CLI 验证规则来源、原子合并及拒绝输入;只操作独占临时项目。
|
||||
|
||||
use super::{Directory, success};
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(root: &Path, arguments: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.current_dir(root)
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn template() -> String {
|
||||
std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../skills/assets/template/AGENTS.md"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_uses_current_toolchain_without_project_version_or_mutation() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let settings = root.join("config/setting.json");
|
||||
let original = std::fs::read(&settings).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
assert!(value.get("dever").is_none());
|
||||
let agents = std::fs::read(root.join("AGENTS.md")).unwrap();
|
||||
let expected = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
for (cwd, arguments) in [
|
||||
(root.as_path(), vec!["skill", "path", "."]),
|
||||
(root.as_path(), vec!["skill", "path"]),
|
||||
(directory.0.as_path(), vec!["skill", "path"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
success(cli(cwd, &arguments)).trim(),
|
||||
expected.to_str().unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&settings).unwrap(), original);
|
||||
assert_eq!(std::fs::read(root.join("AGENTS.md")).unwrap(), agents);
|
||||
// 查询与安装都不创建项目配置,更不引入项目版本锁定。
|
||||
std::fs::remove_file(&settings).unwrap();
|
||||
success(cli(&root, &["skill", "path", "."]));
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
assert!(!settings.exists());
|
||||
assert!(!cli(&directory.0, &["skill", "path", "."]).status.success());
|
||||
assert!(!cli(&directory.0, &["skill", "install"]).status.success());
|
||||
assert!(!directory.0.join("AGENTS.md").exists());
|
||||
assert!(!cli(&root, &["skill", "install", "."]).status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_preserves_unmarked_content_permissions_and_is_idempotent() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let agents = root.join("AGENTS.md");
|
||||
std::fs::remove_file(&agents).unwrap();
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
assert_eq!(std::fs::read_to_string(&agents).unwrap(), template());
|
||||
assert_eq!(std::fs::metadata(&agents).unwrap().mode() & 0o777, 0o644);
|
||||
|
||||
let prefix = "# 项目规则\r\n保留这些内容。";
|
||||
std::fs::write(&agents, prefix).unwrap();
|
||||
std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
let installed = format!("{prefix}\n\n{}", template());
|
||||
assert_eq!(std::fs::read_to_string(&agents).unwrap(), installed);
|
||||
let before = std::fs::metadata(&agents).unwrap();
|
||||
assert_eq!(before.mode() & 0o777, 0o640);
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
let after = std::fs::metadata(&agents).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&agents).unwrap(), installed);
|
||||
assert_eq!(before.ino(), after.ino());
|
||||
assert_eq!(before.modified().unwrap(), after.modified().unwrap());
|
||||
|
||||
let prefix = "保留头部\r\n\r\n";
|
||||
let suffix = "\r\n\r\n保留尾部\r\n";
|
||||
std::fs::write(
|
||||
&agents,
|
||||
format!(
|
||||
"{prefix}<!-- dever-language:start -->\r\n旧规则\r\n<!-- dever-language:end -->{suffix}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&agents).unwrap(),
|
||||
format!("{prefix}{}{suffix}", template().trim_end_matches('\n'))
|
||||
);
|
||||
|
||||
// 同一项目的并发安装不得附加两份规则或留下暂存文件。
|
||||
std::fs::remove_file(&agents).unwrap();
|
||||
let mut first = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.current_dir(&root)
|
||||
.args(["skill", "install"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
success(cli(&root, &["skill", "install"]));
|
||||
assert!(first.wait().unwrap().success());
|
||||
assert_eq!(std::fs::read_to_string(&agents).unwrap(), template());
|
||||
assert!(!std::fs::read_dir(&root).unwrap().any(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-skill-")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_malformed_markers_and_links_without_mutation() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let agents = root.join("AGENTS.md");
|
||||
let start = "<!-- dever-language:start -->";
|
||||
let end = "<!-- dever-language:end -->";
|
||||
for text in [
|
||||
start.to_owned(),
|
||||
end.to_owned(),
|
||||
format!("{end}\n{start}"),
|
||||
format!("{start}\n{end}\n{start}\n{end}"),
|
||||
format!("{start}\n{start}\n{end}"),
|
||||
format!("{start}\n{end}\n{end}"),
|
||||
"<!-- dever-language:unknown -->".into(),
|
||||
format!("inline {start}\n{end}"),
|
||||
] {
|
||||
std::fs::write(&agents, &text).unwrap();
|
||||
assert!(
|
||||
!cli(&root, &["skill", "install"]).status.success(),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&agents).unwrap(), text);
|
||||
}
|
||||
std::fs::write(&agents, [0xff, 0xfe]).unwrap();
|
||||
assert!(!cli(&root, &["skill", "install"]).status.success());
|
||||
assert_eq!(std::fs::read(&agents).unwrap(), [0xff, 0xfe]);
|
||||
std::fs::remove_file(&agents).unwrap();
|
||||
let target = directory.0.join("private");
|
||||
std::fs::write(&target, "untouched").unwrap();
|
||||
symlink(&target, &agents).unwrap();
|
||||
assert!(!cli(&root, &["skill", "install"]).status.success());
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "untouched");
|
||||
assert!(std::fs::symlink_metadata(&agents).unwrap().is_symlink());
|
||||
std::fs::remove_file(&agents).unwrap();
|
||||
std::fs::create_dir(&agents).unwrap();
|
||||
assert!(!cli(&root, &["skill", "install"]).status.success());
|
||||
assert!(agents.is_dir());
|
||||
let linked = directory.0.join("linked");
|
||||
symlink(&root, &linked).unwrap();
|
||||
assert!(
|
||||
!cli(&directory.0, &["skill", "path", linked.to_str().unwrap()])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(!std::fs::read_dir(&root).unwrap().any(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-skill-")
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::{Directory, fixture};
|
||||
use dever::{
|
||||
compiler::{build, project::Project},
|
||||
syntax::source::Source,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn native_uuid_values_and_maps_use_only_the_uuid_feature() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("uuid.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "verify")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &program.reachable([main])),
|
||||
["uuid"]
|
||||
);
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"verify",
|
||||
&directory.0.join("verify"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infallible_batches_preserve_nullable_values_and_pending_task_cleanup() {
|
||||
let directory = Directory::new();
|
||||
let project = Project::load(&fixture().with_file_name("uuid.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
for entry in ["batchVerify", "taskVerify"] {
|
||||
let binary =
|
||||
build::build(&program, &project.sources, entry, &directory.0.join(entry)).unwrap();
|
||||
let result = std::process::Command::new(binary).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{entry}: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.stdout, b"true\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_command_fields_normalize_and_reject_noncanonical_input() {
|
||||
let directory = Directory::new();
|
||||
let mut project = Project::load(&fixture().with_file_name("application")).unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("greeting/app.dever"))
|
||||
.unwrap();
|
||||
let text = source.text.replace("id: Id", "id: Uuid");
|
||||
*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("commands"),
|
||||
)
|
||||
.unwrap();
|
||||
let input =
|
||||
r#"{"value":{"id":"550E8400-E29B-41D4-A716-446655440000","name":"雪","scores":[]}}"#;
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.greeting.echo", input])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
"{\"code\":0,\"message\":\"ok\",\"data\":{\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"name\":\"雪\",\"scores\":[],\"ratio\":null}}\n"
|
||||
);
|
||||
for value in [
|
||||
"550e8400e29b41d4a716446655440000",
|
||||
"00000000-0000-0000-0000-00000000000-",
|
||||
] {
|
||||
let input = input.replace("550E8400-E29B-41D4-A716-446655440000", value);
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.greeting.echo", &input])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("invalid command input"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user