use super::support::*;
use super::*;
#[test]
fn logout_custom_provider_opens_confirmation_and_removes_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::login::configure_custom_provider(
&app.config.paths,
"local-ai",
"Local AI",
"http://localhost:8080/v1",
"LOCAL_AI_KEY",
)
.unwrap();
let mut ui_state = state::MissionControlState::default();
app.logout_provider("local-ai", &mut ui_state);
assert!(matches!(
ui_state.modals.logout_confirmation.as_ref(),
Some(confirmation) if confirmation.provider_id == "local-ai"
));
ui_state.close_logout_confirmation();
app.remove_logout_provider("local-ai", &mut ui_state);
assert!(ui_state.status.contains("removed custom provider local-ai"));
assert!(
!crate::config::read_settings(&app.config.paths)
.unwrap()
.custom_providers
.contains_key("local-ai")
);
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn logout_direct_provider_opens_confirmation_without_worker_or_prompt() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.config.provider = Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string());
app.state.config = Some(app.config.clone());
let mut auth = crate::config::Auth::default();
auth.providers.insert(
crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
crate::config::AuthProviderRecord::OAuth {
access: "access".into(),
refresh: Some("refresh".into()),
expires: None,
account_id: Some("acct".into()),
},
);
crate::config::write_auth(&app.config.paths, &auth).unwrap();
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.modals.logout_confirmation.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
assert!(
crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
}
#[test]
fn logout_confirmation_removes_auth_and_clears_runtime_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
assert!(
!crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(!app.state.auth_state.is_ready());
assert!(!ui_state.provider_ready);
assert!(app.config.auth.is_none());
assert!(app.state.config.as_ref().unwrap().auth.is_none());
}
#[test]
fn logout_picker_escape_preserves_auth_runtime_state_and_workers() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.open_logout_picker(
vec![state::LoginProviderEntry {
id: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
label: "OpenAI Codex".to_string(),
description: "codex".to_string(),
status: "configured".to_string(),
}],
None,
input::DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert!(matches!(action, input::InputAction::None));
assert!(ui_state.modals.logout_picker.is_none());
assert!(
crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(app.state.auth_state.is_ready());
assert!(app.config.auth.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn logout_bad_provider_and_auth_read_failures_do_not_start_workers_or_submit_prompts() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout bad-provider".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(
ui_state
.status
.contains("unsupported logout provider 'bad-provider'")
);
assert!(receiver.try_recv().is_err());
std::fs::write(&app.config.paths.auth_file, b"not json").unwrap();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(
ui_state.status.contains("expected ident")
|| ui_state.status.contains("key must be a string")
);
assert!(receiver.try_recv().is_err());
assert!(ui_state.modals.logout_confirmation.is_none());
}
#[cfg(unix)]
#[test]
fn logout_unsafe_auth_file_failure_does_not_start_worker_or_submit_prompt() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut permissions = std::fs::metadata(&app.config.paths.auth_file)
.unwrap()
.permissions();
permissions.set_mode(0o644);
std::fs::set_permissions(&app.config.paths.auth_file, permissions).unwrap();
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(
ui_state
.status
.contains("auth.json permissions must be private")
);
assert!(receiver.try_recv().is_err());
assert!(ui_state.modals.logout_confirmation.is_none());
}
#[test]
fn post_logout_prompt_starts_worker_and_finishes_with_relogin_error() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
assert!(app.submit("hello".to_string(), &mut ui_state, &receiver, test_area()));
assert!(app.worker.is_some());
assert!(app.active_run);
while app
.worker
.as_ref()
.is_some_and(|worker| !worker.handle.is_finished())
{
std::thread::yield_now();
}
assert!(drain_tui_events(&receiver, &mut ui_state).run_finished);
assert!(app.finish_worker_if_ready(&mut ui_state));
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(!ui_state.provider_ready);
let message = ui_state.status.clone();
assert!(
message.contains("openai-codex credential refresh failed")
&& message.contains("run /login openai-codex"),
"unexpected message: {message}"
);
}
#[test]
fn post_logout_model_discovery_requires_relogin() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
app.open_model_picker(&mut ui_state, test_area());
assert!(ui_state.modals.model_picker.is_none());
assert!(ui_state.status.contains("loading model catalogs"));
}