use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
#[cfg(unix)]
use std::path::Path;
use tempfile::TempDir;
fn kagi_bin(kagi_home: &str) -> Command {
let mut cmd = Command::cargo_bin("kagi").unwrap();
cmd.env("KAGI_DISABLE_KEYRING", "1");
cmd.env("KAGI_HOME", kagi_home);
cmd
}
fn setup_kagi_home() -> TempDir {
TempDir::new().unwrap()
}
#[cfg(unix)]
fn run_kagi_interactive(
kagi_home: &str,
current_dir: &Path,
args: &[&str],
inputs: &[&str],
) -> (std::process::ExitStatus, String) {
let bin_path = std::env::var("CARGO_BIN_EXE_kagi")
.unwrap_or_else(|_| assert_cmd::cargo_bin!("kagi").to_string_lossy().to_string());
const PTY_RUNNER: &str = r#"
import os
import pty
import select
import subprocess
import sys
master, slave = pty.openpty()
child = subprocess.Popen(sys.argv[1:], stdin=slave, stdout=slave, stderr=subprocess.STDOUT, close_fds=True)
os.close(slave)
inputs = os.environ.get("KAGI_E2E_INPUTS", "")
for line in inputs.split("\n"):
if line:
os.write(master, line.encode())
os.write(master, b"\n")
while True:
ready, _, _ = select.select([master], [], [], 0.05)
if ready:
try:
data = os.read(master, 4096)
except OSError:
break
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
if child.poll() is not None:
while True:
ready, _, _ = select.select([master], [], [], 0)
if not ready:
break
try:
data = os.read(master, 4096)
except OSError:
break
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
break
os.close(master)
sys.exit(child.wait())
"#;
let mut cmd = std::process::Command::new("python3");
cmd.arg("-c").arg(PTY_RUNNER).arg(&bin_path).args(args);
cmd.env("KAGI_DISABLE_KEYRING", "1")
.env("KAGI_HOME", kagi_home)
.env("KAGI_E2E_INPUTS", inputs.join("\n"))
.current_dir(current_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = cmd.output().expect("failed to run pty helper");
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stderr));
(output.status, text)
}
#[test]
fn test_e2e_init_set_get_doctor_backup_restore() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev,staging,prod"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Initialized .kagi/"));
let kagi_dir = project_path.join(".kagi");
assert!(kagi_dir.exists());
assert!(kagi_dir.join("kagi.json").exists());
assert!(kagi_dir.join("access.json").exists());
assert!(kagi_dir.join("secrets").exists());
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "URL", "http://localhost:3000"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args([
"set",
"api",
"staging",
"URL",
"https://staging.example.com",
]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "db", "prod", "HOST", "db.production.local"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["get", "api"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("api"))
.stdout(predicate::str::contains("dev"))
.stdout(predicate::str::contains("staging"))
.stdout(predicate::str::contains("URL"))
.stdout(predicate::str::contains("********"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["get", "api", "dev"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("URL"))
.stdout(predicate::str::contains("********"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["search", "URL"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("api/dev.URL"))
.stdout(predicate::str::contains("api/staging.URL"))
.stdout(predicate::str::contains("********"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["doctor"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Kagi Doctor"))
.stdout(predicate::str::contains("all checks passed"));
let backup_dir = project_path.join("test-backup");
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["backup", "--out", backup_dir.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("backup created"));
assert!(backup_dir.join("kagi").exists());
assert!(backup_dir.join("manifest.json").exists());
assert!(backup_dir.join("checksums.json").exists());
let manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(backup_dir.join("manifest.json")).unwrap())
.unwrap();
assert_eq!(manifest["version"].as_u64(), Some(1));
let home_files = manifest["home_files"].as_array().unwrap();
assert!(home_files.iter().all(|v| {
let s = v.as_str().unwrap();
s == "identities" || s == "admins" || s.starts_with("projects/")
}));
let restore_dir = TempDir::new().unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(restore_dir.path());
cmd.args(["restore", "--from", backup_dir.to_str().unwrap(), "--force"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("restored from backup"));
assert!(restore_dir.path().join(".kagi").exists());
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(restore_dir.path());
cmd.args(["doctor"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("all checks passed"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(restore_dir.path());
cmd.args(["get", "api", "dev"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("URL"))
.stdout(predicate::str::contains("********"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["env", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("development"))
.stdout(predicate::str::contains("dev"))
.stdout(predicate::str::contains("staging"))
.stdout(predicate::str::contains("prod"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Members"))
.stdout(predicate::str::contains("active"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["unset", "api", "dev", "URL"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["export", "api", "dev", "--out", "/tmp/test.env"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args([
"backup",
"--out",
kagi_dir.join("bad-backup").to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("inside the source tree"));
let manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(backup_dir.join("manifest.json")).unwrap())
.unwrap();
let mut bad_manifest = manifest.clone();
bad_manifest["home_files"] = serde_json::json!(["../etc/passwd"]);
let bad_manifest_str = serde_json::to_string_pretty(&bad_manifest).unwrap();
fs::write(backup_dir.join("manifest.json"), &bad_manifest_str).unwrap();
use sha2::Digest;
let mut checksums: serde_json::Value =
serde_json::from_str(&fs::read_to_string(backup_dir.join("checksums.json")).unwrap())
.unwrap();
let new_hash = hex::encode(sha2::Sha256::digest(bad_manifest_str.as_bytes()));
if let Some(obj) = checksums.as_object_mut() {
obj.insert("manifest.json".to_string(), serde_json::json!(new_hash));
}
fs::write(
backup_dir.join("checksums.json"),
serde_json::to_string_pretty(&checksums).unwrap(),
)
.unwrap();
let restore_dir2 = TempDir::new().unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(restore_dir2.path());
cmd.args(["restore", "--from", backup_dir.to_str().unwrap(), "--force"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("invalid path in backup manifest"));
}
#[cfg(feature = "server")]
#[test]
fn test_e2e_server_full_remote_workflow() {
use std::io::{Read, Write};
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "KEY", "value123"]);
cmd.assert().success();
let kagi_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/kagi.json")).unwrap())
.unwrap();
let project_id = kagi_json["project_id"].as_str().unwrap().to_string();
let server_dir = TempDir::new().unwrap();
let db_path = server_dir.path().join("server.db");
let key_path = server_dir.path().join("server.key");
let mut serve_cmd = std::process::Command::new(assert_cmd::cargo_bin!("kagi"));
serve_cmd.env("KAGI_DISABLE_KEYRING", "1");
serve_cmd.env("KAGI_HOME", server_dir.path().join("kagi-home"));
serve_cmd.args([
"serve",
"--bind",
"127.0.0.1:0",
"--db",
db_path.to_str().unwrap(),
"--key-file",
key_path.to_str().unwrap(),
]);
serve_cmd.stdout(std::process::Stdio::piped());
serve_cmd.stderr(std::process::Stdio::piped());
let mut child = serve_cmd.spawn().expect("failed to spawn kagi serve");
let stderr = child.stderr.take().unwrap();
std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(stderr);
for _ in std::io::BufRead::lines(&mut reader) {}
});
let stdout = child.stdout.take().unwrap();
let reader = std::io::BufReader::new(stdout);
let (line_tx, line_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufRead::lines(reader).map_while(Result::ok) {
let _ = line_tx.send(line);
}
});
let mut token = String::new();
let mut port: Option<u16> = None;
let timeout = std::time::Duration::from_secs(10);
let start = std::time::Instant::now();
while token.is_empty() || port.is_none() {
if start.elapsed() > timeout {
let _ = child.kill();
panic!("server startup timed out");
}
match line_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(line) => {
if line.contains("generated admin token:") {
token = line
.split("generated admin token:")
.nth(1)
.unwrap()
.trim()
.to_string();
}
if let Some(p) = line.rfind(":")
&& (line.contains("listening on http://")
|| line.contains("running on http://"))
{
let port_str = &line[p + 1..];
port = port_str.parse().ok();
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => {
let _ = child.kill();
panic!("server stdout closed unexpectedly");
}
}
}
let port = port.unwrap();
let base_url = format!("http://127.0.0.1:{port}");
let mut ready = false;
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Ok(mut stream) = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
&& stream
.write_all(b"GET /v1/server-key HTTP/1.1\r\nHost: localhost\r\n\r\n")
.is_ok()
{
let mut buf = [0u8; 12];
if stream.read(&mut buf).is_ok() && buf.starts_with(b"HTTP/1.1") {
ready = true;
break;
}
}
}
assert!(ready, "server did not become HTTP-ready");
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "login", "--remote", &base_url, "--token", &token]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "register", "--remote", &base_url]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "approve", "--remote", &base_url, &project_id]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "pull"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "push"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "pull"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "status"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "tokens"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "audit", "--limit", "10"]);
cmd.assert().success();
let body = http_get_json(&format!("{base_url}/v1/metrics"), Some(&token));
assert!(body["active_admins"].is_number());
assert!(body["active_projects"].is_number());
assert!(body["active_tokens"].is_number());
assert!(body["db_size"].is_number());
let body = http_get_json(&format!("{base_url}/v1/metrics"), None);
assert!(!body["ok"].as_bool().unwrap_or(true));
assert_eq!(body["error"]["code"].as_str(), Some("auth_failed"));
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_e2e_local_advanced() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev,test"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "HOST", "localhost"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "PORT", "3000"]);
cmd.assert().success();
let env_file = project_path.join(".env");
fs::write(&env_file, "DEBUG=true\nSECRET=imported123\n").unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["import", "api", "dev", "--file", env_file.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args([
"import",
"api",
"dev",
"--file",
env_file.to_str().unwrap(),
"--force",
]);
cmd.assert().success();
let example_file = project_path.join(".env.example");
fs::write(&example_file, "API_KEY=\n# some comment\nNEW_VAR=\n").unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args([
"sync",
"--service",
"api",
"--example",
example_file.to_str().unwrap(),
"--envs",
"dev,test",
]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["env", "add", "staging"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["env", "rename", "test", "testing"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["env", "remove", "testing"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "request", "--name", "Alice"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("pending"));
let access_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/access.json")).unwrap())
.unwrap();
let pending_member = access_json["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["status"] == "pending")
.unwrap();
let pending_member_id = pending_member["member_id"].as_str().unwrap().to_string();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "approve", &pending_member_id]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("active"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "remove", &pending_member_id]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["run", "api", "dev", "sh", "-c", "echo $HOST"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("localhost"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["search", "--values", "HOST"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let kagi_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/kagi.json")).unwrap())
.unwrap();
let project_id = kagi_json["project_id"].as_str().unwrap().to_string();
let access_json = fs::read_to_string(project_path.join(".kagi/access.json")).unwrap();
let journal = serde_json::json!({
"version": 1,
"project_id": project_id,
"access_json": access_json,
"files": {}
});
let journal_dir = std::path::Path::new(&home_path).join("projects");
fs::create_dir_all(&journal_dir).unwrap();
let journal_path = journal_dir.join(format!("{project_id}.rotation.json"));
fs::write(
&journal_path,
serde_json::to_string_pretty(&journal).unwrap(),
)
.unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["doctor", "--fix"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("requires an interactive terminal"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["get", "api"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("api"))
.stdout(predicate::str::contains("dev"))
.stdout(predicate::str::contains("staging"));
}
#[test]
fn test_e2e_file_artifact_workflow() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let api_path = project_path.join("apps/api");
fs::create_dir_all(&api_path).unwrap();
fs::write(api_path.join(".env.dev"), "API_KEY=example\n").unwrap();
fs::write(api_path.join("service-account.json"), "{\"ok\":true}").unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--nested", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(&api_path);
cmd.args(["file", "add", "service-account.json"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("added file"));
let index_path = project_path.join(".kagi/files/index.enc");
let content_path = project_path.join(".kagi/files");
assert!(index_path.exists());
assert!(fs::read_dir(&content_path).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("kgf_")
}));
let index_content = fs::read_to_string(&index_path).unwrap();
assert!(!index_content.contains("service-account.json"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(&api_path);
cmd.args(["file", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("service-account.json"))
.stdout(predicate::str::contains("apps-api/dev"));
fs::remove_file(api_path.join("service-account.json")).unwrap();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(&api_path);
cmd.args(["file", "restore", "service-account.json"]);
cmd.assert().success();
assert_eq!(
fs::read_to_string(api_path.join("service-account.json")).unwrap(),
"{\"ok\":true}"
);
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(&api_path);
cmd.args(["file", "remove", "--force", "service-account.json"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(&api_path);
cmd.args(["file", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("no encrypted files found"));
}
#[cfg(feature = "server")]
#[test]
fn test_e2e_server_advanced() {
use std::io::{Read, Write};
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "KEY", "value123"]);
cmd.assert().success();
let kagi_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/kagi.json")).unwrap())
.unwrap();
let project_id = kagi_json["project_id"].as_str().unwrap().to_string();
let server_dir = TempDir::new().unwrap();
let db_path = server_dir.path().join("server.db");
let key_path = server_dir.path().join("server.key");
let mut serve_cmd = std::process::Command::new(assert_cmd::cargo_bin!("kagi"));
serve_cmd.env("KAGI_DISABLE_KEYRING", "1");
serve_cmd.env("KAGI_HOME", server_dir.path().join("kagi-home"));
serve_cmd.args([
"serve",
"--bind",
"127.0.0.1:0",
"--db",
db_path.to_str().unwrap(),
"--key-file",
key_path.to_str().unwrap(),
]);
serve_cmd.stdout(std::process::Stdio::piped());
serve_cmd.stderr(std::process::Stdio::piped());
let mut child = serve_cmd.spawn().expect("failed to spawn kagi serve");
let stderr = child.stderr.take().unwrap();
std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(stderr);
for _ in std::io::BufRead::lines(&mut reader) {}
});
let stdout = child.stdout.take().unwrap();
let reader = std::io::BufReader::new(stdout);
let (line_tx, line_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufRead::lines(reader).map_while(Result::ok) {
let _ = line_tx.send(line);
}
});
let mut token = String::new();
let mut port: Option<u16> = None;
let timeout = std::time::Duration::from_secs(10);
let start = std::time::Instant::now();
while token.is_empty() || port.is_none() {
if start.elapsed() > timeout {
let _ = child.kill();
panic!("server startup timed out");
}
match line_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(line) => {
if line.contains("generated admin token:") {
token = line
.split("generated admin token:")
.nth(1)
.unwrap()
.trim()
.to_string();
}
if let Some(p) = line.rfind(":")
&& (line.contains("listening on http://")
|| line.contains("running on http://"))
{
let port_str = &line[p + 1..];
port = port_str.parse().ok();
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => {
let _ = child.kill();
panic!("server stdout closed unexpectedly");
}
}
}
let port = port.unwrap();
let base_url = format!("http://127.0.0.1:{port}");
let mut ready = false;
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Ok(mut stream) = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
&& stream
.write_all(b"GET /v1/server-key HTTP/1.1\r\nHost: localhost\r\n\r\n")
.is_ok()
{
let mut buf = [0u8; 12];
if stream.read(&mut buf).is_ok() && buf.starts_with(b"HTTP/1.1") {
ready = true;
break;
}
}
}
assert!(ready, "server did not become HTTP-ready");
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "login", "--remote", &base_url, "--token", &token]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "register", "--remote", &base_url]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "approve", "--remote", &base_url, &project_id]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "pull"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "push"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "projects", "--remote", &base_url]);
cmd.assert()
.success()
.stdout(predicate::str::contains(&project_id));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "tokens", "--remote", &base_url]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args([
"remote",
"revoke-token",
"--remote",
&base_url,
"some_token_id",
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("revoked"))
.stdout(predicate::str::contains("some_token_id"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["remote", "remove", "--remote", &base_url, &project_id]);
cmd.assert().success();
let _ = child.kill();
let _ = child.wait();
}
#[cfg(feature = "server")]
fn http_get_json(url: &str, token: Option<&str>) -> serde_json::Value {
let mut cmd = std::process::Command::new("curl");
cmd.args(["-s", "-w", "\\n%{http_code}", url]);
if let Some(t) = token {
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
}
let output = cmd.output().expect("curl failed");
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let body = lines.first().unwrap_or(&"{}");
serde_json::from_str(body).unwrap_or_else(|_| panic!("failed to parse JSON from {url}: {body}"))
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_unset() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "KEY", "value123"]);
cmd.assert().success();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["unset", "api", "dev", "KEY"],
&["y"],
);
assert!(status.success(), "unset failed: {output}");
assert!(output.contains("unset"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["get", "api", "dev"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("KEY").not());
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_env_remove() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev,test"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "KEY", "value123"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "test", "KEY", "value456"]);
cmd.assert().success();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["env", "remove", "test", "--plain"],
&["test"],
);
assert!(status.success(), "env remove failed: {output}");
assert!(output.contains("deleted environment"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["env", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("test").not());
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_import_cancel_does_not_write() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let env_file = project_path.join(".env");
fs::write(&env_file, "API_KEY=secret\n").unwrap();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["import", "api", "dev", "--file", env_file.to_str().unwrap()],
&["n"],
);
assert!(status.success(), "import cancel failed: {output}");
assert!(output.contains("aborted."));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["run", "api", "dev", "sh", "-c", "test -z \"${API_KEY+x}\""]);
cmd.assert().success();
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_member_remove() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "request", "--name", "Alice"]);
cmd.assert().success();
let access_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/access.json")).unwrap())
.unwrap();
let pending_member = access_json["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["status"] == "pending")
.unwrap();
let pending_member_id = pending_member["member_id"].as_str().unwrap().to_string();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "approve", &pending_member_id]);
cmd.assert().success();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["member", "remove", &pending_member_id],
&[&pending_member_id],
);
assert!(status.success(), "member remove failed: {output}");
assert!(output.contains("removed member"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["member", "list"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("removed"));
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_search_values() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["set", "api", "dev", "HOST", "localhost"]);
cmd.assert().success();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["search", "--values", "--plain", "localhost"],
&["y"],
);
assert!(status.success(), "search --values --plain failed: {output}");
assert!(output.contains("api/dev.HOST"));
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_doctor_fix() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["init", "--envs", "dev"]);
cmd.assert().success();
let kagi_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(project_path.join(".kagi/kagi.json")).unwrap())
.unwrap();
let project_id = kagi_json["project_id"].as_str().unwrap().to_string();
let access_json = fs::read_to_string(project_path.join(".kagi/access.json")).unwrap();
let journal = serde_json::json!({
"version": 1,
"project_id": project_id,
"access_json": access_json,
"files": {}
});
let journal_dir = std::path::Path::new(&home_path).join("projects");
fs::create_dir_all(&journal_dir).unwrap();
let journal_path = journal_dir.join(format!("{project_id}.rotation.json"));
fs::write(
&journal_path,
serde_json::to_string_pretty(&journal).unwrap(),
)
.unwrap();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["doctor", "--fix", "--plain"],
&["y"],
);
assert!(status.success(), "doctor --fix failed: {output}");
assert!(output.contains("recovered pending rotation"));
assert!(!journal_path.exists());
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_set_can_normalize_key_to_upper_snake() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.arg("init");
cmd.assert().success();
let (status, output) = run_kagi_interactive(
&home_path,
project_path,
&["set", "api", "abc-d", "val"],
&[],
);
assert!(status.success(), "interactive set failed: {output}");
assert!(output.contains("normalized key"));
let mut cmd = kagi_bin(&home_path);
cmd.current_dir(project_path);
cmd.args(["run", "api", "sh", "-c", "printf %s \"$ABC_D\""]);
cmd.assert().success().stdout("val");
let (status, output) =
run_kagi_interactive(&home_path, project_path, &["get", "api", "abc-d"], &["y"]);
assert!(status.success(), "interactive get failed: {output}");
assert!(output.contains("normalized key"));
assert!(output.contains("val"));
}
#[cfg(unix)]
#[test]
fn test_e2e_interactive_init_migration() {
let home = setup_kagi_home();
let home_path = home.path().to_str().unwrap().to_string();
let project_dir = TempDir::new().unwrap();
let project_path = project_dir.path();
fs::write(project_path.join(".env"), "ROOT_KEY=root\n").unwrap();
fs::create_dir(project_path.join("api")).unwrap();
fs::write(project_path.join("api/.env"), "API_KEY=api\n").unwrap();
let (status, output) =
run_kagi_interactive(&home_path, project_path, &["init", "--envs", "dev"], &["y"]);
assert!(status.success(), "init with migration failed: {output}");
assert!(
output.contains("migrated"),
"expected migration output, got: {output}"
);
assert!(
output.contains(".env"),
"expected .env file path in output, got: {output}"
);
assert!(
output.contains("api/.env"),
"expected api .env file path in output, got: {output}"
);
}