ref:main
//! End-to-end tests for `anvil auth rotate`.
//!
//! Rotation is the one command that can lock a user out of their own server, so
//! these drive the real binary against a mock Anvil and assert on what actually
//! lands in `config.json`.
//!
//! Each test points `ANVIL_CONFIG` at a scratch file so the developer's real
//! credentials are never touched. Note this deliberately does *not* use
//! `XDG_CONFIG_HOME`: `dirs::config_dir()` only honours that on Linux and
//! returns `~/Library/Application Support` on macOS, so an XDG-based harness
//! would silently drive the developer's live config there.
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Output;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A unique scratch config file, seeded with a config.json holding `token`.
struct ConfigHome {
root: PathBuf,
}
impl ConfigHome {
fn new(tag: &str, server_url: &str, token: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!("anvil-rotate-{tag}-{nanos}"));
std::fs::create_dir_all(&root).expect("create scratch config dir");
let me = Self { root };
std::fs::write(
me.config_path(),
serde_json::to_string_pretty(&json!({
"server_url": server_url,
"token": token,
"default_repo": null,
}))
.unwrap(),
)
.expect("seed config.json");
me
}
fn config_path(&self) -> PathBuf {
self.root.join("config.json")
}
fn stored(&self) -> Value {
let raw = std::fs::read_to_string(self.config_path()).expect("read config.json");
serde_json::from_str(&raw).expect("config.json is valid JSON")
}
fn run(&self, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(args)
.env("ANVIL_CONFIG", self.config_path())
// Must stay unset: these shadow the config file.
.env_remove("ANVIL_TOKEN")
.env_remove("ANVIL_SERVER_URL")
.output()
.expect("run anvil")
}
/// Like `run`, but with `ANVIL_SERVER_URL` exported — the shadowing case.
fn run_with_server_env(&self, args: &[&str], server_url: &str) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(args)
.env("ANVIL_CONFIG", self.config_path())
.env("ANVIL_SERVER_URL", server_url)
.env_remove("ANVIL_TOKEN")
.output()
.expect("run anvil")
}
}
impl Drop for ConfigHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Mock the rotate endpoint, requiring the *old* token as the bearer.
async fn mock_rotate(server: &MockServer, old_token: &str, new_token: &str) {
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.and(header(
"authorization",
format!("Bearer {old_token}").as_str(),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": new_token,
"id": "11111111-2222-3333-4444-555555555555",
"name": "laptop",
"scopes": ["repo:read"],
"token_prefix": "anvil_new",
"expires_at": null,
"revoked": {"id": "99999999-0000-0000-0000-000000000000", "token_prefix": "anvil_old"}
})))
.mount(server)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_replaces_the_stored_token() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("replace", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate", "--yes"]);
assert!(
out.status.success(),
"rotate failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(home.stored()["token"], "anvil_newtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_refuses_without_confirmation_when_not_a_tty() {
let server = MockServer::start().await;
// Deliberately no mock: the command must fail before issuing any request.
let home = ConfigHome::new("noconfirm", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate"]);
assert!(!out.status.success(), "expected a non-zero exit");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--yes"),
"error should point at --yes; got: {stderr}"
);
// The stored credential must be untouched.
assert_eq!(home.stored()["token"], "anvil_oldtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_leaves_the_old_token_in_place_when_the_server_rejects() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"forbidden"}"#),
)
.mount(&server)
.await;
let home = ConfigHome::new("rejected", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate", "--yes"]);
assert!(!out.status.success(), "expected a non-zero exit");
assert_eq!(
home.stored()["token"],
"anvil_oldtoken",
"a rejected rotation must not disturb the stored token"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_emits_a_json_envelope_under_json_mode() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("json", &server.uri(), "anvil_oldtoken");
let out = home.run(&["--json", "auth", "rotate", "--yes"]);
assert!(out.status.success(), "rotate failed");
let body: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not JSON: {e}\nstdout: {}",
String::from_utf8_lossy(&out.stdout)
)
});
assert_eq!(body["ok"], true);
assert_eq!(body["rotated"]["token"], "anvil_newtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_refuses_without_yes_under_json() {
let server = MockServer::start().await;
// No mock: --json without --yes must fail before issuing any request,
// rather than trying to prompt a script.
let home = ConfigHome::new("jsonconfirm", &server.uri(), "anvil_oldtoken");
let out = home.run(&["--json", "auth", "rotate"]);
assert!(!out.status.success(), "expected a non-zero exit");
// Under --json the error is a JSON envelope on stdout, not human stderr.
let body: Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON on error");
assert_eq!(body["ok"], false);
assert!(
body["error"].as_str().unwrap_or("").contains("--yes"),
"error should point at --yes; got {body}"
);
assert_eq!(home.stored()["token"], "anvil_oldtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_does_not_persist_an_env_server_url() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
// The file names one server; the environment transiently overrides it.
let home = ConfigHome::new("envurl", "https://stored.invalid", "anvil_oldtoken");
let out = home.run_with_server_env(&["auth", "rotate", "--yes"], &server.uri());
assert!(
out.status.success(),
"rotate failed\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(home.stored()["token"], "anvil_newtoken");
assert_eq!(
home.stored()["server_url"],
"https://stored.invalid",
"a transient ANVIL_SERVER_URL must not be written into the config"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_warns_on_stderr_when_env_token_shadows_the_config() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_envtoken", "anvil_newtoken").await;
let home = ConfigHome::new("shadow", &server.uri(), "anvil_filetoken");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(["--json", "auth", "rotate", "--yes"])
.env("ANVIL_CONFIG", home.config_path())
.env("ANVIL_TOKEN", "anvil_envtoken")
.env_remove("ANVIL_SERVER_URL")
.output()
.expect("run anvil");
assert!(out.status.success(), "rotate failed");
// stdout stays parseable JSON; the warning goes to stderr.
let body: Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
assert_eq!(body["ok"], true);
assert!(
String::from_utf8_lossy(&out.stderr).contains("ANVIL_TOKEN"),
"a --json caller must still be warned that its env token is now dead"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_writes_the_config_owner_readable_only() {
use std::os::unix::fs::PermissionsExt;
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("perms", &server.uri(), "anvil_oldtoken");
// Start from a deliberately permissive mode to prove save() tightens it.
std::fs::set_permissions(home.config_path(), std::fs::Permissions::from_mode(0o644))
.expect("relax perms");
assert!(home.run(&["auth", "rotate", "--yes"]).status.success());
let mode = std::fs::metadata(home.config_path())
.expect("stat config.json")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"config.json holds a bearer token; got {mode:o}"
);
}