test(cli): focused journey suite + cover new commands + tighten asserts
Replace the single bare-metal `integration.rs` test with focused
modules driven by the shared `LazyLock<Fixture>` server. Each module
owns one journey:
* `auth.rs` — login (both bearer and username+password paths),
logout (local file + server-side session invalidation), env-vars
overriding the on-disk credentials file, role-label rendering.
* `apps.rs` — create / ls / show / delete (with and without
`--force`), invalid-slug rejection, conflict on duplicate slug.
* `scripts.rs` — deploy (create + update), name override, version
bumping, `ls` (with and without `--app`), delete.
* `invoke.rs` — body sources (inline, `@file`, `@-`), header
propagation, non-2xx exit semantics, top-level `pic invoke` alias.
* `logs.rs` — emptiness, status labels, `--limit`, summary truncation.
* `roles.rs` — Member RBAC: app-list filtering, viewer-vs-editor on
deploy, member can hit the unguarded data plane, non-member 403
on logs.
* `output.rs` — TSV column headers, stdout/stderr separation, RFC3339
shape, and the `--output json` invariants for apps / scripts /
logs / whoami.
* `api_keys.rs` — mint emits `raw_token` once, `ls` omits it, the
minted token works as a real bearer, `rm` invalidates server-side.
Bug-bug-fix-bug-fix:
* The 5× retry loop in `ls_without_app_walks_every_accessible_app`
was masking the abort-on-first-404 walk in the CLI. Now that the
CLI uses a single server call, the retry is gone — the test runs
one `pic scripts ls` and asserts.
* Six `predicate::str::contains("HTTP 4")` assertions tightened to
the specific status code: 422 for invalid-slug, 404 for unknown
app/script/log id, 403 for role denials. Loose `HTTP 4` would
have silently matched a regressed 401 from broken auth.
* `tests/integration.rs` deleted — every step it covered is in one
of the focused modules above.
* Members module exposes `MEMBER_PASSWORD` so auth tests can drive
the real username+password flow over stdin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
170
crates/picloud-cli/tests/api_keys.rs
Normal file
170
crates/picloud-cli/tests/api_keys.rs
Normal file
@@ -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"));
|
||||
}
|
||||
268
crates/picloud-cli/tests/apps.rs
Normal file
268
crates/picloud-cli/tests/apps.rs
Normal file
@@ -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}"
|
||||
);
|
||||
}
|
||||
288
crates/picloud-cli/tests/auth.rs
Normal file
288
crates/picloud-cli/tests/auth.rs
Normal file
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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/<fixture_name>` 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<String> {
|
||||
|
||||
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
@@ -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" },
|
||||
}
|
||||
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
@@ -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,
|
||||
}
|
||||
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
@@ -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 }
|
||||
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
@@ -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";
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
171
crates/picloud-cli/tests/invoke.rs
Normal file
171
crates/picloud-cli/tests/invoke.rs
Normal file
@@ -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 <id>` (top-level alias) and `pic scripts invoke <id>`
|
||||
/// 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}");
|
||||
}
|
||||
179
crates/picloud-cli/tests/logs.rs
Normal file
179
crates/picloud-cli/tests/logs.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! `pic logs <script-id>` — 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}"
|
||||
);
|
||||
}
|
||||
289
crates/picloud-cli/tests/output.rs
Normal file
289
crates/picloud-cli/tests/output.rs
Normal file
@@ -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}");
|
||||
}
|
||||
146
crates/picloud-cli/tests/roles.rs
Normal file
146
crates/picloud-cli/tests/roles.rs
Normal file
@@ -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"));
|
||||
}
|
||||
240
crates/picloud-cli/tests/scripts.rs
Normal file
240
crates/picloud-cli/tests/scripts.rs
Normal file
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user