use std::time::Duration;
use tokio::io::AsyncReadExt;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::error::{OlError, ERR_AUTH_FLOW_FAILED, ERR_AUTH_TIMEOUT};
pub use crate::cli::AuthLoginArgs;
pub fn try_open_browser(url: &str) -> bool {
#[cfg(target_os = "linux")]
{
let has_display = std::env::var("DISPLAY").is_ok();
let has_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if !has_display && !has_wayland {
return false;
}
}
open::that(url).is_ok()
}
pub fn mask_api_key(key: &str) -> String {
if key.len() <= 11 {
return key.to_string();
}
let prefix = &key[..7];
let suffix = &key[key.len() - 4..];
format!("{prefix}...{suffix}")
}
fn url_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'+' {
out.push(' ');
i += 1;
} else if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(char::from((h * 16 + l) as u8));
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
pub fn system_hostname() -> Option<String> {
#[cfg(unix)]
{
use std::ffi::CStr;
let mut buf = [0u8; 256];
let ret = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if ret != 0 {
return None;
}
let cstr = unsafe { CStr::from_ptr(buf.as_ptr() as *const libc::c_char) };
let s = cstr.to_str().ok()?.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[cfg(windows)]
{
let s = std::env::var("COMPUTERNAME").ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
pub fn parse_callback_params(query: &str) -> Result<(String, String, String), OlError> {
let mut api_key = String::new();
let mut org_name = String::new();
let mut org_id = String::new();
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
let k = parts.next().unwrap_or("").trim();
let v = parts.next().unwrap_or("").trim();
match k {
"key" => api_key = url_decode(v),
"org_name" => org_name = url_decode(v),
"org_id" => org_id = url_decode(v),
_ => {}
}
}
if api_key.is_empty() {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback is missing required 'key' parameter",
)
.with_suggestion("The authentication server may be misconfigured. Try again."));
}
Ok((api_key, org_name, org_id))
}
pub fn build_success_html() -> &'static str {
r#"<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>OpenLatch — Authentication Successful</title>
<style>body{font-family:sans-serif;max-width:480px;margin:60px auto;text-align:center;color:#333}</style>
</head>
<body>
<h1>Authentication successful!</h1>
<p>You can close this tab and return to your terminal.</p>
</body>
</html>"#
}
pub fn keychain_backend_name() -> &'static str {
#[cfg(target_os = "macos")]
return "macOS Keychain";
#[cfg(target_os = "windows")]
return "Windows Credential Manager";
#[cfg(target_os = "linux")]
return "Linux Secret Service";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return "OS Keychain";
}
pub async fn accept_callback(
listener: &tokio::net::TcpListener,
) -> Result<(String, String, String), OlError> {
let (mut stream, _) = listener.accept().await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to accept callback connection: {e}"),
)
})?;
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to read callback request: {e}"),
)
})?;
if n == 0 {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback received empty (truncated) HTTP request",
)
.with_suggestion("The browser may have closed the connection. Try again."));
}
let raw = &buf[..n];
let request_str = String::from_utf8_lossy(raw);
let first_line = request_str.lines().next().unwrap_or("");
if !first_line.starts_with("GET ") {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Callback received unexpected request: {first_line}"),
));
}
let path_part = first_line
.trim_start_matches("GET ")
.split_whitespace()
.next()
.unwrap_or("");
let query = if let Some(pos) = path_part.find('?') {
&path_part[pos + 1..]
} else {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback URL missing query parameters (api_key not provided)",
)
.with_suggestion("The authentication server may be misconfigured. Try again."));
};
let params = parse_callback_params(query)?;
use tokio::io::AsyncWriteExt;
let html = build_success_html();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(response.as_bytes()).await;
Ok(params)
}
pub fn run_login(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
let started = std::time::Instant::now();
let result = rt.block_on(run_login_async(args, output));
let duration_ms = std::time::Instant::now()
.duration_since(started)
.as_millis()
.min(u128::from(u64::MAX)) as u64;
match &result {
Ok(()) => {
crate::telemetry::capture_global(crate::telemetry::Event::auth_completed(
"browser",
duration_ms,
));
}
Err(e) => {
let stage = match e.code {
"OL-1605" => "timeout",
"OL-1606" => "callback",
_ => "other",
};
crate::telemetry::capture_global(crate::telemetry::Event::auth_failed(e.code, stage));
}
}
result
}
async fn run_login_async(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
use tokio::sync::oneshot;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to bind callback server: {e}"),
)
.with_suggestion("Check that no firewall rules block localhost connections.")
})?;
let port = listener
.local_addr()
.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to get callback port: {e}"),
)
})?
.port();
let callback_url = format!("http://127.0.0.1:{port}/callback");
let app_url = resolve_app_url();
let mut auth_url = format!(
"{}/cli-auth?callback={callback_url}",
app_url.trim_end_matches('/')
);
if let Some(h) = system_hostname() {
auth_url.push_str("&hostname=");
auth_url.push_str(&url_encode(&h));
}
let browser_opened = if args.no_browser {
false
} else {
try_open_browser(&auth_url)
};
if browser_opened {
output.print_info("Opening browser for authentication...");
} else {
output.print_info("Open the following URL in your browser to authenticate:");
}
output.print_info(&format!("\n {auth_url}\n"));
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
let spinner_handle = if output.format != OutputFormat::Json && !output.quiet {
let total_secs = 300u64;
Some(tokio::spawn(async move {
let pb = indicatif::ProgressBar::new_spinner();
pb.set_draw_target(indicatif::ProgressDrawTarget::stderr());
pb.enable_steady_tick(Duration::from_millis(100));
let mut remaining = total_secs;
let mut cancel_rx = cancel_rx;
loop {
let minutes = remaining / 60;
let seconds = remaining % 60;
pb.set_message(format!(
"Waiting for authentication... ({minutes}:{seconds:02} remaining)"
));
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {
remaining = remaining.saturating_sub(1);
}
_ = &mut cancel_rx => {
pb.finish_and_clear();
break;
}
}
}
}))
} else {
drop(cancel_rx);
None
};
let callback_result =
tokio::time::timeout(Duration::from_secs(300), accept_callback(&listener)).await;
let _ = cancel_tx.send(());
if let Some(handle) = spinner_handle {
let _ = handle.await;
}
let (api_key, org_name, org_id) = match callback_result {
Err(_timeout) => {
return Err(
OlError::new(ERR_AUTH_TIMEOUT, "Authentication timed out after 5 minutes")
.with_suggestion("Run 'openlatch auth login' to try again."),
);
}
Ok(Err(e)) => return Err(e),
Ok(Ok(params)) => params,
};
let store = crate::core::auth::KeyringCredentialStore::new();
let secret_key = secrecy::SecretString::from(api_key.clone());
store.store_async(secret_key).await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to store API key in keychain: {}", e.message),
)
.with_suggestion("Try running 'openlatch auth login' again.")
})?;
{
let api_url = crate::core::config::Config::load(None, None, false)
.ok()
.map(|c| c.cloud.api_url)
.unwrap_or_else(|| "https://app.openlatch.ai/api".to_string());
let validation = validate_online_full(&api_key, &api_url).await;
if let Some(user_db_id) = validation.user_db_id.as_deref() {
if let Some(handle) = crate::telemetry::global() {
let dir = crate::config::openlatch_dir();
let agent_id = crate::core::config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_else(|| "agt_unknown".into());
let org_id_opt = if validation.org_id.is_empty() {
None
} else {
Some(validation.org_id.as_str())
};
crate::telemetry::identity::record_auth_success(
handle, &dir, &agent_id, user_db_id, org_id_opt,
);
}
}
}
let masked = mask_api_key(&api_key);
let backend = keychain_backend_name();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"authenticated": true,
"org_name": org_name,
"org_id": org_id,
"key_prefix": masked,
"keychain_backend": backend,
});
output.print_json(&json);
} else {
output.print_step("Authenticated successfully");
if !org_name.is_empty() {
output.print_substep(&format!("Org: {org_name} ({org_id})"));
}
output.print_substep(&format!("API key: {masked}"));
output.print_substep(&format!("Stored in: {backend}"));
}
Ok(())
}
pub fn run_logout(output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
rt.block_on(run_logout_async(output))
}
async fn run_logout_async(output: &OutputConfig) -> Result<(), OlError> {
let store = crate::core::auth::KeyringCredentialStore::new();
let api_url = load_agent_id()
.and_then(|_| crate::core::config::Config::load(None, None, false).ok())
.map(|c| c.cloud.api_url)
.unwrap_or_else(|| "https://app.openlatch.ai/api".to_string());
let server_revoked = match store.retrieve_async().await {
Ok(key) => {
use secrecy::ExposeSecret;
let key_str = key.expose_secret().to_string();
attempt_server_revocation(&key_str, &api_url).await
}
Err(_) => false,
};
if let Err(e) = store.delete_async().await {
tracing::warn!(error = %e.message, "Failed to delete credential from keychain");
}
let backend = keychain_backend_name();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"logged_out": true,
"server_revoked": server_revoked,
"backend": backend,
});
output.print_json(&json);
} else {
if server_revoked {
output.print_step("API key revoked on server");
} else {
output.print_step("Server revocation failed — continuing with local cleanup");
}
output.print_substep(&format!("Credentials cleared from {backend}"));
output.print_substep("Cloud forwarding is now disabled");
output.print_info("\nRun 'openlatch auth login' to re-authenticate.");
}
Ok(())
}
pub fn run_status(output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
rt.block_on(run_status_async(output))
}
async fn run_status_async(output: &OutputConfig) -> Result<(), OlError> {
use secrecy::ExposeSecret;
let store = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
let api_url = crate::core::config::Config::load(None, None, false)
.map(|c| c.cloud.api_url)
.unwrap_or_else(|_| "https://app.openlatch.ai/api".to_string());
let key_result = crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
);
match key_result {
Err(_) => {
if output.format == OutputFormat::Json {
output.print_json(&build_auth_status_json(false, "", "", "", "", false));
} else {
output.print_info("Not authenticated");
output.print_info("Run 'openlatch auth login' to authenticate.");
}
}
Ok(key) => {
let key_str = key.expose_secret().to_string();
let masked = mask_api_key(&key_str);
let backend = keychain_backend_name();
let (online, org_name, org_id) = validate_online(&key_str, &api_url).await;
if output.format == OutputFormat::Json {
output.print_json(&build_auth_status_json(
true, &org_name, &org_id, &masked, backend, online,
));
} else {
output.print_step(if online {
"Authenticated (online)"
} else {
"Authenticated (offline — could not reach cloud)"
});
output.print_substep(&format!("API key: {masked}"));
output.print_substep(&format!("Keychain: {backend}"));
if !org_name.is_empty() {
output.print_substep(&format!("Org: {org_name} ({org_id})"));
}
}
}
}
Ok(())
}
pub fn build_auth_status_json(
authenticated: bool,
org_name: &str,
org_id: &str,
key_prefix: &str,
keychain_backend: &str,
online: bool,
) -> serde_json::Value {
if !authenticated {
return serde_json::json!({ "authenticated": false });
}
serde_json::json!({
"authenticated": true,
"org_name": org_name,
"org_id": org_id,
"key_prefix": key_prefix,
"keychain_backend": keychain_backend,
"online": online,
})
}
pub(crate) fn make_file_store() -> crate::core::auth::FileCredentialStore {
let path = crate::core::config::openlatch_dir().join("credentials.enc");
let agent_id = load_agent_id().unwrap_or_default();
crate::core::auth::FileCredentialStore::new(path, agent_id)
}
fn load_agent_id() -> Option<String> {
let config = crate::core::config::Config::load(None, None, false).ok()?;
config.agent_id
}
async fn attempt_server_revocation(api_key: &str, api_url: &str) -> bool {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
let base = api_url.trim_end_matches('/');
let result = client
.delete(format!("{base}/api/v1/api-keys/self"))
.bearer_auth(api_key)
.send()
.await;
match result {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
fn derive_app_url_from_api(api_url: &str) -> String {
let trimmed = api_url.trim_end_matches('/');
trimmed.strip_suffix("/api").unwrap_or(trimmed).to_string()
}
fn resolve_app_url() -> String {
if let Ok(val) = std::env::var("OPENLATCH_APP_URL") {
if !val.is_empty() {
return val;
}
}
let api_url = crate::core::config::Config::load(None, None, false)
.ok()
.map(|c| c.cloud.api_url)
.unwrap_or_else(|| "https://app.openlatch.ai/api".to_string());
derive_app_url_from_api(&api_url)
}
pub async fn validate_online(api_key: &str, api_url: &str) -> (bool, String, String) {
let v = validate_online_full(api_key, api_url).await;
(v.online, v.org_name, v.org_id)
}
#[derive(Debug, Default, Clone)]
pub struct AuthValidation {
pub online: bool,
pub rejected: bool,
pub org_name: String,
pub org_id: String,
pub user_db_id: Option<String>,
}
pub async fn validate_online_full(api_key: &str, api_url: &str) -> AuthValidation {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return AuthValidation::default(),
};
let base = api_url.trim_end_matches('/');
let result = client
.get(format!("{base}/api/v1/users/me"))
.bearer_auth(api_key)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(body) => parse_me_response_body(&body),
Err(_) => AuthValidation {
online: true,
..Default::default()
},
}
}
Ok(resp)
if resp.status() == reqwest::StatusCode::UNAUTHORIZED
|| resp.status() == reqwest::StatusCode::FORBIDDEN =>
{
AuthValidation {
rejected: true,
..Default::default()
}
}
_ => {
AuthValidation::default()
}
}
}
fn parse_me_response_body(body: &serde_json::Value) -> AuthValidation {
let org_name = body
.get("organization_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let org_id = body
.get("organization_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let user_db_id = body
.get("user_db_id")
.or_else(|| body.get("id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
AuthValidation {
online: true,
rejected: false,
org_name,
org_id,
user_db_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn test_try_open_browser_headless_linux_returns_false() {
let display_orig = std::env::var("DISPLAY").ok();
let wayland_orig = std::env::var("WAYLAND_DISPLAY").ok();
unsafe {
std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
}
let result = try_open_browser("https://example.com");
unsafe {
match display_orig {
Some(v) => std::env::set_var("DISPLAY", v),
None => std::env::remove_var("DISPLAY"),
}
match wayland_orig {
Some(v) => std::env::set_var("WAYLAND_DISPLAY", v),
None => std::env::remove_var("WAYLAND_DISPLAY"),
}
}
assert!(
!result,
"Expected false on headless Linux (no DISPLAY/WAYLAND_DISPLAY)"
);
}
#[test]
fn test_derive_app_url_strips_trailing_api_suffix() {
assert_eq!(
derive_app_url_from_api("https://app.openlatch.ai/api"),
"https://app.openlatch.ai"
);
}
#[test]
fn test_derive_app_url_strips_trailing_slash_before_api() {
assert_eq!(
derive_app_url_from_api("https://app.openlatch.ai/api/"),
"https://app.openlatch.ai"
);
}
#[test]
fn test_derive_app_url_passes_through_bare_origin() {
assert_eq!(
derive_app_url_from_api("http://localhost:5173"),
"http://localhost:5173"
);
}
#[test]
fn test_derive_app_url_passes_through_bare_origin_with_trailing_slash() {
assert_eq!(
derive_app_url_from_api("http://localhost:5173/"),
"http://localhost:5173"
);
}
#[test]
fn test_derive_app_url_does_not_strip_mid_path_api_segment() {
assert_eq!(
derive_app_url_from_api("https://example.com/api/v2"),
"https://example.com/api/v2"
);
}
#[test]
fn test_url_decode_plain_string_unchanged() {
assert_eq!(url_decode("hello"), "hello");
}
#[test]
fn test_url_decode_plus_becomes_space() {
assert_eq!(url_decode("Acme+Corp"), "Acme Corp");
}
#[test]
fn test_url_decode_percent_encoded_space() {
assert_eq!(url_decode("Acme%20Corp"), "Acme Corp");
}
#[test]
fn test_url_decode_mixed_encoding() {
assert_eq!(url_decode("Acme%20Corp+Ltd"), "Acme Corp Ltd");
}
#[test]
fn test_url_decode_invalid_percent_sequence_passes_through() {
assert_eq!(url_decode("%ZZ"), "%ZZ");
}
#[test]
fn test_url_encode_alphanumeric_unchanged() {
assert_eq!(url_encode("devbox-01"), "devbox-01");
}
#[test]
fn test_url_encode_space_becomes_percent_20() {
assert_eq!(url_encode("Acme Corp"), "Acme%20Corp");
}
#[test]
fn test_url_encode_apostrophe_encoded() {
assert_eq!(url_encode("Alice's Mac"), "Alice%27s%20Mac");
}
#[test]
fn test_url_encode_unreserved_chars_passthrough() {
assert_eq!(url_encode("a-b.c_d~e"), "a-b.c_d~e");
}
#[test]
fn test_url_encode_non_ascii_utf8() {
assert_eq!(url_encode("café"), "caf%C3%A9");
}
#[test]
fn test_url_encode_roundtrips_with_url_decode() {
let input = "Alice's MacBook Pro";
assert_eq!(url_decode(&url_encode(input)), input);
}
#[test]
fn test_system_hostname_is_non_empty_when_available() {
if let Some(h) = system_hostname() {
assert!(!h.is_empty(), "system_hostname must not return Some(\"\")");
assert_eq!(
h.trim(),
h,
"system_hostname must not return padded whitespace"
);
}
}
#[test]
fn test_parse_callback_params_extracts_all_fields() {
let query = "key=ol_org_abc123&org_name=Acme&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.0, "ol_org_abc123");
assert_eq!(result.1, "Acme");
assert_eq!(result.2, "org_456");
}
#[test]
fn test_parse_callback_params_decodes_percent_encoded_org_name() {
let query = "key=ol_org_abc123&org_name=Acme%20Corp&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.1, "Acme Corp");
}
#[test]
fn test_parse_callback_params_decodes_plus_encoded_org_name() {
let query = "key=ol_org_abc123&org_name=Acme+Corp&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.1, "Acme Corp");
}
#[test]
fn test_parse_callback_params_returns_error_on_missing_key() {
let query = "org_name=Acme&org_id=org_456";
let result = parse_callback_params(query);
assert!(result.is_err(), "Should fail when key is absent");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_parse_callback_params_returns_error_on_empty_key() {
let query = "key=&org_name=Acme&org_id=org_456";
let result = parse_callback_params(query);
assert!(result.is_err(), "Should fail when key is empty");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_parse_callback_params_returns_error_on_empty_query() {
let result = parse_callback_params("");
assert!(result.is_err(), "Should fail on empty query string");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_mask_api_key_shows_prefix_and_suffix() {
let masked = mask_api_key("ol_org_abcdef1234567890");
assert_eq!(masked, "ol_org_...7890");
}
#[test]
fn test_mask_api_key_short_key_returned_as_is() {
let masked = mask_api_key("short");
assert_eq!(masked, "short");
}
#[test]
fn test_build_success_html_contains_close_tab_message() {
let html = build_success_html();
assert!(
html.contains("You can close this tab"),
"Success HTML must tell user to close the tab; got: {html}"
);
}
#[tokio::test]
async fn test_accept_callback_returns_error_on_truncated_request() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _stream = tokio::net::TcpStream::connect(addr).await.unwrap();
});
let result = accept_callback(&listener).await;
assert!(
result.is_err(),
"Empty/truncated request should return error"
);
let err = result.unwrap_err();
assert_eq!(
err.code, ERR_AUTH_FLOW_FAILED,
"Expected OL-1606 for truncated request, got: {}",
err.code
);
}
#[test]
fn test_build_auth_status_json_authenticated() {
let json = build_auth_status_json(
true,
"Acme Corp",
"org_123",
"ol_org_...7890",
"macOS Keychain",
true,
);
assert_eq!(json["authenticated"], true);
assert_eq!(json["org_name"], "Acme Corp");
assert_eq!(json["org_id"], "org_123");
assert_eq!(json["key_prefix"], "ol_org_...7890");
assert_eq!(json["keychain_backend"], "macOS Keychain");
assert_eq!(json["online"], true);
}
#[test]
fn test_build_auth_status_json_not_authenticated() {
let json = build_auth_status_json(false, "", "", "", "", false);
assert_eq!(json["authenticated"], false);
assert!(
json.as_object().map(|o| o.len() == 1).unwrap_or(false),
"Unauthenticated JSON should have exactly one field"
);
}
#[test]
fn test_parse_me_response_body_canonical_platform_shape() {
let body = serde_json::json!({
"id": "usr_abc",
"user_db_id": "usr_abc",
"email": "alice@example.com",
"organization_id": "org_123",
"organization_name": "Acme Corp",
});
let v = parse_me_response_body(&body);
assert!(v.online);
assert!(!v.rejected);
assert_eq!(v.org_name, "Acme Corp");
assert_eq!(v.org_id, "org_123");
assert_eq!(v.user_db_id.as_deref(), Some("usr_abc"));
}
#[test]
fn test_parse_me_response_body_user_db_id_falls_back_to_id() {
let body = serde_json::json!({
"id": "usr_xyz",
"organization_id": "org_123",
"organization_name": "Acme Corp",
});
let v = parse_me_response_body(&body);
assert_eq!(v.user_db_id.as_deref(), Some("usr_xyz"));
}
#[test]
fn test_parse_me_response_body_missing_org_fields_yield_empty_strings() {
let body = serde_json::json!({
"id": "usr_no_org",
});
let v = parse_me_response_body(&body);
assert_eq!(v.org_name, "");
assert_eq!(v.org_id, "");
assert_eq!(v.user_db_id.as_deref(), Some("usr_no_org"));
}
#[test]
fn test_run_logout_succeeds_even_when_no_credentials_stored() {
let output = OutputConfig {
format: OutputFormat::Json,
verbose: false,
debug: false,
quiet: true,
color: false,
};
let result = run_logout(&output);
assert!(
result.is_ok(),
"run_logout must succeed (fail-open) even with no stored credentials: {result:?}"
);
}
}