@@ -1,0 +1,303 @@
//! End-to-end tests for the baked-in default server URL (issue #47).
//!
//! Anvil is a hosted service, not an on-prem product: `https://anvil.fangorn.io`
//! is the server in practically every invocation. These tests pin the resolution
//! chain (env > config file > baked-in default) and, critically, that a missing
//! *server URL* is no longer conflated with a missing *token* — a host with no
//! user config can still run the commands that send no credentials.
//!
//! Each test points `ANVIL_CONFIG` at a scratch file so the developer's real
//! credentials are never read or written. Deliberately not `XDG_CONFIG_HOME`:
//! `dirs::config_dir()` only honours that on Linux and returns
//! `~/Library/Application Support` on macOS, which would drive the live config.
//!
//! No test here talks to the production server. The default is asserted through
//! `auth status`, which resolves and reports the URL without making a request;
//! anything that needs an HTTP round-trip is pointed at a wiremock instead.
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const PRODUCTION_URL: &str = "https://anvil.fangorn.io";
/// A unique scratch config directory. `seed` writes a config.json; leaving it
/// unseeded models a freshly-provisioned host that never ran `auth login`.
struct ConfigHome {
root: PathBuf,
}
impl ConfigHome {
fn new(tag: &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-defaulturl-{tag}-{nanos}"));
std::fs::create_dir_all(&root).expect("create scratch config dir");
Self { root }
}
fn seed(self, contents: Value) -> Self {
std::fs::write(
self.config_path(),
serde_json::to_string_pretty(&contents).unwrap(),
)
.expect("seed config.json");
self
}
fn config_path(&self) -> PathBuf {
self.root.join("config.json")
}
/// Run with both env overrides stripped, so only the config file (or the
/// baked-in default) can supply the server URL.
fn run(&self, args: &[&str]) -> Output {
self.run_env(args, &[])
}
fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_anvil"));
cmd.args(args)
.env("ANVIL_CONFIG", self.config_path())
.env_remove("ANVIL_TOKEN")
.env_remove("ANVIL_SERVER_URL");
for (k, v) in env {
cmd.env(k, v);
}
cmd.output().expect("run anvil")
}
}
impl Drop for ConfigHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-001 — the default is baked in
// ─────────────────────────────────────────────────────────────────────────────
/// The bare case this issue was filed for: a host with no config file at all.
/// `auth status` must resolve the production URL instead of erroring.
#[test]
fn no_config_file_resolves_production_url() {
let home = ConfigHome::new("no-config");
assert!(
!home.config_path().exists(),
"precondition: no config file on disk"
);
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["server"], json!(PRODUCTION_URL));
assert!(
out.status.success(),
"auth status must not fail without a config file; stderr:\n{}",
stderr(&out)
);
}
/// A config file that exists but has no `server_url` (e.g. one written by
/// `repo set-default` before any login) resolves the default just the same.
#[test]
fn config_without_server_url_resolves_production_url() {
let home = ConfigHome::new("null-url").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!(PRODUCTION_URL));
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-002 — resolution precedence: env > config file > baked-in default
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_file_server_url_overrides_the_default() {
let home = ConfigHome::new("file-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!("https://staging.anvil.example"));
}
#[test]
fn env_server_url_overrides_the_config_file() {
let home = ConfigHome::new("env-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
#[test]
fn env_server_url_overrides_the_default() {
let home = ConfigHome::new("env-over-default");
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
/// The default must not be written to disk as a side effect of resolving it —
/// otherwise a host silently pins today's URL and stops following the default.
#[test]
fn resolving_the_default_does_not_persist_it() {
let home = ConfigHome::new("no-persist");
let _ = home.run(&["--json", "auth", "status"]);
assert!(
!home.config_path().exists(),
"auth status must not create a config file just by resolving the default"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-003 — commands that send no token work without a login
// ─────────────────────────────────────────────────────────────────────────────
/// `anvil update` hits `/runner/version` with no auth header. On a runner host
/// there is no user config at all, and this used to die with "not logged in".
///
/// Pointed at a wiremock rather than production: the claim under test is that
/// *no token* is required, and the baked-in default itself is covered above.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_succeeds_with_no_config_and_no_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "2026.07.9",
"platforms": ["linux_arm64"],
})))
.mount(&server)
.await;
let home = ConfigHome::new("update-nologin");
let out = home.run_env(
&["--json", "update", "--check"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
let err = stderr(&out);
assert!(
!err.contains("not logged in"),
"update sends no token and must not demand a login; stderr:\n{err}"
);
assert!(
out.status.success(),
"update --check must succeed without credentials; stderr:\n{err}"
);
// A read: the payload is printed bare, with no `ok` envelope.
let v = stdout_json(&out);
assert_eq!(v["latest"], json!("2026.07.9"), "payload: {v}");
assert_eq!(v["update_available"], json!(true), "payload: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-004 — the auth gate is unchanged for credentialed commands
// ─────────────────────────────────────────────────────────────────────────────
/// A resolvable server URL must not be mistaken for being authenticated: a
/// command that sends a bearer token still refuses to run without one.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_command_still_requires_a_token() {
let server = MockServer::start().await;
// Mounted so that a request slipping through would 200 rather than fail on
// transport — the assertion below then genuinely reflects the auth gate.
Mock::given(method("GET"))
.and(path("/api/v1/fangorn/anvil-cli/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "issues": [] })))
.mount(&server)
.await;
let home = ConfigHome::new("no-token").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let out = home.run_env(
&["--json", "issue", "list", "fangorn/anvil-cli"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
assert!(
!out.status.success(),
"issue list must fail without a token; stdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let combined = format!("{}{}", String::from_utf8_lossy(&out.stdout), stderr(&out));
assert!(
combined.contains("not logged in"),
"expected a not-logged-in error, got:\n{combined}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-005 — auth status reports on the token, not the server URL
// ─────────────────────────────────────────────────────────────────────────────
/// Now that a server URL always resolves, it can no longer be half the
/// logged-in signal — otherwise every unconfigured host reports itself as
/// logged in.
#[test]
fn auth_status_is_not_logged_in_without_a_token() {
let home = ConfigHome::new("status-notoken");
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["logged_in"], json!(false), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}
#[test]
fn auth_status_is_logged_in_with_a_token() {
let home = ConfigHome::new("status-token").seed(json!({
"server_url": null,
"token": "tok_abcdef123456",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["logged_in"], json!(true), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}