diff --git a/crates/picloud-cli/tests/api_keys.rs b/crates/picloud-cli/tests/api_keys.rs new file mode 100644 index 0000000..4da33de --- /dev/null +++ b/crates/picloud-cli/tests/api_keys.rs @@ -0,0 +1,170 @@ +//! `pic api-keys` — mint / ls / rm journeys. +//! +//! Server semantics asserted here: +//! * `mint` emits the `raw_token` *exactly once* and never on `ls`. +//! * A minted key is a valid bearer for `/auth/me`. +//! * After `rm`, the same token is rejected (401). + +use predicates::prelude::*; +use serde_json::Value; + +use crate::common; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn mint_prints_raw_token_once_and_ls_omits_it() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let name = format!("pic-cli-mint-{}", common::unique_slug("k")); + + let out = common::pic_as(&env) + .args([ + "--output", + "json", + "api-keys", + "mint", + &name, + "--scope", + "script:read", + ]) + .output() + .expect("api-keys mint"); + assert!(out.status.success(), "mint failed: {out:?}"); + let body: Value = serde_json::from_slice(&out.stdout).expect("JSON"); + let token = body["token"] + .as_str() + .expect("mint should expose `token`") + .to_string(); + let key_id = body["id"] + .as_str() + .expect("mint should expose `id`") + .to_string(); + assert!( + token.starts_with("pic_"), + "tokens are pic_-prefixed: {token}" + ); + + // `ls` must NEVER carry the raw token. The key row should appear, + // identified by name, but `token` is mint-only. + let ls = common::pic_as(&env) + .args(["--output", "json", "api-keys", "ls"]) + .output() + .expect("api-keys ls"); + assert!(ls.status.success(), "ls failed: {ls:?}"); + let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON"); + let arr = ls_body.as_array().expect("array"); + let row = arr + .iter() + .find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str())) + .expect("our key in ls"); + assert!( + row.get("token").is_none(), + "ls must not expose raw_token: {row}" + ); + + // Cleanup so we don't leak keys across runs. + common::pic_as(&env) + .args(["api-keys", "rm", &key_id]) + .assert() + .success(); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn minted_key_works_as_bearer() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let name = format!("pic-cli-bearer-{}", common::unique_slug("k")); + + let mint = common::pic_as(&env) + .args([ + "--output", + "json", + "api-keys", + "mint", + &name, + "--scope", + "script:read", + ]) + .output() + .expect("mint"); + assert!(mint.status.success()); + let body: Value = serde_json::from_slice(&mint.stdout).unwrap(); + let token = body["token"].as_str().unwrap().to_string(); + let id = body["id"].as_str().unwrap().to_string(); + + // Drive whoami with the minted token — proves the bearer string we + // captured really is what the server stamped. + let key_env = common::custom_env(&fx.url, &token); + common::seed_credentials(&key_env, &fx.admin_username); + common::pic_as(&key_env) + .args(["whoami"]) + .assert() + .success() + .stdout(predicate::str::contains(fx.admin_username.as_str())); + + common::pic_as(&env) + .args(["api-keys", "rm", &id]) + .assert() + .success(); +} + +/// After `rm`, the bearer token is dead server-side: a follow-up +/// `whoami` driven by it must 401, not 500. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn rm_revokes_the_token() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let name = format!("pic-cli-rm-{}", common::unique_slug("k")); + + let mint = common::pic_as(&env) + .args([ + "--output", + "json", + "api-keys", + "mint", + &name, + "--scope", + "script:read", + ]) + .output() + .expect("mint"); + let body: Value = serde_json::from_slice(&mint.stdout).unwrap(); + let token = body["token"].as_str().unwrap().to_string(); + let id = body["id"].as_str().unwrap().to_string(); + + common::pic_as(&env) + .args(["api-keys", "rm", &id]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Revoked api-key {id}"))); + + let dead = common::custom_env(&fx.url, &token); + common::pic_as(&dead) + .args(["whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 401")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn mint_with_unknown_scope_is_rejected_client_side() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + common::pic_as(&env) + .args(["api-keys", "mint", "doomed", "--scope", "script:nope"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown scope")); +} diff --git a/crates/picloud-cli/tests/apps.rs b/crates/picloud-cli/tests/apps.rs new file mode 100644 index 0000000..ff47194 --- /dev/null +++ b/crates/picloud-cli/tests/apps.rs @@ -0,0 +1,268 @@ +//! `pic apps create` / `pic apps ls` edge cases. The integration smoke +//! test covers the happy path; this module covers conflict, validation, +//! and the persistence of the optional `--name` / `--description` flags +//! (which `apps ls` doesn't surface). + +use predicates::prelude::*; +use serde_json::Value; + +use crate::common; +use crate::common::cleanup::AppGuard; +use crate::common::member; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_with_name_and_description_persists() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-named"); + + common::pic_as(&env) + .args([ + "apps", + "create", + &slug, + "--name", + "Pretty Name", + "--description", + "test description", + ]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + // `apps ls` only shows slug+name+role+created_at, so verify the + // persisted shape via the admin GET endpoint. + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{}/api/v1/admin/apps/{}", env.url, slug)) + .bearer_auth(&env.token) + .send() + .expect("GET app"); + assert!(resp.status().is_success(), "GET app failed: {resp:?}"); + let body: Value = resp.json().expect("app json"); + assert_eq!(body["slug"].as_str(), Some(slug.as_str())); + assert_eq!(body["name"].as_str(), Some("Pretty Name")); + assert_eq!(body["description"].as_str(), Some("test description")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_duplicate_slug_conflicts() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-dup"); + + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .failure() + .stderr(predicate::str::contains("409").or(predicate::str::contains("conflict"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_invalid_slug_rejected() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + // Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase + // breaks the rule on the very first char. The server returns 422 + // (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous + // `"HTTP 4"` predicate would have silently matched any other 4xx + // (a regressed 401 from broken auth, for example). + common::pic_as(&env) + .args(["apps", "create", "NotALowerSlug"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 422")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn ls_includes_created_app_with_expected_columns() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-ls"); + + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let out = common::pic_as(&env) + .args(["apps", "ls"]) + .output() + .expect("apps ls"); + assert!(out.status.success(), "apps ls failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); + let mut lines = stdout.lines(); + let header = lines.next().expect("header row"); + assert_eq!( + common::cells(header), + vec!["slug", "name", "my_role", "created_at"] + ); + + // The slug must appear in some data row and its row's my_role column + // is dashed (the ls endpoint doesn't compute it per-app). + let row = lines + .map(common::cells) + .find(|c| c.first().copied() == Some(slug.as_str())) + .unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}")); + assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}"); + assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn delete_removes_app_from_ls() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-del"); + + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + + common::pic_as(&env) + .args(["apps", "delete", &slug]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Deleted app {slug}"))); + + let out = common::pic_as(&env) + .args(["apps", "ls"]) + .output() + .expect("apps ls"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + !stdout.lines().any(|l| l.starts_with(&slug)), + "deleted slug should not appear in ls: {stdout}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn delete_with_scripts_errors_without_force() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-del-busy"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + // AppGuard is the safety net: if the no-force delete fails (as + // expected) the app stays around; AppGuard force-deletes on drop. + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success(); + + common::pic_as(&env) + .args(["apps", "delete", &slug]) + .assert() + .failure() + // Server `HasScripts` → 409 with a "scripts present" message. + .stderr(predicate::str::contains("HTTP 409")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn delete_with_scripts_succeeds_with_force() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("apps-del-force"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success(); + + common::pic_as(&env) + .args(["apps", "delete", &slug, "--force"]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Deleted app {slug}"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn show_prints_my_role_for_member() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let admin_env = common::admin_env(fx); + let slug = common::unique_slug("apps-show"); + common::pic_as(&admin_env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug); + + let m = member::member_user(fx, &common::unique_username("show")); + member::grant_membership(fx, &slug, &m.id, "viewer"); + + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + let out = common::pic_as(&member_env) + .args(["apps", "show", &slug]) + .output() + .expect("apps show"); + assert!(out.status.success(), "apps show failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + // KvBlock output: `my_role` row carries the wire form (`viewer`). + assert!( + stdout + .lines() + .any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")), + "show should surface my_role=viewer, got: {stdout}" + ); + assert!( + stdout.lines().any(|l| l.starts_with("slug")), + "show should include slug row: {stdout}" + ); +} diff --git a/crates/picloud-cli/tests/auth.rs b/crates/picloud-cli/tests/auth.rs new file mode 100644 index 0000000..945d148 --- /dev/null +++ b/crates/picloud-cli/tests/auth.rs @@ -0,0 +1,288 @@ +//! Login + whoami journeys beyond the happy path: bad tokens, missing +//! credentials file, stale on-disk creds, and the role-label rendered +//! by `pic login`. + +use predicates::prelude::*; + +use crate::common; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn login_persists_credentials_with_correct_perms() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + common::pic_as(&env).args(["login"]).assert().success(); + + let creds_path = env.config_dir.path().join("credentials"); + let body = std::fs::read_to_string(&creds_path).expect("credentials file"); + assert!( + body.contains(&format!("url = \"{}\"", env.url)), + "creds missing url line: {body}", + ); + assert!( + body.contains(&format!("token = \"{}\"", env.token)), + "creds missing token line: {body}", + ); + assert!( + body.contains(&format!("username = \"{}\"", fx.admin_username)), + "creds missing username line: {body}", + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}"); + } +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn login_rejects_bad_token() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::custom_env(&fx.url, "pic_garbage_token"); + + common::pic_as(&env) + .args(["login"]) + .assert() + .failure() + .stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected"))); + + let creds_path = env.config_dir.path().join("credentials"); + assert!( + !creds_path.exists(), + "failed login must not persist credentials" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn whoami_without_credentials_errors() { + let Some(_fx) = common::fixture_or_skip() else { + return; + }; + // Build a TestEnv directly so the config dir stays empty — + // `admin_env` would seed a credentials file, masking the bug + // this test is supposed to catch. + let env = common::TestEnv { + url: String::new(), + token: String::new(), + config_dir: tempfile::TempDir::new().unwrap(), + home: tempfile::TempDir::new().unwrap(), + }; + + common::pic_no_env(&env) + .args(["whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("pic login")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn whoami_with_stale_token_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + let body = format!( + "url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n", + env.url + ); + std::fs::write(env.config_dir.path().join("credentials"), body).unwrap(); + + common::pic_no_env(&env) + .args(["whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn login_prints_member_role_label() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let username = common::unique_username("auth"); + let m = common::member::member_user(fx, &username); + let env = common::custom_env(&fx.url, &m.token); + + common::pic_as(&env) + .args(["login"]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Logged in as {} (member)", + m.username + ))); +} + +/// Drive the real username+password flow end-to-end. `pic_no_env` +/// strips `PICLOUD_TOKEN` so login can't short-circuit through the +/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied +/// via `--url` to avoid the third prompt). +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn login_with_username_and_password_persists() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let username = common::unique_username("lpw"); + let m = common::member::member_user(fx, &username); + let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login + + let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD); + common::pic_no_env(&env) + .args(["login", "--url", &fx.url]) + .write_stdin(stdin_payload) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Logged in as {} (member)", + m.username + ))); + + let creds_path = env.config_dir.path().join("credentials"); + let body = std::fs::read_to_string(&creds_path).expect("credentials file"); + assert!( + body.contains(&format!("username = \"{}\"", m.username)), + "creds should carry the canonical username: {body}", + ); + // The token persisted must be a real session token, not whatever + // the user typed — a regression where we accidentally saved the + // password as the token would fail this check. + assert!( + !body.contains(common::member::MEMBER_PASSWORD), + "password leaked into credentials file: {body}", + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn login_with_wrong_password_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let username = common::unique_username("lpwbad"); + let m = common::member::member_user(fx, &username); + let env = common::custom_env(&fx.url, ""); + + let stdin_payload = format!("{}\nwrong-password\n", m.username); + common::pic_no_env(&env) + .args(["login", "--url", &fx.url]) + .write_stdin(stdin_payload) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 401")); + + let creds_path = env.config_dir.path().join("credentials"); + assert!( + !creds_path.exists(), + "failed login must not persist credentials" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logout_clears_local_credentials() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + // Use a member's token so we don't yank the admin session out from + // under parallel tests. The local-file cleanup is the same. + let username = common::unique_username("lout"); + let m = common::member::member_user(fx, &username); + let env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&env, &m.username); + + let creds_path = env.config_dir.path().join("credentials"); + assert!(creds_path.exists(), "precondition: creds file seeded"); + + common::pic_no_env(&env) + .args(["logout"]) + .assert() + .success() + .stdout(predicate::str::contains("Logged out")); + assert!( + !creds_path.exists(), + "credentials file should be removed after logout" + ); +} + +/// `pic logout` is meant to be idempotent: running it with no +/// credentials file present is not an error. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logout_is_idempotent_when_already_logged_out() { + let Some(_fx) = common::fixture_or_skip() else { + return; + }; + let env = common::TestEnv { + url: String::new(), + token: String::new(), + config_dir: tempfile::TempDir::new().unwrap(), + home: tempfile::TempDir::new().unwrap(), + }; + common::pic_no_env(&env) + .args(["logout"]) + .assert() + .success() + .stdout(predicate::str::contains("Logged out")); +} + +/// Server-side session invalidation: after `pic logout`, a subsequent +/// `pic whoami` driven by the same (now-stale) token must 401. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logout_invalidates_server_session() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let username = common::unique_username("lout2"); + let m = common::member::member_user(fx, &username); + let env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&env, &m.username); + + common::pic_no_env(&env).args(["logout"]).assert().success(); + + // Replay the member's old token explicitly — pic_no_env reads the + // (now-deleted) file, so we go back to env-driven mode with the + // stale bearer. + let stale = common::custom_env(&fx.url, &m.token); + common::pic_as(&stale) + .args(["whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 401")); +} + +/// Env vars must override the on-disk credentials file globally. Write +/// garbage into the file, set env to the real admin creds, and prove +/// every read-side command (here `whoami`) goes via env. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn env_vars_override_credentials_file() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::custom_env(&fx.url, &fx.admin_token); + // Garbage in the file: would 401 if used. + let body = format!( + "url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n", + env.url + ); + std::fs::write(env.config_dir.path().join("credentials"), body).unwrap(); + + common::pic_as(&env) + .args(["whoami"]) + .assert() + .success() + .stdout(predicate::str::contains(fx.admin_username.as_str())); +} diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 6f93c1b..541c6f3 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -12,4 +12,12 @@ //! cargo test -p picloud-cli --test cli -- --include-ignored mod common; -mod integration; + +mod api_keys; +mod apps; +mod auth; +mod invoke; +mod logs; +mod output; +mod roles; +mod scripts; diff --git a/crates/picloud-cli/tests/common/member.rs b/crates/picloud-cli/tests/common/member.rs index 2474c3c..8a45a55 100644 --- a/crates/picloud-cli/tests/common/member.rs +++ b/crates/picloud-cli/tests/common/member.rs @@ -30,7 +30,9 @@ pub fn member_user(fx: &Fixture, username: &str) -> MemberUser { .json(&json!({ "username": username, "password": MEMBER_PASSWORD, - "instance_role": "Member", + // InstanceRole / AppRole serialize via `rename_all = + // "snake_case"` — wire forms are always lowercase. + "instance_role": "member", })) .send() .expect("create member user"); diff --git a/crates/picloud-cli/tests/common/mod.rs b/crates/picloud-cli/tests/common/mod.rs index 1741cbe..852e823 100644 --- a/crates/picloud-cli/tests/common/mod.rs +++ b/crates/picloud-cli/tests/common/mod.rs @@ -89,20 +89,27 @@ pub struct TestEnv { pub home: TempDir, } -/// Per-test env pre-loaded with the admin token. Mirrors what the seed -/// test built inline. +/// Per-test env pre-loaded with the admin token, and a credentials +/// file already on disk so non-login commands ("pic apps create", …) +/// can run without first calling `pic login`. As of the env-var +/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`) +/// also work for *every* command, not just `login` — `config::resolve` +/// reads them first and falls back to the on-disk file. pub fn admin_env(fx: &Fixture) -> TestEnv { - TestEnv { + let env = TestEnv { url: fx.url.clone(), token: fx.admin_token.clone(), config_dir: TempDir::new().expect("config tempdir"), home: TempDir::new().expect("home tempdir"), - } + }; + seed_credentials(&env, &fx.admin_username); + env } /// 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. +/// URL. Does **not** seed a credentials file — call `seed_credentials` +/// explicitly when the test needs to run non-login commands. pub fn custom_env(url: &str, token: &str) -> TestEnv { TestEnv { url: url.to_string(), @@ -112,6 +119,19 @@ pub fn custom_env(url: &str, token: &str) -> TestEnv { } } +/// Write a valid credentials TOML into `env.config_dir` so subsequent +/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors +/// the file shape `pic login` produces (url/token/username). Tests that +/// exercise the "no credentials" / "stale token" error paths construct +/// `TestEnv` directly to keep the config dir empty. +pub fn seed_credentials(env: &TestEnv, username: &str) { + let body = format!( + "url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n", + env.url, env.token, username, + ); + std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file"); +} + /// `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 { @@ -183,6 +203,53 @@ pub fn fixture_path(name: &str) -> PathBuf { .join(name) } +/// Create a fresh app and deploy a `tests/fixtures/` into +/// it. Returns the new script id plus the `AppGuard` that cleans the +/// app (and its scripts via `force=true`) on Drop. Used by invoke / +/// logs / output journeys that all need "deploy something, then drive +/// `pic` against it". +pub fn deploy_fixture( + env: &TestEnv, + app_label: &str, + fixture_name: &str, +) -> (String, cleanup::AppGuard) { + let slug = unique_slug(app_label); + pic_as(env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug); + + let fixture = fixture_path(fixture_name); + pic_as(env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success(); + + let out = pic_as(env) + .args(["scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls"); + let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap()) + .expect("scripts ls should produce one row"); + (id, guard) +} + +/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed +/// cells. The output writer space-pads each cell to its column's max +/// width before the tab, so raw `split('\t')` leaves trailing spaces; +/// this helper hides that detail from tests that only care about the +/// logical values. +pub fn cells(row: &str) -> Vec<&str> { + row.split('\t').map(str::trim).collect() +} + /// 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 { diff --git a/crates/picloud-cli/tests/fixtures/boom.rhai b/crates/picloud-cli/tests/fixtures/boom.rhai new file mode 100644 index 0000000..711a7e7 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/boom.rhai @@ -0,0 +1,7 @@ +// Returns a structured 500. The execution is still `Success` in the +// log because the script ran cleanly — for an `Error`-status log entry +// use throw.rhai instead. +#{ + statusCode: 500, + body: #{ ok: false, why: "intentional" }, +} diff --git a/crates/picloud-cli/tests/fixtures/echo.rhai b/crates/picloud-cli/tests/fixtures/echo.rhai new file mode 100644 index 0000000..af76a4d --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/echo.rhai @@ -0,0 +1,6 @@ +// Echoes the request body and headers back so invoke tests can verify +// that `--body` (inline / @file / @-) and `-H` flow through end-to-end. +#{ + body: ctx.request.body, + headers: ctx.request.headers, +} diff --git a/crates/picloud-cli/tests/fixtures/loud.rhai b/crates/picloud-cli/tests/fixtures/loud.rhai new file mode 100644 index 0000000..61f2896 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/loud.rhai @@ -0,0 +1,5 @@ +// Logs a long line so the logs-truncation test has something to chew on. +// `pic logs` truncates the summary cell to 120 characters; this line is +// 240 chars after the prefix so the truncation is unambiguous. +log::info("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); +#{ ok: true } diff --git a/crates/picloud-cli/tests/fixtures/throw.rhai b/crates/picloud-cli/tests/fixtures/throw.rhai new file mode 100644 index 0000000..566d353 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/throw.rhai @@ -0,0 +1,4 @@ +// Throws a Rhai runtime error. The orchestrator records this as +// `ExecutionStatus::Error` in the execution log (a structured 5xx +// response is recorded as `Success`). +throw "boom"; diff --git a/crates/picloud-cli/tests/integration.rs b/crates/picloud-cli/tests/integration.rs deleted file mode 100644 index d7f99b6..0000000 --- a/crates/picloud-cli/tests/integration.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! 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}" - ); -} diff --git a/crates/picloud-cli/tests/invoke.rs b/crates/picloud-cli/tests/invoke.rs new file mode 100644 index 0000000..604f441 --- /dev/null +++ b/crates/picloud-cli/tests/invoke.rs @@ -0,0 +1,171 @@ +//! `pic scripts invoke` — body sources (inline, `@file`, `@-`), header +//! propagation, exit-code semantics for non-2xx responses, and 404 +//! handling for unknown ids. + +use predicates::prelude::*; +use serde_json::Value; + +use crate::common; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_with_inline_json_body_echoes() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "invoke-inline", "echo.rhai"); + + let out = common::pic_as(&env) + .args(["scripts", "invoke", &id, "--body", r#"{"x":1}"#]) + .output() + .expect("invoke"); + assert!(out.status.success(), "invoke failed: {out:?}"); + let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + assert_eq!(parsed["body"]["x"], 1, "echoed body: {parsed}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_with_file_body() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "invoke-file", "echo.rhai"); + + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + std::fs::write(tmp.path(), r#"{"src":"file"}"#).unwrap(); + let body_arg = format!("@{}", tmp.path().display()); + + let out = common::pic_as(&env) + .args(["scripts", "invoke", &id, "--body", &body_arg]) + .output() + .expect("invoke"); + assert!(out.status.success(), "invoke failed: {out:?}"); + let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + assert_eq!(parsed["body"]["src"], "file", "echoed body: {parsed}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_with_stdin_body() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "invoke-stdin", "echo.rhai"); + + let assert = common::pic_as(&env) + .args(["scripts", "invoke", &id, "--body", "@-"]) + .write_stdin(r#"{"src":"stdin"}"#) + .assert() + .success(); + let out = assert.get_output(); + let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + assert_eq!(parsed["body"]["src"], "stdin", "echoed body: {parsed}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_propagates_headers() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "invoke-hdr", "echo.rhai"); + + let out = common::pic_as(&env) + .args([ + "scripts", + "invoke", + &id, + "-H", + "X-Foo: bar", + "-H", + "X-Baz=qux", + ]) + .output() + .expect("invoke"); + assert!(out.status.success(), "invoke failed: {out:?}"); + let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + // HTTP normalises header names to lowercase. + assert_eq!(parsed["headers"]["x-foo"], "bar", "echoed: {parsed}"); + assert_eq!(parsed["headers"]["x-baz"], "qux", "echoed: {parsed}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_unknown_script_id_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + // Any well-formed UUID that doesn't exist server-side. The + // orchestrator's `/execute/{id}` handler returns 404 specifically + // for unknown ids — tighten the predicate so a regressed 401 + // wouldn't sneak through. + let bogus = "00000000-0000-0000-0000-000000000000"; + common::pic_as(&env) + .args(["scripts", "invoke", bogus]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 404")); +} + +/// `pic invoke ` (top-level alias) and `pic scripts invoke ` +/// must hit the same handler and produce identical-shape stdout. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn top_level_invoke_alias_works() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "inv-alias", "hello.rhai"); + + let nested = common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .output() + .expect("scripts invoke"); + assert!(nested.status.success()); + let nested_body: Value = serde_json::from_slice(&nested.stdout).unwrap(); + + let aliased = common::pic_as(&env) + .args(["invoke", &id]) + .output() + .expect("invoke (top-level)"); + assert!(aliased.status.success()); + let aliased_body: Value = serde_json::from_slice(&aliased.stdout).unwrap(); + + assert_eq!( + nested_body, aliased_body, + "top-level alias should produce identical body to scripts invoke" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_non_2xx_exits_nonzero_but_prints_body() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "invoke-500", "boom.rhai"); + + let out = common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .output() + .expect("invoke"); + assert!(!out.status.success(), "expected non-zero exit: {out:?}"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("<- HTTP 500"), + "stderr should report HTTP 500: {stderr}" + ); + let parsed: Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("stdout was not JSON ({e}): {:?}", out.stdout)); + assert_eq!(parsed["ok"], false, "boom body: {parsed}"); + assert_eq!(parsed["why"], "intentional", "boom body: {parsed}"); +} diff --git a/crates/picloud-cli/tests/logs.rs b/crates/picloud-cli/tests/logs.rs new file mode 100644 index 0000000..1b8e732 --- /dev/null +++ b/crates/picloud-cli/tests/logs.rs @@ -0,0 +1,179 @@ +//! `pic logs ` — emptiness, status labels, `--limit` +//! clamping, error path for unknown ids, and the 120-char truncate +//! applied to the summary column. + +use predicates::prelude::*; + +use crate::common; + +/// Pick out the data rows from `pic logs` TSV output — the header line +/// (`created_at\tstatus\tsummary`) is now always present, so the old +/// "no non-empty lines means no logs" check needs to skip it. +fn data_rows(stdout: &str) -> Vec<&str> { + stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .filter(|l| !l.starts_with("created_at")) + .collect() +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_for_fresh_script_is_empty() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "logs-empty", "hello.rhai"); + + let out = common::pic_as(&env) + .args(["logs", &id]) + .output() + .expect("logs"); + assert!(out.status.success(), "logs failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + data_rows(&stdout).is_empty(), + "expected no log rows (header is allowed), got: {stdout}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_after_invoke_records_success_row() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "logs-ok", "hello.rhai"); + + common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .assert() + .success(); + + let out = common::pic_as(&env) + .args(["logs", &id]) + .output() + .expect("logs"); + assert!(out.status.success(), "logs failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let rows = data_rows(&stdout); + assert_eq!(rows.len(), 1, "expected 1 data row, got: {stdout}"); + let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect(); + assert_eq!( + cols.len(), + 3, + "row should be 3 tab-delimited cells: {rows:?}" + ); + assert_eq!(cols[1], "success"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_records_error_for_throwing_script() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "logs-err", "throw.rhai"); + + // The invoke is expected to fail — we only care that the execution + // gets recorded with `Error` status. + let _ = common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .output(); + + let out = common::pic_as(&env) + .args(["logs", &id]) + .output() + .expect("logs"); + assert!(out.status.success(), "logs failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let row = data_rows(&stdout) + .into_iter() + .next() + .expect("at least one data row"); + let cols: Vec<&str> = row.split('\t').map(str::trim).collect(); + assert_eq!(cols[1], "error", "expected error status, got row: {row}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_respects_limit_flag() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "logs-limit", "hello.rhai"); + + for _ in 0..3 { + common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .assert() + .success(); + } + + let out = common::pic_as(&env) + .args(["logs", &id, "--limit", "1"]) + .output() + .expect("logs"); + assert!(out.status.success(), "logs failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let rows = data_rows(&stdout).len(); + assert_eq!(rows, 1, "expected --limit 1, got rows: {stdout}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_for_unknown_id_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + let bogus = "00000000-0000-0000-0000-000000000000"; + common::pic_as(&env) + .args(["logs", bogus]) + .assert() + .failure() + // 404 specifically — same `NotFound(ScriptId)` path the get/edit + // endpoints use. + .stderr(predicate::str::contains("HTTP 404")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_truncates_long_summary() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "logs-loud", "loud.rhai"); + + common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .assert() + .success(); + + let out = common::pic_as(&env) + .args(["logs", &id]) + .output() + .expect("logs"); + assert!(out.status.success(), "logs failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let row = data_rows(&stdout) + .into_iter() + .next() + .expect("at least one data row"); + let summary = row.split('\t').nth(2).expect("summary column"); + assert!( + summary.ends_with('…'), + "summary should be truncated with `…`, got: {summary}" + ); + let chars = summary.chars().count(); + assert!( + chars <= 121, + "summary should be ≤120 chars + the truncation marker, got {chars}: {summary}" + ); +} diff --git a/crates/picloud-cli/tests/output.rs b/crates/picloud-cli/tests/output.rs new file mode 100644 index 0000000..9730783 --- /dev/null +++ b/crates/picloud-cli/tests/output.rs @@ -0,0 +1,289 @@ +//! Output-shape invariants — the contracts downstream `jq`/`awk` +//! pipelines depend on: column headers, stdout-vs-stderr separation, +//! and RFC3339 timestamps. + +use serde_json::Value; + +use crate::common; +use crate::common::cleanup::AppGuard; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn apps_ls_header_columns() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + let out = common::pic_as(&env) + .args(["apps", "ls"]) + .output() + .expect("apps ls"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let header = stdout.lines().next().expect("header row"); + assert_eq!( + common::cells(header), + vec!["slug", "name", "my_role", "created_at"] + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn scripts_ls_header_columns() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("out-ls"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let out = common::pic_as(&env) + .args(["scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let header = stdout.lines().next().expect("header row"); + assert_eq!( + common::cells(header), + vec!["id", "app_slug", "name", "version", "updated_at"] + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn invoke_separates_stdout_and_stderr() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai"); + + let out = common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .output() + .expect("invoke"); + assert!(out.status.success()); + + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.starts_with("<- HTTP 200"), + "stderr should announce HTTP status: {stderr:?}" + ); + + let parsed: Value = serde_json::from_slice(&out.stdout) + .expect("stdout should be JSON only, with no status prefix"); + assert_eq!(parsed["ok"], true, "body: {parsed}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn error_goes_to_stderr_not_stdout() { + let Some(_fx) = common::fixture_or_skip() else { + return; + }; + // Use a pristine env (no credentials file) so `whoami` is guaranteed + // to fail at the `config::load` step — `admin_env` would pre-seed + // creds and the command would succeed. + let env = common::TestEnv { + url: String::new(), + token: String::new(), + config_dir: tempfile::TempDir::new().unwrap(), + home: tempfile::TempDir::new().unwrap(), + }; + + let out = common::pic_no_env(&env) + .args(["whoami"]) + .output() + .expect("whoami"); + assert!(!out.status.success(), "expected failure, got: {out:?}"); + assert!( + out.stdout.is_empty(), + "stdout should be empty on error, got: {:?}", + String::from_utf8_lossy(&out.stdout), + ); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("error:"), + "stderr should be prefixed with `error:`: {stderr}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn apps_ls_created_at_is_rfc3339() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("out-date"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let out = common::pic_as(&env) + .args(["apps", "ls"]) + .output() + .expect("apps ls"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let row = stdout + .lines() + .map(common::cells) + .find(|c| c.first().copied() == Some(slug.as_str())) + .unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}")); + let created_at = row.get(3).expect("created_at cell"); + + // Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS` + // with optional fraction + timezone is enough of a contract for the test. + assert!( + created_at.len() >= 20 + && created_at.as_bytes()[4] == b'-' + && created_at.as_bytes()[7] == b'-' + && created_at.as_bytes()[10] == b'T' + && created_at.as_bytes()[13] == b':' + && created_at.as_bytes()[16] == b':', + "created_at not RFC3339-shaped: {created_at}" + ); +} + +/// `--output json` is the global pipeline-friendly format. Validates +/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack). +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn apps_ls_json_output_is_valid_array() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("out-json-apps"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let out = common::pic_as(&env) + .args(["--output", "json", "apps", "ls"]) + .output() + .expect("apps ls --output json"); + assert!(out.status.success(), "apps ls failed: {out:?}"); + let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON"); + let arr = v.as_array().expect("apps ls JSON should be an array"); + assert!( + arr.iter() + .any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())), + "json should include created slug: {v}" + ); + // The header row must NOT bleed into JSON output — the rendered + // objects use header *keys*, not data cells. + assert!( + arr.iter().all(|row| row.get("slug").is_some()), + "every row should have a `slug` key: {v}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn scripts_ls_json_output_has_app_slug() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("out-json-scr"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success(); + + let out = common::pic_as(&env) + .args(["--output", "json", "scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls --output json"); + assert!(out.status.success()); + let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + let arr = v.as_array().expect("array"); + let row = arr + .iter() + .find(|r| r.get("name").and_then(Value::as_str) == Some("hello")) + .expect("hello row"); + assert_eq!(row["app_slug"].as_str(), Some(slug.as_str())); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn logs_json_output_is_array_of_objects() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai"); + common::pic_as(&env) + .args(["scripts", "invoke", &id]) + .assert() + .success(); + + let out = common::pic_as(&env) + .args(["--output", "json", "logs", &id]) + .output() + .expect("logs --output json"); + assert!(out.status.success()); + let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); + let arr = v.as_array().expect("array"); + assert!(!arr.is_empty(), "expected at least one log"); + // Schema: each row carries the raw `ExecutionLog`, not the + // truncated summary the TSV form uses. + assert!( + arr[0].get("status").is_some(), + "log row missing status: {arr:?}" + ); +} + +/// TSV `whoami` used to be a single tab-separated line with no labels; +/// downstream tools couldn't tell which column was the role. Now it's +/// a key/value block with stable labels. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn whoami_tsv_has_labeled_rows() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + + let out = common::pic_as(&env) + .args(["whoami"]) + .output() + .expect("whoami"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let labels: Vec<&str> = stdout + .lines() + .filter_map(|l| l.split('\t').next()) + .map(str::trim) + .collect(); + assert!( + labels.contains(&"username"), + "missing username row: {stdout}" + ); + assert!(labels.contains(&"role"), "missing role row: {stdout}"); + assert!(labels.contains(&"email"), "missing email row: {stdout}"); + assert!(labels.contains(&"url"), "missing url row: {stdout}"); +} diff --git a/crates/picloud-cli/tests/roles.rs b/crates/picloud-cli/tests/roles.rs new file mode 100644 index 0000000..29fb46c --- /dev/null +++ b/crates/picloud-cli/tests/roles.rs @@ -0,0 +1,146 @@ +//! RBAC mirror of the dashboard's role-shadowing specs. A Member user +//! is minted via the admin API, granted (or denied) membership on an +//! app, then `pic` is driven against the member's bearer token to +//! confirm the server's capability gates surface as expected exit +//! codes / error messages. + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::AppGuard; +use crate::common::member; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn member_apps_ls_only_shows_their_apps() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let admin_env = common::admin_env(fx); + + let slug_visible = common::unique_slug("roles-visible"); + let slug_hidden = common::unique_slug("roles-hidden"); + common::pic_as(&admin_env) + .args(["apps", "create", &slug_visible]) + .assert() + .success(); + let _g1 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_visible); + common::pic_as(&admin_env) + .args(["apps", "create", &slug_hidden]) + .assert() + .success(); + let _g2 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_hidden); + + let m = member::member_user(fx, &common::unique_username("rls")); + member::grant_membership(fx, &slug_visible, &m.id, "viewer"); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + let out = common::pic_as(&member_env) + .args(["apps", "ls"]) + .output() + .expect("apps ls"); + assert!(out.status.success(), "apps ls failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains(&slug_visible), + "member should see {slug_visible}, got: {stdout}" + ); + assert!( + !stdout.contains(&slug_hidden), + "member should NOT see {slug_hidden}, got: {stdout}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn viewer_cannot_deploy_but_editor_can() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let admin_env = common::admin_env(fx); + let slug = common::unique_slug("roles-write"); + common::pic_as(&admin_env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug); + + let m = member::member_user(fx, &common::unique_username("vw")); + member::grant_membership(fx, &slug, &m.id, "viewer"); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&member_env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .failure() + // `Forbidden` → 403. A regressed predicate of `"HTTP 4"` would + // have masked an auth break (401) as an authz issue. + .stderr(predicate::str::contains("HTTP 403")); + + // Promote to Editor and retry — the same command should now succeed. + member::update_membership(fx, &slug, &m.id, "editor"); + common::pic_as(&member_env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success() + .stdout(predicate::str::contains("Created hello v1")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn member_can_invoke_any_script_with_id() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + // `/api/v1/execute/{id}` is the unguarded data-plane ingress — even + // a member with no app membership can hit it as long as they hold + // a valid token (the orchestrator doesn't gate it). + let admin_env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&admin_env, "roles-inv", "hello.rhai"); + + let m = member::member_user(fx, &common::unique_username("inv")); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + common::pic_as(&member_env) + .args(["scripts", "invoke", &id]) + .assert() + .success(); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn non_member_cannot_read_logs() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let admin_env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&admin_env, "roles-log", "hello.rhai"); + + let m = member::member_user(fx, &common::unique_username("rl")); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + common::pic_as(&member_env) + .args(["logs", &id]) + .assert() + .failure() + // Non-member → 403 from the authz layer, not 404 — the script + // exists; the caller just can't see it. + .stderr(predicate::str::contains("HTTP 403")); +} diff --git a/crates/picloud-cli/tests/scripts.rs b/crates/picloud-cli/tests/scripts.rs new file mode 100644 index 0000000..3cd8c65 --- /dev/null +++ b/crates/picloud-cli/tests/scripts.rs @@ -0,0 +1,240 @@ +//! `pic scripts deploy` / `pic scripts ls` edge cases beyond the +//! smoke test: unknown app, name override, version bumping, missing +//! file, and the no-`--app` walk across every accessible app. + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::AppGuard; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn deploy_against_unknown_app_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let fixture = common::fixture_path("hello.rhai"); + let bogus_slug = common::unique_slug("nope"); + + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &bogus_slug, + ]) + .assert() + .failure() + // Specifically 404 — `apps_get` short-circuits before the deploy + // request even starts. Loose `"HTTP 4"` would have matched a + // regressed 401 from broken auth. + .stderr(predicate::str::contains("HTTP 404")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn deploy_with_name_override() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("scripts-named"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let fixture = common::fixture_path("hello.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + "--name", + "custom-name", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Created custom-name v1")); + + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + "--name", + "custom-name", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Updated custom-name v2")); + + let out = common::pic_as(&env) + .args(["scripts", "ls", "--app", &slug]) + .output() + .expect("scripts ls"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout + .lines() + .map(common::cells) + .any(|c| c.get(2).copied() == Some("custom-name") && c.get(3).copied() == Some("2")), + "expected custom-name v2 row, got: {stdout}", + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn deploy_bumps_version_each_redeploy() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("scripts-bump"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let fixture = common::fixture_path("hello.rhai"); + for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] { + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .success() + .stdout(predicate::str::contains(expected)); + } +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn deploy_missing_file_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("scripts-missing"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let missing = std::env::temp_dir().join(common::unique_slug("ghost") + ".rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + missing.to_str().unwrap(), + "--app", + &slug, + ]) + .assert() + .failure() + .stderr(predicate::str::contains("reading")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn ls_without_app_walks_every_accessible_app() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug_a = common::unique_slug("scripts-walk-a"); + let slug_b = common::unique_slug("scripts-walk-b"); + + common::pic_as(&env) + .args(["apps", "create", &slug_a]) + .assert() + .success(); + let _guard_a = AppGuard::new(&env.url, &env.token, &slug_a); + common::pic_as(&env) + .args(["apps", "create", &slug_b]) + .assert() + .success(); + let _guard_b = AppGuard::new(&env.url, &env.token, &slug_b); + + let fixture = common::fixture_path("hello.rhai"); + for slug in [&slug_a, &slug_b] { + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + slug, + ]) + .assert() + .success(); + } + + // `pic scripts ls` (no `--app`) issues a single `GET /admin/scripts` + // against the server now — there's nothing per-app to race against + // a concurrent AppGuard drop. The previous implementation walked + // `apps_list` followed by per-app `scripts_list_by_app` calls and + // aborted on the first 404, which forced this test to retry 5× to + // paper over the bug. Both the walk and the retry are gone. + let out = common::pic_as(&env) + .args(["scripts", "ls"]) + .output() + .expect("scripts ls"); + assert!(out.status.success(), "scripts ls failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + + let slugs: std::collections::HashSet<&str> = stdout + .lines() + .map(common::cells) + .filter_map(|c| c.get(1).copied()) + .collect(); + assert!( + slugs.contains(slug_a.as_str()), + "missing app A in: {stdout}" + ); + assert!( + slugs.contains(slug_b.as_str()), + "missing app B in: {stdout}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn delete_removes_script_from_ls() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (id, _guard) = common::deploy_fixture(&env, "scripts-del", "hello.rhai"); + + common::pic_as(&env) + .args(["scripts", "delete", &id]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Deleted script {id}"))); + + let out = common::pic_as(&env) + .args(["scripts", "ls"]) + .output() + .expect("scripts ls"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + !stdout.contains(&id), + "deleted script id should not appear in ls: {stdout}" + ); +}