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>
315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
//! Shared fixture and helpers for the CLI integration test binary.
|
|
//!
|
|
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
|
|
//! that spawns picloud on a private port the first time it's touched
|
|
//! and reuses that subprocess for every subsequent test. The dashboard
|
|
//! Playwright suite pays the same cost once for 63 tests; we do the
|
|
//! same here.
|
|
|
|
#![allow(dead_code)] // shared helpers — not every module uses every fn.
|
|
|
|
pub mod cleanup;
|
|
pub mod member;
|
|
pub mod server;
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Child;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{LazyLock, Mutex, OnceLock};
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use assert_cmd::Command as AssertCommand;
|
|
use tempfile::TempDir;
|
|
|
|
// --------------------------------------------------------------------
|
|
// Fixture
|
|
// --------------------------------------------------------------------
|
|
|
|
pub struct Fixture {
|
|
pub url: String,
|
|
pub admin_token: String,
|
|
pub admin_username: String,
|
|
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
|
|
child: Mutex<Option<Child>>,
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut guard) = self.child.lock() {
|
|
if let Some(mut child) = guard.take() {
|
|
server::kill_subprocess(&mut child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
|
|
|
|
fn init_fixture() -> Fixture {
|
|
let database_url =
|
|
std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud");
|
|
let username = admin_username();
|
|
let password = admin_password();
|
|
let port = server::pick_free_port();
|
|
let url = format!("http://127.0.0.1:{port}");
|
|
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
|
|
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
|
|
server::kill_subprocess(&mut child);
|
|
panic!("picloud failed to become healthy: {e}");
|
|
}
|
|
let token = server::login_for_bearer_token(&url, &username, &password);
|
|
Fixture {
|
|
url,
|
|
admin_token: token,
|
|
admin_username: username,
|
|
child: Mutex::new(Some(child)),
|
|
}
|
|
}
|
|
|
|
/// Returns the shared fixture, spawning the picloud subprocess on first
|
|
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
|
|
/// is absent — matching the existing convention so the suite is a
|
|
/// no-op outside the integration environment.
|
|
pub fn fixture_or_skip() -> Option<&'static Fixture> {
|
|
if std::env::var("DATABASE_URL").is_err() {
|
|
eprintln!("skipping: DATABASE_URL not set");
|
|
return None;
|
|
}
|
|
Some(&FIXTURE)
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Per-test env
|
|
// --------------------------------------------------------------------
|
|
|
|
pub struct TestEnv {
|
|
pub url: String,
|
|
pub token: String,
|
|
pub config_dir: TempDir,
|
|
pub home: TempDir,
|
|
}
|
|
|
|
/// Per-test env pre-loaded with the admin token, 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 {
|
|
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. 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(),
|
|
token: token.to_string(),
|
|
config_dir: TempDir::new().expect("config tempdir"),
|
|
home: TempDir::new().expect("home tempdir"),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
|
cmd.env("PICLOUD_URL", &env.url)
|
|
.env("PICLOUD_TOKEN", &env.token)
|
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
|
.env("HOME", env.home.path());
|
|
cmd
|
|
}
|
|
|
|
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
|
|
/// command sees only the on-disk credentials file (or lack thereof).
|
|
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
|
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
|
cmd.env_remove("PICLOUD_URL")
|
|
.env_remove("PICLOUD_TOKEN")
|
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
|
.env("HOME", env.home.path());
|
|
cmd
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Unique slugs / usernames
|
|
// --------------------------------------------------------------------
|
|
|
|
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
pub fn unique_slug(prefix: &str) -> String {
|
|
let ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis();
|
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
format!("pic-cli-{prefix}-{ms}-{n:x}")
|
|
}
|
|
|
|
pub fn unique_username(prefix: &str) -> String {
|
|
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
|
|
// alphanumerics only; "piccli" prefix keeps collisions with other
|
|
// test suites obvious. Caller's `prefix` must be ≤8 chars and
|
|
// already match the regex.
|
|
let ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis();
|
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let name = format!("piccli{prefix}{ms:x}{n:x}");
|
|
assert!(name.len() <= 32, "username overflow: {name}");
|
|
name
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Misc helpers
|
|
// --------------------------------------------------------------------
|
|
|
|
pub fn admin_username() -> String {
|
|
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
|
}
|
|
|
|
pub fn admin_password() -> String {
|
|
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
|
}
|
|
|
|
pub fn fixture_path(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
/// 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> {
|
|
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
|
|
let header = lines.next()?;
|
|
if !header.starts_with("id") {
|
|
return None;
|
|
}
|
|
let row = lines.next()?;
|
|
let first = row.split('\t').next()?.trim();
|
|
if first.is_empty() {
|
|
None
|
|
} else {
|
|
Some(first.to_string())
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Fixture-sharing sanity check
|
|
// --------------------------------------------------------------------
|
|
//
|
|
// Two tests record `fixture().url` into the same `OnceLock` — if the
|
|
// fixture isn't actually shared, the second test sees a different URL
|
|
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
|
|
|
|
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
|
|
|
|
fn observe_fixture_url(label: &str) {
|
|
let Some(fx) = fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let url = fx.url.clone();
|
|
match OBSERVED_URL.get() {
|
|
Some(prev) => assert_eq!(
|
|
prev, &url,
|
|
"{label} observed a different fixture URL: prior={prev} now={url}"
|
|
),
|
|
None => {
|
|
let _ = OBSERVED_URL.set(url);
|
|
}
|
|
}
|
|
// Same `&'static Fixture` from every call — proves the LazyLock is
|
|
// sharing, not respawning.
|
|
let a = fixture_or_skip().unwrap();
|
|
let b = fixture_or_skip().unwrap();
|
|
assert!(
|
|
std::ptr::eq(a, b),
|
|
"fixture_or_skip should return the same &'static Fixture"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn fixture_url_is_shared_a() {
|
|
observe_fixture_url("fixture_url_is_shared_a");
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn fixture_url_is_shared_b() {
|
|
observe_fixture_url("fixture_url_is_shared_b");
|
|
}
|