diff --git a/Cargo.lock b/Cargo.lock index d579e41..31d3aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1537,6 +1537,7 @@ dependencies = [ "assert_cmd", "clap", "directories", + "libc", "picloud-shared", "predicates", "reqwest", diff --git a/crates/picloud-cli/Cargo.toml b/crates/picloud-cli/Cargo.toml index cef4c75..10f0757 100644 --- a/crates/picloud-cli/Cargo.toml +++ b/crates/picloud-cli/Cargo.toml @@ -7,11 +7,20 @@ license.workspace = true repository.workspace = true authors.workspace = true description = "PiCloud command-line client" +# Each top-level `tests/*.rs` would otherwise auto-discover as its own +# test binary, respawning picloud once per file. We want one binary +# with module sub-files (auth.rs, apps.rs, …) so the LazyLock fixture +# is genuinely shared. +autotests = false [[bin]] name = "pic" path = "src/main.rs" +[[test]] +name = "cli" +path = "tests/cli.rs" + [dependencies] picloud-shared.workspace = true reqwest = { workspace = true, features = ["json"] } @@ -29,3 +38,4 @@ assert_cmd = "2" predicates = "3" tempfile = "3" reqwest = { workspace = true, features = ["json", "blocking"] } +libc = "0.2" diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index d690d39..6f93c1b 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -1,9 +1,9 @@ -//! Bare-metal end-to-end integration test. +//! Integration-test binary for the `pic` CLI. //! -//! 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. +//! Every `#[test]` in this binary routes through `common::fixture()`, a +//! `LazyLock` that spawns picloud once on a private port and reuses it +//! across all journey modules. Mirrors the dashboard Playwright suite, +//! which spins backend + Vite up once for 63 specs. //! //! Gated on `DATABASE_URL`. To run: //! @@ -11,361 +11,5 @@ //! 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(); - } -} +mod common; +mod integration; diff --git a/crates/picloud-cli/tests/common/cleanup.rs b/crates/picloud-cli/tests/common/cleanup.rs new file mode 100644 index 0000000..9a2783d --- /dev/null +++ b/crates/picloud-cli/tests/common/cleanup.rs @@ -0,0 +1,61 @@ +//! RAII guards that delete server-side resources on `Drop`. +//! +//! Each guard owns the minimum it needs to issue a single DELETE: the +//! base URL, an admin bearer token, and the resource identifier. +//! Failures are swallowed because Drop runs during teardown — a panic +//! here would just mask the real failure that the test was reporting. + +pub struct AppGuard { + url: String, + token: String, + slug: String, +} + +impl AppGuard { + pub fn new(url: &str, token: &str, slug: &str) -> Self { + Self { + url: url.to_string(), + token: token.to_string(), + slug: slug.to_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(); + } +} + +pub struct UserGuard { + url: String, + token: String, + user_id: String, +} + +impl UserGuard { + pub fn new(url: &str, token: &str, user_id: &str) -> Self { + Self { + url: url.to_string(), + token: token.to_string(), + user_id: user_id.to_string(), + } + } +} + +impl Drop for UserGuard { + fn drop(&mut self) { + let client = reqwest::blocking::Client::new(); + let _ = client + .delete(format!("{}/api/v1/admin/admins/{}", self.url, self.user_id)) + .bearer_auth(&self.token) + .send(); + } +} diff --git a/crates/picloud-cli/tests/common/member.rs b/crates/picloud-cli/tests/common/member.rs new file mode 100644 index 0000000..2474c3c --- /dev/null +++ b/crates/picloud-cli/tests/common/member.rs @@ -0,0 +1,97 @@ +//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus +//! direct API calls for granting / updating app memberships. +//! +//! These talk to the manager HTTP surface directly instead of going +//! through the CLI, so role-gated tests can stage state without +//! requiring `pic` to grow new commands. + +use serde_json::{json, Value}; + +use super::cleanup::UserGuard; +use super::Fixture; + +pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678"; + +pub struct MemberUser { + pub id: String, + pub username: String, + pub token: String, + pub _guard: UserGuard, +} + +/// Mint a fresh `instance_role: Member` user, log them in for a bearer +/// token, and register a `UserGuard` for teardown. +pub fn member_user(fx: &Fixture, username: &str) -> MemberUser { + let client = reqwest::blocking::Client::new(); + + let create = client + .post(format!("{}/api/v1/admin/admins", fx.url)) + .bearer_auth(&fx.admin_token) + .json(&json!({ + "username": username, + "password": MEMBER_PASSWORD, + "instance_role": "Member", + })) + .send() + .expect("create member user"); + assert!( + create.status().is_success(), + "create member user failed: {} {}", + create.status(), + create.text().unwrap_or_default(), + ); + let body: Value = create.json().expect("admin create json"); + let id = body["id"] + .as_str() + .expect("admin create returns id") + .to_string(); + + // Register cleanup before we attempt anything else that could fail. + let guard = UserGuard::new(&fx.url, &fx.admin_token, &id); + + let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD); + + MemberUser { + id, + username: username.to_string(), + token, + _guard: guard, + } +} + +/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`. +pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) { + let client = reqwest::blocking::Client::new(); + let resp = client + .post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug)) + .bearer_auth(&fx.admin_token) + .json(&json!({ "user_id": user_id, "role": role })) + .send() + .expect("grant membership"); + assert!( + resp.status().is_success(), + "grant membership failed: {} {}", + resp.status(), + resp.text().unwrap_or_default(), + ); +} + +/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote. +pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) { + let client = reqwest::blocking::Client::new(); + let resp = client + .patch(format!( + "{}/api/v1/admin/apps/{}/members/{}", + fx.url, app_slug, user_id + )) + .bearer_auth(&fx.admin_token) + .json(&json!({ "role": role })) + .send() + .expect("update membership"); + assert!( + resp.status().is_success(), + "update membership failed: {} {}", + resp.status(), + resp.text().unwrap_or_default(), + ); +} diff --git a/crates/picloud-cli/tests/common/mod.rs b/crates/picloud-cli/tests/common/mod.rs new file mode 100644 index 0000000..1741cbe --- /dev/null +++ b/crates/picloud-cli/tests/common/mod.rs @@ -0,0 +1,247 @@ +//! Shared fixture and helpers for the CLI integration test binary. +//! +//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock` +//! that spawns picloud on a private port the first time it's touched +//! and reuses that subprocess for every subsequent test. The dashboard +//! Playwright suite pays the same cost once for 63 tests; we do the +//! same here. + +#![allow(dead_code)] // shared helpers — not every module uses every fn. + +pub mod cleanup; +pub mod member; +pub mod server; + +use std::path::PathBuf; +use std::process::Child; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use assert_cmd::Command as AssertCommand; +use tempfile::TempDir; + +// -------------------------------------------------------------------- +// Fixture +// -------------------------------------------------------------------- + +pub struct Fixture { + pub url: String, + pub admin_token: String, + pub admin_username: String, + // Held in a Mutex so Drop can kill it without UB; we never re-enter. + child: Mutex>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Ok(mut guard) = self.child.lock() { + if let Some(mut child) = guard.take() { + server::kill_subprocess(&mut child); + } + } + } +} + +static FIXTURE: LazyLock = LazyLock::new(init_fixture); + +fn init_fixture() -> Fixture { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud"); + let username = admin_username(); + let password = admin_password(); + let port = server::pick_free_port(); + let url = format!("http://127.0.0.1:{port}"); + let mut child = server::spawn_picloud(&database_url, port, &username, &password); + if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) { + server::kill_subprocess(&mut child); + panic!("picloud failed to become healthy: {e}"); + } + let token = server::login_for_bearer_token(&url, &username, &password); + Fixture { + url, + admin_token: token, + admin_username: username, + child: Mutex::new(Some(child)), + } +} + +/// Returns the shared fixture, spawning the picloud subprocess on first +/// call. Returns `None` (and prints a skip message) when `DATABASE_URL` +/// is absent — matching the existing convention so the suite is a +/// no-op outside the integration environment. +pub fn fixture_or_skip() -> Option<&'static Fixture> { + if std::env::var("DATABASE_URL").is_err() { + eprintln!("skipping: DATABASE_URL not set"); + return None; + } + Some(&FIXTURE) +} + +// -------------------------------------------------------------------- +// Per-test env +// -------------------------------------------------------------------- + +pub struct TestEnv { + pub url: String, + pub token: String, + pub config_dir: TempDir, + pub home: TempDir, +} + +/// Per-test env pre-loaded with the admin token. Mirrors what the seed +/// test built inline. +pub fn admin_env(fx: &Fixture) -> TestEnv { + TestEnv { + url: fx.url.clone(), + token: fx.admin_token.clone(), + config_dir: TempDir::new().expect("config tempdir"), + home: TempDir::new().expect("home tempdir"), + } +} + +/// Per-test env pre-loaded with a specific (URL, token) pair. Used by +/// tests that want a non-admin token, a bogus token, or an unreachable +/// URL. +pub fn custom_env(url: &str, token: &str) -> TestEnv { + TestEnv { + url: url.to_string(), + token: token.to_string(), + config_dir: TempDir::new().expect("config tempdir"), + home: TempDir::new().expect("home tempdir"), + } +} + +/// `pic` invocation with the env wired up — credentials dir, HOME, and +/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars. +pub fn pic_as(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.path()) + .env("HOME", env.home.path()); + cmd +} + +/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the +/// command sees only the on-disk credentials file (or lack thereof). +pub fn pic_no_env(env: &TestEnv) -> AssertCommand { + let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary"); + cmd.env_remove("PICLOUD_URL") + .env_remove("PICLOUD_TOKEN") + .env("PICLOUD_CONFIG_DIR", env.config_dir.path()) + .env("HOME", env.home.path()); + cmd +} + +// -------------------------------------------------------------------- +// Unique slugs / usernames +// -------------------------------------------------------------------- + +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub fn unique_slug(prefix: &str) -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("pic-cli-{prefix}-{ms}-{n:x}") +} + +pub fn unique_username(prefix: &str) -> String { + // Server regex: [a-z0-9._-]{2,32}. Build out of lowercase + // alphanumerics only; "piccli" prefix keeps collisions with other + // test suites obvious. Caller's `prefix` must be ≤8 chars and + // already match the regex. + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = format!("piccli{prefix}{ms:x}{n:x}"); + assert!(name.len() <= 32, "username overflow: {name}"); + name +} + +// -------------------------------------------------------------------- +// Misc helpers +// -------------------------------------------------------------------- + +pub fn admin_username() -> String { + std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string()) +} + +pub fn admin_password() -> String { + std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string()) +} + +pub fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +/// First data row's first tab-delimited cell, used to extract IDs from +/// `pic scripts ls` output. The header is expected to start with "id". +pub fn parse_first_id(table: &str) -> Option { + 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()) + } +} + +// -------------------------------------------------------------------- +// Fixture-sharing sanity check +// -------------------------------------------------------------------- +// +// Two tests record `fixture().url` into the same `OnceLock` — if the +// fixture isn't actually shared, the second test sees a different URL +// and panics. Belt-and-suspenders: pointer identity on `&Fixture`. + +static OBSERVED_URL: OnceLock = OnceLock::new(); + +fn observe_fixture_url(label: &str) { + let Some(fx) = fixture_or_skip() else { + return; + }; + let url = fx.url.clone(); + match OBSERVED_URL.get() { + Some(prev) => assert_eq!( + prev, &url, + "{label} observed a different fixture URL: prior={prev} now={url}" + ), + None => { + let _ = OBSERVED_URL.set(url); + } + } + // Same `&'static Fixture` from every call — proves the LazyLock is + // sharing, not respawning. + let a = fixture_or_skip().unwrap(); + let b = fixture_or_skip().unwrap(); + assert!( + std::ptr::eq(a, b), + "fixture_or_skip should return the same &'static Fixture" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn fixture_url_is_shared_a() { + observe_fixture_url("fixture_url_is_shared_a"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn fixture_url_is_shared_b() { + observe_fixture_url("fixture_url_is_shared_b"); +} diff --git a/crates/picloud-cli/tests/common/server.rs b/crates/picloud-cli/tests/common/server.rs new file mode 100644 index 0000000..d1112df --- /dev/null +++ b/crates/picloud-cli/tests/common/server.rs @@ -0,0 +1,166 @@ +//! Picloud subprocess lifecycle for the CLI integration test binary. +//! +//! Mirrors what the original seed test did inline, lifted out so it can +//! be shared across modules via `common::fixture()`. The Fixture lives +//! in a `LazyLock` — and `LazyLock` never drops, so we register an +//! `atexit` handler that SIGTERMs the child when the test binary exits +//! normally (which is the common case under `cargo test`). +//! +//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the +//! creating thread dies, and cargo runs each `#[test]` on its own +//! worker thread that exits as soon as the test returns — which would +//! kill picloud after the first test that triggered the LazyLock, +//! breaking every test after it. +//! +//! Abnormal exit (SIGKILL of the test binary) leaks the child; the +//! dashboard Playwright suite accepts the same tradeoff. + +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command as StdCommand, Stdio}; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +pub fn pick_free_port() -> u16 { + 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() +} + +pub fn picloud_binary_path() -> PathBuf { + // The integration test binary lives at + // `/debug/deps/cli-`. Walk up two levels to reach + // `/debug` and look for `picloud` next to ourselves. + let exe = std::env::current_exe().expect("current_exe"); + 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" + }) +} + +pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child { + 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_user) + .env("PICLOUD_ADMIN_PASSWORD", admin_pass) + .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. + 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); + } + }); + } + + register_atexit_killer(child.id()); + child +} + +// -------------------------------------------------------------------- +// atexit-based child cleanup +// -------------------------------------------------------------------- + +static PICLOUD_PID: AtomicI32 = AtomicI32::new(0); + +fn register_atexit_killer(pid: u32) { + // First spawn wins; subsequent spawns (none expected today) would + // overwrite, but the previous child would already be tracked via + // its Drop path on the Fixture. + PICLOUD_PID.store(pid as i32, Ordering::SeqCst); + + #[cfg(unix)] + { + use std::sync::Once; + static REGISTERED: Once = Once::new(); + REGISTERED.call_once(|| { + // SAFETY: atexit's contract is a `extern "C" fn()` callback; + // ours signals a child PID we own. + unsafe { + libc::atexit(kill_picloud_at_exit); + } + }); + } +} + +#[cfg(unix)] +extern "C" fn kill_picloud_at_exit() { + let pid = PICLOUD_PID.swap(0, Ordering::SeqCst); + if pid > 0 { + // SAFETY: SIGTERM to a PID we recorded; if PID has been reused + // we're killing the wrong process — accepted risk for a test + // helper. + unsafe { + libc::kill(pid, libc::SIGTERM); + } + } +} + +pub 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:?}")) +} + +pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String { + let client = reqwest::blocking::Client::new(); + let resp = client + .post(format!("{url}/api/v1/admin/auth/login")) + .json(&serde_json::json!({ + "username": username, + "password": 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() +} + +pub fn kill_subprocess(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/crates/picloud-cli/tests/integration.rs b/crates/picloud-cli/tests/integration.rs new file mode 100644 index 0000000..d7f99b6 --- /dev/null +++ b/crates/picloud-cli/tests/integration.rs @@ -0,0 +1,131 @@ +//! End-to-end smoke test: login → whoami → apps create → apps ls → +//! scripts deploy (create + update) → scripts ls → scripts invoke → +//! logs. The original seed test, refactored to run against the shared +//! fixture so subsequent journey modules don't each pay for a server +//! spawn. + +#![allow(clippy::too_many_lines)] + +use predicates::prelude::*; +use serde_json::Value; + +use crate::common; +use crate::common::cleanup::AppGuard; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn end_to_end_login_deploy_invoke_logs() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("e2e"); + let username = &fx.admin_username; + + // 1) login + common::pic_as(&env) + .args(["login"]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Logged in as {username}"))); + + let creds_path = env.config_dir.path().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.as_str()), + "creds should contain username: {body}" + ); + + // 2) whoami + common::pic_as(&env) + .args(["whoami"]) + .assert() + .success() + .stdout(predicate::str::contains(username.clone())); + + // 3) apps create + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Created app {slug}"))); + + // Cleanup no matter what subsequent assertions do. + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + // 4) apps ls + common::pic_as(&env) + .args(["apps", "ls"]) + .assert() + .success() + .stdout(predicate::str::contains(slug.as_str())); + + // 5) scripts deploy (create then update) + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success() + .stdout(predicate::str::contains("Created hello v1")); + + common::pic_as(&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 = common::pic_as(&env) + .args(["scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls"); + assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}"); + let id = common::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 = common::pic_as(&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 = common::pic_as(&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}" + ); +}