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>
269 lines
8.4 KiB
Rust
269 lines
8.4 KiB
Rust
//! `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}"
|
|
);
|
|
}
|