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>
289 lines
9.4 KiB
Rust
289 lines
9.4 KiB
Rust
//! Login + whoami journeys beyond the happy path: bad tokens, missing
|
|
//! credentials file, stale on-disk creds, and the role-label rendered
|
|
//! by `pic login`.
|
|
|
|
use predicates::prelude::*;
|
|
|
|
use crate::common;
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn login_persists_credentials_with_correct_perms() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
|
|
common::pic_as(&env).args(["login"]).assert().success();
|
|
|
|
let creds_path = env.config_dir.path().join("credentials");
|
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
|
assert!(
|
|
body.contains(&format!("url = \"{}\"", env.url)),
|
|
"creds missing url line: {body}",
|
|
);
|
|
assert!(
|
|
body.contains(&format!("token = \"{}\"", env.token)),
|
|
"creds missing token line: {body}",
|
|
);
|
|
assert!(
|
|
body.contains(&format!("username = \"{}\"", fx.admin_username)),
|
|
"creds missing username line: {body}",
|
|
);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}");
|
|
}
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn login_rejects_bad_token() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::custom_env(&fx.url, "pic_garbage_token");
|
|
|
|
common::pic_as(&env)
|
|
.args(["login"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
|
|
|
let creds_path = env.config_dir.path().join("credentials");
|
|
assert!(
|
|
!creds_path.exists(),
|
|
"failed login must not persist credentials"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn whoami_without_credentials_errors() {
|
|
let Some(_fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
// Build a TestEnv directly so the config dir stays empty —
|
|
// `admin_env` would seed a credentials file, masking the bug
|
|
// this test is supposed to catch.
|
|
let env = common::TestEnv {
|
|
url: String::new(),
|
|
token: String::new(),
|
|
config_dir: tempfile::TempDir::new().unwrap(),
|
|
home: tempfile::TempDir::new().unwrap(),
|
|
};
|
|
|
|
common::pic_no_env(&env)
|
|
.args(["whoami"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("pic login"));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn whoami_with_stale_token_errors() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
|
|
let body = format!(
|
|
"url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n",
|
|
env.url
|
|
);
|
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
|
|
|
common::pic_no_env(&env)
|
|
.args(["whoami"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn login_prints_member_role_label() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let username = common::unique_username("auth");
|
|
let m = common::member::member_user(fx, &username);
|
|
let env = common::custom_env(&fx.url, &m.token);
|
|
|
|
common::pic_as(&env)
|
|
.args(["login"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(format!(
|
|
"Logged in as {} (member)",
|
|
m.username
|
|
)));
|
|
}
|
|
|
|
/// Drive the real username+password flow end-to-end. `pic_no_env`
|
|
/// strips `PICLOUD_TOKEN` so login can't short-circuit through the
|
|
/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied
|
|
/// via `--url` to avoid the third prompt).
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn login_with_username_and_password_persists() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let username = common::unique_username("lpw");
|
|
let m = common::member::member_user(fx, &username);
|
|
let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login
|
|
|
|
let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD);
|
|
common::pic_no_env(&env)
|
|
.args(["login", "--url", &fx.url])
|
|
.write_stdin(stdin_payload)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(format!(
|
|
"Logged in as {} (member)",
|
|
m.username
|
|
)));
|
|
|
|
let creds_path = env.config_dir.path().join("credentials");
|
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
|
assert!(
|
|
body.contains(&format!("username = \"{}\"", m.username)),
|
|
"creds should carry the canonical username: {body}",
|
|
);
|
|
// The token persisted must be a real session token, not whatever
|
|
// the user typed — a regression where we accidentally saved the
|
|
// password as the token would fail this check.
|
|
assert!(
|
|
!body.contains(common::member::MEMBER_PASSWORD),
|
|
"password leaked into credentials file: {body}",
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn login_with_wrong_password_errors() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let username = common::unique_username("lpwbad");
|
|
let m = common::member::member_user(fx, &username);
|
|
let env = common::custom_env(&fx.url, "");
|
|
|
|
let stdin_payload = format!("{}\nwrong-password\n", m.username);
|
|
common::pic_no_env(&env)
|
|
.args(["login", "--url", &fx.url])
|
|
.write_stdin(stdin_payload)
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("HTTP 401"));
|
|
|
|
let creds_path = env.config_dir.path().join("credentials");
|
|
assert!(
|
|
!creds_path.exists(),
|
|
"failed login must not persist credentials"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn logout_clears_local_credentials() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
// Use a member's token so we don't yank the admin session out from
|
|
// under parallel tests. The local-file cleanup is the same.
|
|
let username = common::unique_username("lout");
|
|
let m = common::member::member_user(fx, &username);
|
|
let env = common::custom_env(&fx.url, &m.token);
|
|
common::seed_credentials(&env, &m.username);
|
|
|
|
let creds_path = env.config_dir.path().join("credentials");
|
|
assert!(creds_path.exists(), "precondition: creds file seeded");
|
|
|
|
common::pic_no_env(&env)
|
|
.args(["logout"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Logged out"));
|
|
assert!(
|
|
!creds_path.exists(),
|
|
"credentials file should be removed after logout"
|
|
);
|
|
}
|
|
|
|
/// `pic logout` is meant to be idempotent: running it with no
|
|
/// credentials file present is not an error.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn logout_is_idempotent_when_already_logged_out() {
|
|
let Some(_fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::TestEnv {
|
|
url: String::new(),
|
|
token: String::new(),
|
|
config_dir: tempfile::TempDir::new().unwrap(),
|
|
home: tempfile::TempDir::new().unwrap(),
|
|
};
|
|
common::pic_no_env(&env)
|
|
.args(["logout"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Logged out"));
|
|
}
|
|
|
|
/// Server-side session invalidation: after `pic logout`, a subsequent
|
|
/// `pic whoami` driven by the same (now-stale) token must 401.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn logout_invalidates_server_session() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let username = common::unique_username("lout2");
|
|
let m = common::member::member_user(fx, &username);
|
|
let env = common::custom_env(&fx.url, &m.token);
|
|
common::seed_credentials(&env, &m.username);
|
|
|
|
common::pic_no_env(&env).args(["logout"]).assert().success();
|
|
|
|
// Replay the member's old token explicitly — pic_no_env reads the
|
|
// (now-deleted) file, so we go back to env-driven mode with the
|
|
// stale bearer.
|
|
let stale = common::custom_env(&fx.url, &m.token);
|
|
common::pic_as(&stale)
|
|
.args(["whoami"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("HTTP 401"));
|
|
}
|
|
|
|
/// Env vars must override the on-disk credentials file globally. Write
|
|
/// garbage into the file, set env to the real admin creds, and prove
|
|
/// every read-side command (here `whoami`) goes via env.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn env_vars_override_credentials_file() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::custom_env(&fx.url, &fx.admin_token);
|
|
// Garbage in the file: would 401 if used.
|
|
let body = format!(
|
|
"url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n",
|
|
env.url
|
|
);
|
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
|
|
|
common::pic_as(&env)
|
|
.args(["whoami"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
|
}
|