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