diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs new file mode 100644 index 0000000..82e8d72 --- /dev/null +++ b/crates/picloud-cli/tests/cli.rs @@ -0,0 +1,373 @@ +//! Bare-metal end-to-end integration test. +//! +//! Spawns a `picloud` subprocess against `DATABASE_URL` on a private +//! port, logs in over HTTP to mint a bearer token, then drives the +//! `pic` binary through the full edit-deploy-invoke-tail loop and +//! cleans up the app it created. +//! +//! Gated on `DATABASE_URL`. To run: +//! +//! docker compose up -d postgres +//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \ +//! cargo test -p picloud-cli --test cli -- --include-ignored + +#![allow(clippy::too_many_lines)] + +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command as StdCommand, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::TempDir; + +// The bootstrap env vars are inert once any admin row exists, so we +// can't carve out a dedicated test admin against the dev database. The +// dev stack seeds `admin`/`admin` (see CLAUDE.md); we use those. +// `PICLOUD_CLI_E2E_USERNAME` / `_PASSWORD` let CI override. +fn admin_username() -> String { + std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string()) +} + +fn admin_password() -> String { + std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string()) +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn end_to_end_login_deploy_invoke_logs() { + let Ok(database_url) = std::env::var("DATABASE_URL") else { + eprintln!("skipping: DATABASE_URL not set"); + return; + }; + + let port = pick_free_port(); + let url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_picloud(&database_url, port); + if let Err(e) = wait_for_health(&url, Duration::from_secs(60)) { + kill_subprocess(&mut server); + panic!("picloud failed to become healthy: {e}"); + } + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_flow(&url); + })); + + // Always tear down regardless of outcome so a failed test doesn't + // leak a child process. + kill_subprocess(&mut server); + + if let Err(p) = outcome { + std::panic::resume_unwind(p); + } +} + +fn run_flow(url: &str) { + let token = login_for_bearer_token(url); + + let cfg_dir = TempDir::new().expect("tempdir"); + let home = TempDir::new().expect("home tempdir"); + let env = TestEnv { + url: url.to_string(), + token, + config_dir: cfg_dir.path().to_path_buf(), + home: home.path().to_path_buf(), + }; + + // Slug carries the wall-clock so reruns against a long-lived dev + // database don't collide on the unique-slug constraint. + let slug = format!( + "pic-cli-e2e-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() + ); + + let username = admin_username(); + + // 1) login + pic(&env) + .args(["login"]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Logged in as {username}" + ))); + + let creds_path = env.config_dir.join("credentials"); + assert!( + creds_path.exists(), + "credentials file should exist after login" + ); + let body = std::fs::read_to_string(&creds_path).unwrap(); + assert!(body.contains(&env.url), "creds should contain url: {body}"); + assert!( + body.contains(&username), + "creds should contain username: {body}" + ); + + // 2) whoami + pic(&env) + .args(["whoami"]) + .assert() + .success() + .stdout(predicate::str::contains(username.clone())); + + // 3) apps create + pic(&env) + .args(["apps", "create", &slug]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Created app {slug}"))); + + // Ensure the app is cleaned up no matter what subsequent assertions do. + let _guard = AppGuard { + url: env.url.clone(), + token: env.token.clone(), + slug: slug.clone(), + }; + + // 4) apps ls + pic(&env) + .args(["apps", "ls"]) + .assert() + .success() + .stdout(predicate::str::contains(slug.as_str())); + + // 5) scripts deploy (create then update) + let fixture = fixture_path("hello.rhai"); + pic(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success() + .stdout(predicate::str::contains("Created hello v1")); + + pic(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success() + .stdout(predicate::str::contains("Updated hello v2")); + + // 6) scripts ls and capture the id + let ls_out = pic(&env) + .args(["scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls"); + assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}"); + let id = parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap()) + .expect("scripts ls should print at least one row"); + + // 7) invoke + let invoke_out = pic(&env) + .args(["scripts", "invoke", &id]) + .output() + .expect("scripts invoke"); + assert!( + invoke_out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&invoke_out.stderr) + ); + let parsed: Value = + serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON"); + assert_eq!( + parsed["ok"], true, + "expected hello.rhai response, got {parsed}" + ); + + // 8) logs (the invoke above should have produced exactly one row) + let logs_out = pic(&env).args(["logs", &id]).output().expect("pic logs"); + assert!(logs_out.status.success(), "logs failed: {logs_out:?}"); + let stdout = String::from_utf8_lossy(&logs_out.stdout); + assert!( + stdout.lines().any(|l| !l.trim().is_empty()), + "logs should have at least one row, got: {stdout}" + ); +} + +// -------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------- + +struct TestEnv { + url: String, + token: String, + config_dir: PathBuf, + home: PathBuf, +} + +fn pic(env: &TestEnv) -> AssertCommand { + let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary"); + cmd.env("PICLOUD_URL", &env.url) + .env("PICLOUD_TOKEN", &env.token) + .env("PICLOUD_CONFIG_DIR", &env.config_dir) + .env("HOME", &env.home); + cmd +} + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +fn picloud_binary_path() -> PathBuf { + // The integration test binary lives at + // `/debug/deps/cli-`. CARGO_MANIFEST_DIR points at the + // crate; the workspace target dir is two levels up. `picloud` lands + // next to our own test executable. + let exe = std::env::current_exe().expect("current_exe"); + // current_exe is `.../target/debug/deps/cli-`. Walk up twice + // to reach `.../target/debug`, then look for `picloud`. + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("test binary should live under target/debug/deps"); + debug_dir.join(if cfg!(windows) { + "picloud.exe" + } else { + "picloud" + }) +} + +fn pick_free_port() -> u16 { + // Bind to :0, read the assigned port, drop the listener. + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port"); + listener.local_addr().expect("local addr").port() +} + +fn spawn_picloud(database_url: &str, port: u16) -> Child { + // Execute the pre-built `picloud` binary directly. Going through + // `cargo run -p picloud` while inside `cargo test` would contend on + // the same build lock and can deadlock. We assume the binary was + // built as part of the workspace compile that produced this test — + // and check explicitly so the panic is informative if not. + let binary = picloud_binary_path(); + assert!( + binary.exists(), + "expected picloud binary at {}. Run `cargo build -p picloud` first \ + (or use `cargo test --workspace -- --include-ignored` which builds it)", + binary.display() + ); + let mut child = StdCommand::new(&binary) + .env("PICLOUD_BIND", format!("127.0.0.1:{port}")) + .env("DATABASE_URL", database_url) + .env("PICLOUD_ADMIN_USERNAME", admin_username()) + .env("PICLOUD_ADMIN_PASSWORD", admin_password()) + .env("RUST_LOG", "warn") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn picloud"); + + // Drain stderr in a side thread so the pipe buffer doesn't fill and + // block the server. We only echo to test output on failure. + if let Some(err) = child.stderr.take().map(BufReader::new) { + let (tx, _rx) = mpsc::channel::(); + thread::spawn(move || { + for line in err.lines().map_while(Result::ok) { + let _ = tx.send(line); + } + }); + } + child +} + +fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|e| e.to_string())?; + while Instant::now() < deadline { + if let Ok(resp) = client.get(format!("{url}/healthz")).send() { + if resp.status().is_success() { + return Ok(()); + } + } + thread::sleep(Duration::from_millis(250)); + } + Err(format!("/healthz never returned 200 within {timeout:?}")) +} + +fn login_for_bearer_token(url: &str) -> String { + let client = reqwest::blocking::Client::new(); + let resp = client + .post(format!("{url}/api/v1/admin/auth/login")) + .json(&serde_json::json!({ + "username": admin_username(), + "password": admin_password(), + })) + .send() + .expect("login request"); + assert!( + resp.status().is_success(), + "login should succeed, got {}: {}", + resp.status(), + resp.text().unwrap_or_default() + ); + let v: Value = resp.json().expect("login json"); + v["token"] + .as_str() + .expect("login returns token") + .to_string() +} + +fn parse_first_id(table: &str) -> Option { + // The header line starts with "id"; the first row's first + // tab-delimited cell is the script UUID. + let mut lines = table.lines().filter(|l| !l.trim().is_empty()); + let header = lines.next()?; + if !header.starts_with("id") { + return None; + } + let row = lines.next()?; + let first = row.split('\t').next()?.trim(); + if first.is_empty() { + None + } else { + Some(first.to_string()) + } +} + +fn kill_subprocess(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +struct AppGuard { + url: String, + token: String, + slug: String, +} + +impl Drop for AppGuard { + fn drop(&mut self) { + let client = reqwest::blocking::Client::new(); + let _ = client + .delete(format!( + "{}/api/v1/admin/apps/{}?force=true", + self.url, self.slug + )) + .bearer_auth(&self.token) + .send(); + } +} diff --git a/crates/picloud-cli/tests/fixtures/hello.rhai b/crates/picloud-cli/tests/fixtures/hello.rhai new file mode 100644 index 0000000..c89cb75 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/hello.rhai @@ -0,0 +1,4 @@ +// Smallest possible Rhai script for the integration test: returns a JSON +// object so the orchestrator wraps it as the HTTP response body. +let body = #{ ok: true, greeting: "hello from pic" }; +body