fabro/lib/apps/fabro-cli/tests/it/scenario/auth.rs
2026-07-23 17:59:34 -04:00

844 lines
29 KiB
Rust

#![expect(
clippy::disallowed_methods,
reason = "These blocking CLI scenario tests spawn the real fabro binary and stream child pipes."
)]
#![expect(
clippy::disallowed_types,
reason = "These blocking CLI scenario tests intentionally use std::io readers around real child-process pipes."
)]
use std::io::{BufRead as _, BufReader, Read};
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use fabro_test::{GitHubAppState, apply_test_isolation, test_context};
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use serde_json::{Value, json};
use crate::support::{
RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token,
fatal_error_line, no_redirect_browser_client, run_detached, saved_auth_entry,
};
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
#[test]
fn auth_login_refresh_logout_flow() {
let context = test_context!();
let server = MockServer::start();
let target = server_target(&server);
let token_mock = server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/token")
.header("content-type", "application/json");
then.status(200)
.header("Content-Type", "application/json")
.json_body(token_response("access-1", "refresh-1", 600, 3_600));
});
let login_output = complete_login(&context, &target);
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!(
String::from_utf8_lossy(&login_output.stderr).contains("Logged in to"),
"login output should confirm success:\n{}",
String::from_utf8_lossy(&login_output.stderr)
);
token_mock.assert();
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");
let expired_access_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/v1/system/info")
.header("authorization", "Bearer access-1");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Access token expired.",
"code": "access_token_expired"
}]
}));
});
let refresh_mock = server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/refresh")
.header("authorization", "Bearer refresh-1");
then.status(200)
.header("Content-Type", "application/json")
.json_body(token_response("access-2", "refresh-2", 600, 3_600));
});
let refreshed_info_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/v1/system/info")
.header("authorization", "Bearer access-2");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"version": "1.2.3",
"git_sha": "abcdef0",
"build_date": "2026-04-20",
"profile": "release",
"os": "darwin",
"arch": "arm64",
"storage_dir": "/tmp/fabro-auth-flow",
"storage_engine": "slatedb",
"runs": { "total": 0, "active": 0 },
"uptime_secs": 42
}));
});
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 system_info_json: Value =
serde_json::from_slice(&system_info.stdout).expect("system info JSON should parse");
assert_eq!(system_info_json["version"], "1.2.3");
expired_access_mock.assert();
refresh_mock.assert();
refreshed_info_mock.assert();
let logout_mock = server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/logout")
.header("authorization", "Bearer refresh-2");
then.status(204);
});
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)
);
logout_mock.assert();
let logged_out_status = auth_status(&context, &target);
assert_eq!(
logged_out_status["servers"].as_array().map(Vec::len),
Some(0)
);
}
#[test]
fn auth_refresh_failure_clears_local_session() {
let context = test_context!();
let server = MockServer::start();
let target = server_target(&server);
server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/token")
.header("content-type", "application/json");
then.status(200)
.header("Content-Type", "application/json")
.json_body(token_response(
"access-revoked",
"refresh-revoked",
600,
3_600,
));
});
let login_output = complete_login(&context, &target);
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)
);
let expired_access_mock = server.mock(|when, then| {
when.method(GET)
.path("/api/v1/system/info")
.header("authorization", "Bearer access-revoked");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Access token expired.",
"code": "access_token_expired"
}]
}));
});
let refresh_mock = server.mock(|when, then| {
when.method(POST)
.path("/auth/cli/refresh")
.header("authorization", "Bearer refresh-revoked");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"error": "refresh_token_revoked",
"error_description": "CLI session has expired. Run `fabro auth login` again."
}));
});
let system_info = context
.command()
.args(["--json", "system", "info", "--server", &target])
.output()
.expect("system info should run");
assert!(
!system_info.status.success(),
"system info should fail when refresh is revoked"
);
assert_eq!(system_info.status.code(), Some(4));
assert_eq!(
fatal_error_line(&system_info.stderr),
"CLI session has expired. Run `fabro auth login` again."
);
expired_access_mock.assert();
refresh_mock.assert();
let status = auth_status(&context, &target);
assert_eq!(status["servers"].as_array().map(Vec::len), Some(0));
let local_token_mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/system/info").header(
"authorization",
"Bearer fabro_dev_abababababababababababababababababababababababababababababababab",
);
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"version": "dev-token-should-not-be-used"
}));
});
let auth_required_mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/system/info");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Authentication required.",
"code": "authentication_required"
}]
}));
});
let second_system_info = context
.command()
.args(["--json", "system", "info", "--server", &target])
.output()
.expect("follow-up system info should run");
assert!(
!second_system_info.status.success(),
"explicit remote target should not downgrade to a local dev token after refresh failure"
);
assert!(
String::from_utf8_lossy(&second_system_info.stderr).contains("Authentication required."),
"follow-up request should fail with auth required, got:\n{}",
String::from_utf8_lossy(&second_system_info.stderr)
);
local_token_mock.assert_calls(0);
auth_required_mock.assert();
}
#[test]
fn auth_required_error_suggests_auth_login() {
let context = test_context!();
let server = MockServer::start();
let target = server_target(&server);
let auth_required_mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/system/info");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Authentication required.",
"code": "authentication_required"
}]
}));
});
let output = context
.command()
.args(["system", "info", "--server", &target])
.output()
.expect("system info should run");
assert_eq!(output.status.code(), Some(4));
auth_required_mock.assert();
let stderr = console::strip_ansi_codes(&String::from_utf8_lossy(&output.stderr)).into_owned();
assert!(
stderr.contains("Authentication required."),
"stderr should surface the auth error, got:\n{stderr}"
);
assert!(
stderr.contains("Run `fabro auth login` to authenticate."),
"stderr should suggest `fabro auth login`, got:\n{stderr}"
);
}
#[test]
fn auth_required_hint_is_suppressed_in_json_mode() {
let context = test_context!();
let server = MockServer::start();
let target = server_target(&server);
server.mock(|when, then| {
when.method(GET).path("/api/v1/system/info");
then.status(401)
.header("Content-Type", "application/json")
.json_body(json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Authentication required.",
"code": "authentication_required"
}]
}));
});
let output = context
.command()
.args(["--json", "system", "info", "--server", &target])
.output()
.expect("system info should run");
assert_eq!(output.status.code(), Some(4));
let stderr = console::strip_ansi_codes(&String::from_utf8_lossy(&output.stderr)).into_owned();
assert!(
stderr.contains("Authentication required."),
"stderr should still surface the auth error in --json mode, got:\n{stderr}"
);
assert!(
!stderr.contains("fabro auth login"),
"stderr should not include the hint in --json mode, got:\n{stderr}"
);
}
#[test]
fn auth_login_rejects_unix_socket_target() {
let context = test_context!();
let socket_path = context.temp_dir.join("fabro.sock");
let output = context
.command()
.args([
"auth",
"login",
"--no-browser",
"--server",
socket_path.to_str().unwrap(),
])
.output()
.expect("auth login should execute");
assert!(
!output.status.success(),
"auth login should fail for unix-socket targets\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("fabro auth login requires an HTTP(S) server target"),
"unix-socket rejection should explain the transport requirement:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[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.api_base_url)),
"browser flow should open the server target origin, got: {browser_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("POST /auth/cli/token"));
assert!(harness.api_requests.contains("GET /auth/cli/start"));
assert!(harness.api_requests.contains("GET /auth/cli/resume"));
assert!(harness.api_requests.contains("POST /auth/cli/resume"));
harness.api_requests.clear();
let exec_output = context
.exec_cmd()
.args([
"--server",
&target,
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"say hello",
])
.output()
.expect("exec should run");
assert!(
!exec_output.status.success(),
"real-server exec should fail on missing provider credentials, not auth\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&exec_output.stdout),
String::from_utf8_lossy(&exec_output.stderr)
);
assert!(harness.api_requests.contains("POST /api/v1/completions"));
assert!(
!String::from_utf8_lossy(&exec_output.stderr).contains("API key not set"),
"exec --server should use server auth instead of local provider auth:\n{}",
String::from_utf8_lossy(&exec_output.stderr)
);
harness.api_requests.clear();
let workflow = context.install_fixture("simple.fabro");
let first_run_id = run_detached(&context, &target, &workflow);
assert!(harness.api_requests.contains("POST /api/v1/runs"));
assert!(
harness
.api_requests
.contains(&format!("POST /api/v1/runs/{first_run_id}/start"))
);
harness.api_requests.clear();
expire_saved_access_token(&context, &harness.api_base_url);
let expired_exec_output = context
.exec_cmd()
.args([
"--server",
&target,
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"say hello again",
])
.output()
.expect("expired exec should run");
assert!(
!expired_exec_output.status.success(),
"expired exec should still reach the server after refresh\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&expired_exec_output.stdout),
String::from_utf8_lossy(&expired_exec_output.stderr)
);
assert!(harness.api_requests.contains("POST /auth/cli/refresh"));
assert!(harness.api_requests.contains("POST /api/v1/completions"));
harness.api_requests.clear();
let second_run_id = run_detached(&context, &target, &workflow);
assert!(harness.api_requests.contains("POST /api/v1/runs"));
assert!(
harness
.api_requests
.contains(&format!("POST /api/v1/runs/{second_run_id}/start"))
);
assert!(
!harness.api_requests.contains("POST /auth/cli/refresh"),
"subsequent commands should use the freshly persisted access token without another refresh"
);
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 logged_out_run = context
.run_cmd()
.args([
"--server",
&target,
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("logged-out detached run should execute");
assert!(
!logged_out_run.status.success(),
"detached run should fail after logout\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&logged_out_run.stdout),
String::from_utf8_lossy(&logged_out_run.stderr)
);
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/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.api_base_url)),
"browser flow should open the server target 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.api_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.api_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())
}
fn token_response(
access_token: &str,
refresh_token: &str,
access_lifetime_secs: i64,
refresh_lifetime_secs: i64,
) -> Value {
let now = chrono::Utc::now();
json!({
"access_token": access_token,
"access_token_expires_at": (now + chrono::Duration::seconds(access_lifetime_secs)).to_rfc3339(),
"refresh_token": refresh_token,
"refresh_token_expires_at": (now + chrono::Duration::seconds(refresh_lifetime_secs)).to_rfc3339(),
"subject": {
"idp_issuer": "https://github.com",
"idp_subject": "12345",
"login": "octocat",
"name": "The Octocat",
"email": "octocat@example.com"
}
})
}
fn auth_status(context: &fabro_test::TestContext, target: &str) -> Value {
let output = context
.command()
.args(["--json", "auth", "status", "--server", target])
.output()
.expect("auth status should run");
assert!(
output.status.success(),
"auth status failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("auth status JSON should parse")
}
fn complete_login(context: &fabro_test::TestContext, target: &str) -> Output {
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);
deliver_callback(&browser_url);
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,
}
}
fn read_stderr_and_capture_url(
stderr: impl std::io::Read,
url_tx: &mpsc::Sender<String>,
) -> Vec<u8> {
let mut reader = 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 wait_for_login_url(
child: &mut std::process::Child,
stdout: &mut impl 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),
);
}
}
}
fn deliver_callback(browser_url: &str) {
let parsed = fabro_http::Url::parse(browser_url).expect("browser URL should parse");
let mut redirect_uri = None;
let mut state = None;
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"redirect_uri" => redirect_uri = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
_ => {}
}
}
let redirect_uri = redirect_uri.expect("browser URL should include redirect_uri");
let state = state.expect("browser URL should include state");
super::block_on(async move {
let response = fabro_test::test_http_client()
.get(format!("{redirect_uri}?code=cli-code&state={state}"))
.send()
.await
.expect("callback request should succeed");
assert!(
response.status().is_success(),
"callback request failed: {}",
response.status()
);
});
}