test(auth): add remaining CLI auth integration coverage

This commit is contained in:
Bryan Helmkamp 2026-04-20 08:05:22 -04:00
parent 61bce5450c
commit f2a512185e
No known key found for this signature in database
3 changed files with 707 additions and 2 deletions

38
Cargo.lock generated
View file

@ -826,6 +826,24 @@ dependencies = [
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "coolor"
version = "1.1.0"
@ -1627,6 +1645,7 @@ dependencies = [
"futures",
"git2",
"graphviz-sys",
"hkdf",
"httpmock",
"indicatif",
"insta",
@ -1639,6 +1658,7 @@ dependencies = [
"progenitor-client",
"rand 0.9.4",
"regex",
"reqwest 0.13.2",
"ring",
"rustls",
"rustls-pemfile",
@ -4938,6 +4958,22 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@ -5257,6 +5293,8 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-channel",
"futures-core",
"futures-util",

View file

@ -114,3 +114,5 @@ tempfile = "3"
httpmock = "0.8"
fabro-test = { workspace = true }
fabro-macros = { path = "../fabro-macros" }
hkdf.workspace = true
reqwest = { workspace = true, features = ["cookies"] }

View file

@ -5,15 +5,39 @@
use std::io::{BufRead as _, BufReader, Read};
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use fabro_test::{apply_test_isolation, test_context};
use axum::Router;
use axum::extract::{Request, State as AxumState};
use axum::middleware::{self, Next};
use axum::response::Response as AxumResponse;
use chrono::{Duration as ChronoDuration, Utc};
use fabro_config::{parse_settings_layer, resolve_server_from_file};
use fabro_server::auth::GithubEndpoints;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{
RouterOptions, build_router_with_options, create_app_state_with_env_lookup,
};
use fabro_test::{GitHubAppState, apply_test_isolation, test_context};
use fabro_types::RunAuthMethod;
use hkdf::Hkdf;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::{Value, json};
use sha2::Sha256;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use ulid::Ulid;
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
#[test]
fn auth_login_refresh_logout_flow() {
@ -234,6 +258,227 @@ fn auth_refresh_failure_clears_local_session() {
assert_eq!(status["servers"].as_array().map(Vec::len), Some(0));
}
#[tokio::test(flavor = "multi_thread")]
async fn auth_login_refresh_logout_flow_against_real_server_and_twin_github() {
let context = test_context!();
let harness = RealAuthHarness::start(GitHubAppState::new()).await;
let target = harness.api_target();
let (login_output, browser_url) = complete_login_via_browser(&context, &target).await;
assert!(
login_output.status.success(),
"auth login failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&login_output.stdout),
String::from_utf8_lossy(&login_output.stderr)
);
assert!(
browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)),
"browser flow should open the configured web origin, got: {browser_url}"
);
assert_ne!(harness.api_base_url, harness.web_base_url);
let status = auth_status(&context, &target);
assert_eq!(status["servers"].as_array().map(Vec::len), Some(1));
assert_eq!(status["servers"][0]["oauth_state"], "active");
assert_eq!(status["servers"][0]["login"], "octocat");
assert_eq!(status["servers"][0]["server"], harness.api_base_url);
assert!(harness.api_requests.contains("GET /api/v1/auth/cli/config"));
assert!(harness.web_requests.contains("GET /auth/cli/start"));
assert!(harness.api_requests.contains("POST /auth/cli/token"));
assert!(!harness.web_requests.contains("POST /auth/cli/token"));
expire_saved_access_token(&context, &harness.web_base_url);
let system_info = context
.command()
.args(["--json", "system", "info", "--server", &target])
.output()
.expect("system info should run");
assert!(
system_info.status.success(),
"system info failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&system_info.stdout),
String::from_utf8_lossy(&system_info.stderr)
);
let refreshed_entry = saved_auth_entry(&context);
let refreshed_refresh_token = refreshed_entry["refresh_token"]
.as_str()
.expect("saved auth should include refresh token")
.to_string();
let logout = context
.command()
.args(["auth", "logout", "--server", &target])
.output()
.expect("auth logout should run");
assert!(
logout.status.success(),
"auth logout failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&logout.stdout),
String::from_utf8_lossy(&logout.stderr)
);
let logged_out_status = auth_status(&context, &target);
assert_eq!(
logged_out_status["servers"].as_array().map(Vec::len),
Some(0)
);
let refresh_response = fabro_test::test_http_client()
.post(format!("{}/auth/cli/refresh", harness.api_base_url))
.bearer_auth(&refreshed_refresh_token)
.send()
.await
.expect("refresh request should succeed");
assert_eq!(refresh_response.status(), 401);
let refresh_body: Value = refresh_response
.json()
.await
.expect("refresh error body should parse");
assert_eq!(refresh_body["error"], "refresh_token_expired");
assert!(harness.api_requests.contains("POST /auth/cli/refresh"));
assert!(harness.api_requests.contains("POST /auth/cli/logout"));
harness.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn auth_login_surfaces_access_denied_from_real_browser_flow() {
let context = test_context!();
let mut github_state = GitHubAppState::new();
github_state.allow_authorize = false;
let harness = RealAuthHarness::start(github_state).await;
let target = harness.api_target();
let (login_output, browser_url) = complete_login_via_browser(&context, &target).await;
assert!(
browser_url.starts_with(&format!("{}/auth/cli/start", harness.web_base_url)),
"browser flow should open the configured web origin, got: {browser_url}"
);
assert!(
!login_output.status.success(),
"auth login should fail when GitHub denies access\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&login_output.stdout),
String::from_utf8_lossy(&login_output.stderr)
);
assert!(
String::from_utf8_lossy(&login_output.stderr).contains("Authorization denied."),
"denial should surface in CLI stderr:\n{}",
String::from_utf8_lossy(&login_output.stderr)
);
let status = auth_status(&context, &target);
assert_eq!(status["servers"].as_array().map(Vec::len), Some(0));
harness.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn auth_cli_start_ignores_dev_token_session_and_redirects_to_github_login() {
let harness = RealAuthHarness::start_with_dev_token(GitHubAppState::new()).await;
let client = no_redirect_browser_client();
let login_response = client
.post(format!("{}/auth/login/dev-token", harness.web_base_url))
.json(&json!({ "token": TEST_DEV_TOKEN }))
.send()
.await
.expect("dev-token login request should succeed");
assert_eq!(login_response.status(), reqwest::StatusCode::OK);
let response = client
.get(format!(
"{}/auth/cli/start?redirect_uri=http://127.0.0.1:4444/callback&state=abcdefghijklmnop&code_challenge=challenge&code_challenge_method=S256",
harness.web_base_url
))
.send()
.await
.expect("cli start request should succeed");
assert_eq!(response.status(), reqwest::StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth/login/github?return_to=/auth/cli/resume")
);
harness.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn auth_relogin_overwrites_local_entry_and_keeps_previous_refresh_chain_alive() {
let context = test_context!();
let harness = RealAuthHarness::start(GitHubAppState::new()).await;
let target = harness.api_target();
let (first_login_output, _) = complete_login_via_browser(&context, &target).await;
assert!(
first_login_output.status.success(),
"first auth login failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&first_login_output.stdout),
String::from_utf8_lossy(&first_login_output.stderr)
);
let first_entry = saved_auth_entry(&context);
let first_refresh_token = first_entry["refresh_token"]
.as_str()
.expect("first auth entry should include refresh token")
.to_string();
let first_access_token = first_entry["access_token"]
.as_str()
.expect("first auth entry should include access token")
.to_string();
let (second_login_output, _) = complete_login_via_browser(&context, &target).await;
assert!(
second_login_output.status.success(),
"second auth login failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&second_login_output.stdout),
String::from_utf8_lossy(&second_login_output.stderr)
);
let second_entry = saved_auth_entry(&context);
let second_refresh_token = second_entry["refresh_token"]
.as_str()
.expect("second auth entry should include refresh token")
.to_string();
let second_access_token = second_entry["access_token"]
.as_str()
.expect("second auth entry should include access token")
.to_string();
assert_ne!(second_refresh_token, first_refresh_token);
assert_ne!(second_access_token, first_access_token);
let status = auth_status(&context, &target);
assert_eq!(status["servers"].as_array().map(Vec::len), Some(1));
assert_eq!(status["servers"][0]["server"], harness.api_base_url);
let old_refresh_response = fabro_test::test_http_client()
.post(format!("{}/auth/cli/refresh", harness.api_base_url))
.bearer_auth(&first_refresh_token)
.send()
.await
.expect("old refresh token should reach the server");
assert_eq!(old_refresh_response.status(), reqwest::StatusCode::OK);
let old_refresh_body: Value = old_refresh_response
.json()
.await
.expect("old refresh body should parse");
assert_ne!(
old_refresh_body["refresh_token"].as_str(),
Some(first_refresh_token.as_str())
);
let saved_after_old_refresh = saved_auth_entry(&context);
assert_eq!(
saved_after_old_refresh["refresh_token"].as_str(),
Some(second_refresh_token.as_str())
);
harness.shutdown().await;
}
fn server_target(server: &MockServer) -> String {
format!("{}/api/v1", server.base_url())
}
@ -405,3 +650,423 @@ fn deliver_callback(browser_url: &str) {
);
});
}
struct RealAuthHarness {
api_base_url: String,
web_base_url: String,
api_server: RunningHttpServer,
web_server: RunningHttpServer,
twin: fabro_test::TwinGitHub,
api_requests: ListenerRequestLog,
web_requests: ListenerRequestLog,
}
impl RealAuthHarness {
async fn start(github_state: GitHubAppState) -> Self {
Self::start_with_settings(github_state, &["github"], None).await
}
async fn start_with_dev_token(github_state: GitHubAppState) -> Self {
Self::start_with_settings(github_state, &["github", "dev-token"], Some(TEST_DEV_TOKEN))
.await
}
async fn start_with_settings(
github_state: GitHubAppState,
auth_methods: &[&str],
dev_token: Option<&str>,
) -> Self {
let github_client_id = github_state.oauth_client_id.clone();
let github_client_secret = github_state.oauth_client_secret.clone();
let twin = fabro_test::TwinGitHub::start(github_state).await;
let (api_listener, api_base_url) = bind_listener().await;
let (web_listener, web_base_url) = bind_listener().await;
let settings = auth_settings(&web_base_url, &github_client_id, auth_methods);
let resolved = resolve_server_from_file(&settings).expect("settings should resolve");
let dev_token = dev_token.map(str::to_string);
let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name {
"SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()),
"GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()),
"FABRO_DEV_TOKEN" => dev_token.clone(),
_ => None,
})
.expect("auth mode should resolve");
let state = create_app_state_with_env_lookup(settings, 5, move |name| match name {
"SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()),
"GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()),
"FABRO_DEV_TOKEN" => dev_token.clone(),
_ => None,
});
let github_base = github_base_url(&twin.base_url);
let router = build_router_with_options(
state,
auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: true,
github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
github_base.clone(),
github_base,
))),
},
);
let api_requests = ListenerRequestLog::default();
let web_requests = ListenerRequestLog::default();
let api_server = RunningHttpServer::start(api_listener, router.clone(), &api_requests);
let web_server = RunningHttpServer::start(web_listener, router, &web_requests);
wait_for_http_ready(&api_base_url).await;
wait_for_http_ready(&web_base_url).await;
api_requests.clear();
web_requests.clear();
Self {
api_base_url,
web_base_url,
api_server,
web_server,
twin,
api_requests,
web_requests,
}
}
fn api_target(&self) -> String {
format!("{}/api/v1", self.api_base_url)
}
async fn shutdown(self) {
self.api_server.shutdown().await;
self.web_server.shutdown().await;
self.twin.shutdown().await;
}
}
async fn complete_login_via_browser(
context: &fabro_test::TestContext,
target: &str,
) -> (Output, String) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, &context.home_dir);
cmd.current_dir(&context.temp_dir);
cmd.args(["auth", "login", "--no-browser", "--server", target]);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("auth login should spawn");
let mut stdout = child
.stdout
.take()
.expect("auth login stdout should be piped");
let stderr = child
.stderr
.take()
.expect("auth login stderr should be piped");
let (url_tx, url_rx) = mpsc::channel();
let stderr_reader = std::thread::spawn(move || read_stderr_and_capture_url(stderr, url_tx));
let browser_url = wait_for_login_url(&mut child, &mut stdout, &url_rx);
drive_browser_flow(&browser_url).await;
let status = child.wait().expect("auth login should exit");
let mut stdout_bytes = Vec::new();
stdout
.read_to_end(&mut stdout_bytes)
.expect("auth login stdout should be readable");
let stderr_bytes = stderr_reader.join().expect("stderr reader should join");
(
Output {
status,
stdout: stdout_bytes,
stderr: stderr_bytes,
},
browser_url,
)
}
fn saved_auth_entry(context: &fabro_test::TestContext) -> Value {
auth_file_json(context)["servers"]
.as_object()
.and_then(|servers| servers.values().next())
.cloned()
.expect("saved auth should contain one server entry")
}
fn expire_saved_access_token(context: &fabro_test::TestContext, issuer: &str) {
let path = auth_store_path(context);
let mut file = auth_file_json(context);
let entry = file["servers"]
.as_object_mut()
.and_then(|servers| servers.values_mut().next())
.and_then(Value::as_object_mut)
.expect("saved auth should contain one mutable server entry");
let subject = entry
.get("subject")
.and_then(Value::as_object)
.cloned()
.expect("saved auth entry should include subject");
entry.insert(
"access_token".to_string(),
Value::String(expired_access_token(issuer, &subject)),
);
entry.insert(
"access_token_expires_at".to_string(),
Value::String((Utc::now() - ChronoDuration::seconds(30)).to_rfc3339()),
);
std::fs::write(
&path,
format!(
"{}\n",
serde_json::to_string_pretty(&file).expect("saved auth should serialize")
),
)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display()));
}
struct RunningHttpServer {
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
impl RunningHttpServer {
fn start(listener: TcpListener, router: Router, request_log: &ListenerRequestLog) -> Self {
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let router = router.layer(middleware::from_fn_with_state(
request_log.clone(),
record_request,
));
let handle = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.expect("test server should serve");
});
Self {
shutdown_tx: Some(shutdown_tx),
handle: Some(handle),
}
}
async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
handle.await.expect("test server task should join");
}
}
}
#[derive(Clone, Default)]
struct ListenerRequestLog {
entries: Arc<Mutex<Vec<String>>>,
}
impl ListenerRequestLog {
fn clear(&self) {
self.entries
.lock()
.expect("request log mutex should lock")
.clear();
}
fn contains(&self, needle: &str) -> bool {
self.entries
.lock()
.expect("request log mutex should lock")
.iter()
.any(|entry| entry == needle)
}
}
async fn record_request(
AxumState(log): AxumState<ListenerRequestLog>,
req: Request,
next: Next,
) -> AxumResponse {
log.entries
.lock()
.expect("request log mutex should lock")
.push(format!("{} {}", req.method(), req.uri().path()));
next.run(req).await
}
#[derive(serde::Serialize)]
struct TestJwtClaims {
iss: String,
aud: String,
sub: String,
exp: u64,
iat: u64,
jti: String,
idp_issuer: String,
idp_subject: String,
login: String,
name: String,
email: String,
auth_method: RunAuthMethod,
}
async fn bind_listener() -> (TcpListener, String) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener should bind");
let addr = listener
.local_addr()
.expect("bound test listener should have an address");
(listener, format!("http://127.0.0.1:{}", addr.port()))
}
fn auth_settings(
web_base_url: &str,
github_client_id: &str,
auth_methods: &[&str],
) -> fabro_types::settings::SettingsLayer {
let auth_methods = auth_methods
.iter()
.map(|method| format!("\"{method}\""))
.collect::<Vec<_>>()
.join(", ");
parse_settings_layer(&format!(
r#"
_version = 1
[server.auth]
methods = [{auth_methods}]
[server.auth.github]
allowed_usernames = ["octocat"]
[server.web]
url = "{web_base_url}"
[server.integrations.github]
client_id = "{github_client_id}"
"#
))
.expect("test settings should parse")
}
fn github_base_url(base_url: &str) -> fabro_http::Url {
fabro_http::Url::parse(&format!("{}/", base_url.trim_end_matches('/')))
.expect("twin github base URL should parse")
}
async fn drive_browser_flow(browser_url: &str) {
let response = browser_client()
.get(browser_url)
.send()
.await
.expect("browser flow request should succeed");
let status = response.status();
let body = response
.text()
.await
.expect("browser flow response body should be readable");
if status.is_success() {
return;
}
assert!(
status == reqwest::StatusCode::BAD_REQUEST && body.contains("Login failed:"),
"browser flow failed with {status}\n{body}"
);
}
fn browser_client() -> reqwest::Client {
reqwest::Client::builder()
.cookie_store(true)
.no_proxy()
.build()
.expect("browser client should build")
}
fn no_redirect_browser_client() -> reqwest::Client {
reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.expect("no-redirect browser client should build")
}
async fn wait_for_http_ready(base_url: &str) {
let client = fabro_test::test_http_client();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
match client.get(format!("{base_url}/health")).send().await {
Ok(response) if response.status().is_success() => return,
Ok(_) | Err(_) if std::time::Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(25)).await;
}
Ok(response) => panic!("server at {base_url} was not ready: {}", response.status()),
Err(err) => panic!("server at {base_url} was not ready: {err}"),
}
}
}
fn auth_store_path(context: &fabro_test::TestContext) -> std::path::PathBuf {
context.home_dir.join(".fabro/auth.json")
}
fn auth_file_json(context: &fabro_test::TestContext) -> Value {
let path = auth_store_path(context);
let contents = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
serde_json::from_str(&contents).expect("saved auth should parse")
}
fn expired_access_token(issuer: &str, subject: &serde_json::Map<String, Value>) -> String {
let key = derived_jwt_key();
let now = Utc::now();
let claims = TestJwtClaims {
iss: issuer.to_string(),
aud: "fabro-cli".to_string(),
sub: subject_value(subject, "idp_subject"),
exp: (now - ChronoDuration::minutes(10))
.timestamp()
.try_into()
.expect("expired timestamp should be positive"),
iat: (now - ChronoDuration::minutes(20))
.timestamp()
.try_into()
.expect("issued-at timestamp should be positive"),
jti: Ulid::new().to_string(),
idp_issuer: subject_value(subject, "idp_issuer"),
idp_subject: subject_value(subject, "idp_subject"),
login: subject_value(subject, "login"),
name: subject_value(subject, "name"),
email: subject_value(subject, "email"),
auth_method: RunAuthMethod::Github,
};
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(&key),
)
.expect("expired JWT should encode")
}
fn derived_jwt_key() -> [u8; 32] {
let hkdf = Hkdf::<Sha256>::new(None, TEST_SESSION_SECRET.as_bytes());
let mut key = [0_u8; 32];
hkdf.expand(b"fabro-jwt-hs256-v1", &mut key)
.expect("HKDF should derive the fixed-size JWT key");
key
}
fn subject_value(subject: &serde_json::Map<String, Value>, key: &str) -> String {
subject
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| panic!("saved auth subject should include `{key}`"))
}