306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
mod support;
|
|
|
|
use std::fs::{self, OpenOptions};
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use dever_core::source::SourceMap;
|
|
use dever_runtime::config::Settings;
|
|
use serde_json::{Value, json};
|
|
|
|
fn copy_directory(source: &Path, destination: &Path) {
|
|
fs::create_dir(destination).unwrap();
|
|
for entry in fs::read_dir(source).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let path = entry.path();
|
|
let target = destination.join(entry.file_name());
|
|
let kind = entry.file_type().unwrap();
|
|
if kind.is_dir() {
|
|
copy_directory(&path, &target);
|
|
} else if kind.is_file() {
|
|
fs::copy(path, target).unwrap();
|
|
} else {
|
|
panic!("CMS source must contain only regular files and directories");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn append_fixture(path: &Path, source: &str) {
|
|
let mut file = OpenOptions::new().append(true).open(path).unwrap();
|
|
file.write_all(b"\n").unwrap();
|
|
file.write_all(source.as_bytes()).unwrap();
|
|
}
|
|
|
|
fn copy_cms_project(workspace: &Path, project: &Path) {
|
|
let source = workspace.join("examples/cms/dever");
|
|
fs::create_dir(project).unwrap();
|
|
for directory in ["config", "module", "test"] {
|
|
copy_directory(&source.join(directory), &project.join(directory));
|
|
}
|
|
append_fixture(
|
|
&project.join("module/user/account/app.dever"),
|
|
include_str!("../fixtures/postgres_api/account_app.dever"),
|
|
);
|
|
append_fixture(
|
|
&project.join("module/user/membership/app.dever"),
|
|
include_str!("../fixtures/postgres_api/membership_app.dever"),
|
|
);
|
|
append_fixture(
|
|
&project.join("module/user/account/api.dever"),
|
|
include_str!("../fixtures/postgres_api/account_api.dever"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_member_fixture_checks_without_a_database() {
|
|
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
let temporary = support::temp::TemporaryDirectory::new();
|
|
let project = temporary.path().join("project");
|
|
copy_cms_project(&workspace, &project);
|
|
let sources = SourceMap::load_project(&project.join("module"), &project.join("test")).unwrap();
|
|
let settings = Settings::load_project(&project).unwrap();
|
|
let program = dever_core::check_with_settings(&sources, &settings).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
let application_errors = program.application_errors().unwrap();
|
|
assert!(application_errors.is_empty(), "{application_errors:?}");
|
|
assert!(program.api_snapshot().contains("fixture_member"));
|
|
}
|
|
|
|
fn save_report(workspace: &Path, report: &Value) -> Result<PathBuf, String> {
|
|
let directory = workspace.join("target/performance/postgres-http-09-30-v1");
|
|
fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
|
|
let path = if directory.join("report.json").exists() {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|error| error.to_string())?
|
|
.as_nanos();
|
|
directory.join(format!("report-{}-{nanos}.json", std::process::id()))
|
|
} else {
|
|
directory.join("report.json")
|
|
};
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)
|
|
.map_err(|error| error.to_string())?;
|
|
let document = serde_json::to_vec_pretty(report).map_err(|error| error.to_string())?;
|
|
file.write_all(&document)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn result_directory(workspace: &Path) -> PathBuf {
|
|
let parent = workspace.join("target/performance/postgres-http-09-30-v1");
|
|
fs::create_dir_all(&parent).unwrap();
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let run = parent.join(format!("run-{}-{nanos}", std::process::id()));
|
|
let mut builder = fs::DirBuilder::new();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::DirBuilderExt;
|
|
builder.mode(0o700);
|
|
}
|
|
builder.create(&run).unwrap();
|
|
run.join("result")
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires an isolated PostgreSQL instance configured in config/setting.json"]
|
|
fn postgres_cms_http_isolates_two_tenant_databases() {
|
|
let summary = support::postgres::run_in_isolated_databases("postgres_api", |setting| {
|
|
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../..")
|
|
.canonicalize()
|
|
.unwrap();
|
|
let compiler = workspace.join("target/debug/dever");
|
|
assert!(
|
|
compiler.is_file(),
|
|
"build the current dever before PostgreSQL acceptance"
|
|
);
|
|
let temporary = support::temp::TemporaryDirectory::new();
|
|
let project = temporary.path().join("project");
|
|
copy_cms_project(&workspace, &project);
|
|
let settings_path = project.join("config/setting.json");
|
|
let mut settings: Value =
|
|
serde_json::from_slice(&fs::read(&settings_path).unwrap()).unwrap();
|
|
settings["database"] = json!({"default": setting.database_setting()});
|
|
fs::write(
|
|
&settings_path,
|
|
serde_json::to_vec_pretty(&settings).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let result = result_directory(&workspace);
|
|
let output = Command::new("python3")
|
|
.arg("-B")
|
|
.arg(workspace.join("test/performance/cms.py"))
|
|
.arg("postgres-case")
|
|
.arg("--project")
|
|
.arg(&project)
|
|
.arg("--output")
|
|
.arg(&result)
|
|
.arg("--compiler")
|
|
.arg(&compiler)
|
|
.arg("--articles")
|
|
.arg("2")
|
|
.arg("--rbac-fixture")
|
|
.output()
|
|
.map_err(|error| format!("cannot start PostgreSQL CMS acceptance: {error}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"PostgreSQL CMS acceptance failed; owned logs: {}\n{}\n{}",
|
|
result.display(),
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
));
|
|
}
|
|
let response: Value = serde_json::from_slice(&output.stdout)
|
|
.map_err(|error| format!("invalid PostgreSQL CMS result: {error}"))?;
|
|
if response["status"] != "passed" {
|
|
return Err(format!(
|
|
"PostgreSQL CMS acceptance did not pass: {response}"
|
|
));
|
|
}
|
|
let report_path = response["report"]
|
|
.as_str()
|
|
.ok_or_else(|| "PostgreSQL CMS result has no report path".to_owned())?;
|
|
let report_path = Path::new(report_path);
|
|
if !report_path.starts_with(&result) {
|
|
return Err("PostgreSQL CMS report is outside the owned result directory".into());
|
|
}
|
|
let report: Value =
|
|
serde_json::from_slice(&fs::read(report_path).map_err(|error| error.to_string())?)
|
|
.map_err(|error| error.to_string())?;
|
|
if report["status"] != "passed" {
|
|
return Err(format!("PostgreSQL CMS report did not pass: {report}"));
|
|
}
|
|
let tenant_a = &report["tenants"]["tenant_a"];
|
|
let tenant_b = &report["tenants"]["tenant_b"];
|
|
let tenant_a_id = tenant_a["tenant_id"]
|
|
.as_i64()
|
|
.filter(|id| *id > 0)
|
|
.ok_or_else(|| "tenant A has no positive ID".to_owned())?;
|
|
let tenant_b_id = tenant_b["tenant_id"]
|
|
.as_i64()
|
|
.filter(|id| *id > 0)
|
|
.ok_or_else(|| "tenant B has no positive ID".to_owned())?;
|
|
let user_a_id = tenant_a["user_id"]
|
|
.as_i64()
|
|
.filter(|id| *id > 0)
|
|
.ok_or_else(|| "tenant A has no positive user ID".to_owned())?;
|
|
let user_b_id = tenant_b["user_id"]
|
|
.as_i64()
|
|
.filter(|id| *id > 0)
|
|
.ok_or_else(|| "tenant B has no positive user ID".to_owned())?;
|
|
if tenant_a_id == tenant_b_id || user_a_id == user_b_id {
|
|
return Err("tenant A and B resolved to the same identity".into());
|
|
}
|
|
let titles_a = tenant_a["contract"]["titles"]
|
|
.as_array()
|
|
.ok_or_else(|| "tenant A has no title list".to_owned())?;
|
|
let titles_b = tenant_b["contract"]["titles"]
|
|
.as_array()
|
|
.ok_or_else(|| "tenant B has no title list".to_owned())?;
|
|
if titles_a.len() != 2
|
|
|| titles_b.len() != 2
|
|
|| titles_a == titles_b
|
|
|| tenant_a["contract"]["articles"] != 2
|
|
|| tenant_b["contract"]["articles"] != 2
|
|
|| tenant_a["contract"]["duplicate_publish_status"] != 409
|
|
|| tenant_b["contract"]["duplicate_publish_status"] != 409
|
|
|| tenant_a["slugs"] != json!(["bench-0", "bench-1"])
|
|
|| tenant_b["slugs"] != json!(["bench-0", "bench-1"])
|
|
|| report["cross_site_cookie_rejected"] != true
|
|
|| report["cross_tenant_login_rejected"] != true
|
|
{
|
|
return Err("PostgreSQL CMS tenant or HTTP contract is incomplete".into());
|
|
}
|
|
let rbac = &report["rbac"];
|
|
let member_user_id = rbac["member_user_id"]
|
|
.as_i64()
|
|
.filter(|id| *id > 0)
|
|
.ok_or_else(|| "RBAC fixture has no positive non-Owner user ID".to_owned())?;
|
|
if member_user_id == user_a_id || member_user_id == user_b_id {
|
|
return Err("RBAC fixture selected an Owner identity".into());
|
|
}
|
|
let permission_names = ["admin_read", "admin_permissions", "front_list"];
|
|
let mut permission_keys = Vec::new();
|
|
for name in permission_names {
|
|
let key = rbac["permissions"][name]
|
|
.as_str()
|
|
.filter(|key| !key.is_empty())
|
|
.ok_or_else(|| format!("RBAC report has no {name} permission key"))?;
|
|
if permission_keys.contains(&key) {
|
|
return Err("RBAC report reused one permission key for separate routes".into());
|
|
}
|
|
permission_keys.push(key);
|
|
}
|
|
let checks = [
|
|
"before_grant_denied",
|
|
"both_admin_roles_allowed",
|
|
"revoked_role_denied",
|
|
"second_role_still_allowed",
|
|
"front_same_named_role_still_allowed",
|
|
];
|
|
for check in checks {
|
|
if rbac[check] != true {
|
|
return Err(format!("RBAC HTTP check did not pass: {check}"));
|
|
}
|
|
}
|
|
let summary = json!({
|
|
"status": "passed",
|
|
"source": report["source"],
|
|
"binary": report["binary"],
|
|
"ready_seconds": report["ready_seconds"],
|
|
"scope": report["scope"],
|
|
"tenants": {
|
|
"tenant_a": {
|
|
"tenant_id": tenant_a_id, "user_id": user_a_id,
|
|
"articles": 2, "titles": titles_a, "slugs": tenant_a["slugs"],
|
|
"duplicate_publish_status": 409,
|
|
},
|
|
"tenant_b": {
|
|
"tenant_id": tenant_b_id, "user_id": user_b_id,
|
|
"articles": 2, "titles": titles_b, "slugs": tenant_b["slugs"],
|
|
"duplicate_publish_status": 409,
|
|
},
|
|
},
|
|
"cross_site_cookie_rejected": true,
|
|
"cross_tenant_login_rejected": true,
|
|
"rbac": {
|
|
"member_user_id": member_user_id,
|
|
"permissions": {
|
|
"admin_read": permission_keys[0],
|
|
"admin_permissions": permission_keys[1],
|
|
"front_list": permission_keys[2],
|
|
},
|
|
"before_grant_denied": true,
|
|
"both_admin_roles_allowed": true,
|
|
"revoked_role_denied": true,
|
|
"second_role_still_allowed": true,
|
|
"front_same_named_role_still_allowed": true,
|
|
},
|
|
});
|
|
Ok(summary)
|
|
});
|
|
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../..")
|
|
.canonicalize()
|
|
.unwrap();
|
|
let saved = save_report(&workspace, &summary).unwrap();
|
|
println!("PostgreSQL CMS acceptance: {}", saved.display());
|
|
println!("{summary}");
|
|
}
|