mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
test(auth): extract shared CLI auth harness
Move the real CLI auth integration harness into shared test support so scenario/auth.rs keeps only the scenario cases and mock-browser helpers. This makes the real-server auth setup reusable for future CLI integration tests without duplicating the bootstrap code.
This commit is contained in:
parent
730c752f4f
commit
bd0a03701a
3 changed files with 558 additions and 471 deletions
|
|
@ -5,39 +5,20 @@
|
|||
|
||||
use std::io::{BufRead as _, BufReader, Read};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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;
|
||||
|
||||
use crate::support::{
|
||||
RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token,
|
||||
no_redirect_browser_client, run_detached, saved_auth_entry,
|
||||
};
|
||||
|
||||
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() {
|
||||
|
|
@ -676,449 +657,3 @@ 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 run_detached(
|
||||
context: &fabro_test::TestContext,
|
||||
target: &str,
|
||||
workflow: &std::path::Path,
|
||||
) -> String {
|
||||
let output = context
|
||||
.run_cmd()
|
||||
.args([
|
||||
"--server",
|
||||
target,
|
||||
"--detach",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
workflow.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("detached run should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"detached run failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
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}`"))
|
||||
}
|
||||
|
|
|
|||
546
lib/crates/fabro-cli/tests/it/support/auth_harness.rs
Normal file
546
lib/crates/fabro-cli/tests/it/support/auth_harness.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
use std::io::Read;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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, TestContext, apply_test_isolation};
|
||||
use fabro_types::RunAuthMethod;
|
||||
use hkdf::Hkdf;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use serde_json::Value;
|
||||
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";
|
||||
pub(crate) const TEST_DEV_TOKEN: &str =
|
||||
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
|
||||
pub(crate) struct RealAuthHarness {
|
||||
pub(crate) api_base_url: String,
|
||||
pub(crate) web_base_url: String,
|
||||
api_server: RunningHttpServer,
|
||||
web_server: RunningHttpServer,
|
||||
twin: fabro_test::TwinGitHub,
|
||||
pub(crate) api_requests: ListenerRequestLog,
|
||||
pub(crate) web_requests: ListenerRequestLog,
|
||||
}
|
||||
|
||||
impl RealAuthHarness {
|
||||
pub(crate) async fn start(github_state: GitHubAppState) -> Self {
|
||||
Self::start_with_settings(github_state, &["github"], None).await
|
||||
}
|
||||
|
||||
pub(crate) 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn api_target(&self) -> String {
|
||||
format!("{}/api/v1", self.api_base_url)
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(self) {
|
||||
self.api_server.shutdown().await;
|
||||
self.web_server.shutdown().await;
|
||||
self.twin.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn complete_login_via_browser(
|
||||
context: &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,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn run_detached(
|
||||
context: &TestContext,
|
||||
target: &str,
|
||||
workflow: &std::path::Path,
|
||||
) -> String {
|
||||
let output = context
|
||||
.run_cmd()
|
||||
.args([
|
||||
"--server",
|
||||
target,
|
||||
"--detach",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
workflow.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("detached run should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"detached run failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn saved_auth_entry(context: &TestContext) -> Value {
|
||||
auth_file_json(context)["servers"]
|
||||
.as_object()
|
||||
.and_then(|servers| servers.values().next())
|
||||
.cloned()
|
||||
.expect("saved auth should contain one server entry")
|
||||
}
|
||||
|
||||
pub(crate) fn expire_saved_access_token(context: &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()));
|
||||
}
|
||||
|
||||
pub(crate) 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")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ListenerRequestLog {
|
||||
entries: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl ListenerRequestLog {
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries
|
||||
.lock()
|
||||
.expect("request log mutex should lock")
|
||||
.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, needle: &str) -> bool {
|
||||
self.entries
|
||||
.lock()
|
||||
.expect("request log mutex should lock")
|
||||
.iter()
|
||||
.any(|entry| entry == needle)
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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_file_json(context: &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 auth_store_path(context: &TestContext) -> std::path::PathBuf {
|
||||
context.home_dir.join(".fabro/auth.json")
|
||||
}
|
||||
|
||||
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 read_stderr_and_capture_url(
|
||||
stderr: impl std::io::Read,
|
||||
url_tx: mpsc::Sender<String>,
|
||||
) -> Vec<u8> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
let mut reader = std::io::BufReader::new(stderr);
|
||||
let mut stderr_bytes = Vec::new();
|
||||
let mut line = Vec::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let read = reader
|
||||
.read_until(b'\n', &mut line)
|
||||
.expect("auth login stderr should be readable");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let trimmed = String::from_utf8_lossy(&line).trim().to_string();
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
let _ = url_tx.send(trimmed);
|
||||
}
|
||||
stderr_bytes.extend_from_slice(&line);
|
||||
}
|
||||
|
||||
stderr_bytes
|
||||
}
|
||||
|
||||
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}`"))
|
||||
}
|
||||
|
||||
fn wait_for_login_url(
|
||||
child: &mut std::process::Child,
|
||||
stdout: &mut impl std::io::Read,
|
||||
url_rx: &mpsc::Receiver<String>,
|
||||
) -> String {
|
||||
let deadline = Instant::now() + LOGIN_TIMEOUT;
|
||||
|
||||
loop {
|
||||
match url_rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(url) => return url,
|
||||
Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
}
|
||||
|
||||
if let Some(status) = child.try_wait().expect("auth login should stay alive") {
|
||||
let mut stdout_bytes = Vec::new();
|
||||
stdout
|
||||
.read_to_end(&mut stdout_bytes)
|
||||
.expect("auth login stdout should be readable");
|
||||
panic!(
|
||||
"auth login exited before printing the browser URL: {status}\nstdout:\n{}",
|
||||
String::from_utf8_lossy(&stdout_bytes),
|
||||
);
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let status = child.wait().expect("auth login should exit after kill");
|
||||
let mut stdout_bytes = Vec::new();
|
||||
stdout
|
||||
.read_to_end(&mut stdout_bytes)
|
||||
.expect("auth login stdout should be readable");
|
||||
panic!(
|
||||
"timed out waiting for auth login browser URL\nstatus: {status}\nstdout:\n{}",
|
||||
String::from_utf8_lossy(&stdout_bytes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
mod auth_harness;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use fabro_store::EventEnvelope;
|
||||
use fabro_test::{TestContext, preserve_coverage_env};
|
||||
|
|
@ -44,6 +46,10 @@ macro_rules! fabro_json_snapshot {
|
|||
}};
|
||||
}
|
||||
|
||||
pub(crate) use auth_harness::{
|
||||
RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token,
|
||||
no_redirect_browser_client, run_detached, saved_auth_entry,
|
||||
};
|
||||
pub(crate) use fabro_json_snapshot;
|
||||
|
||||
pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue