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:
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user