Files
PiCloud/crates/picloud-cli/tests/common/member.rs
MechaCat02 c73e3c80c0 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>
2026-05-29 23:34:03 +02:00

100 lines
3.2 KiB
Rust

//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus
//! direct API calls for granting / updating app memberships.
//!
//! These talk to the manager HTTP surface directly instead of going
//! through the CLI, so role-gated tests can stage state without
//! requiring `pic` to grow new commands.
use serde_json::{json, Value};
use super::cleanup::UserGuard;
use super::Fixture;
pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678";
pub struct MemberUser {
pub id: String,
pub username: String,
pub token: String,
pub _guard: UserGuard,
}
/// Mint a fresh `instance_role: Member` user, log them in for a bearer
/// token, and register a `UserGuard` for teardown.
pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
let client = reqwest::blocking::Client::new();
let create = client
.post(format!("{}/api/v1/admin/admins", fx.url))
.bearer_auth(&fx.admin_token)
.json(&json!({
"username": username,
"password": MEMBER_PASSWORD,
// InstanceRole / AppRole serialize via `rename_all =
// "snake_case"` — wire forms are always lowercase.
"instance_role": "member",
}))
.send()
.expect("create member user");
assert!(
create.status().is_success(),
"create member user failed: {} {}",
create.status(),
create.text().unwrap_or_default(),
);
let body: Value = create.json().expect("admin create json");
let id = body["id"]
.as_str()
.expect("admin create returns id")
.to_string();
// Register cleanup before we attempt anything else that could fail.
let guard = UserGuard::new(&fx.url, &fx.admin_token, &id);
let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD);
MemberUser {
id,
username: username.to_string(),
token,
_guard: guard,
}
}
/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`.
pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant membership");
assert!(
resp.status().is_success(),
"grant membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.patch(format!(
"{}/api/v1/admin/apps/{}/members/{}",
fx.url, app_slug, user_id
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "role": role }))
.send()
.expect("update membership");
assert!(
resp.status().is_success(),
"update membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}