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>
290 lines
9.4 KiB
Rust
290 lines
9.4 KiB
Rust
//! 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}");
|
|
}
|