//! 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())); }