Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs
T
雷电芽衣 c9855c4bb6 fix(review): custom provider login comments, doc placement, subagent scheme test
The subagent auth-scheme lookup is now a function with a test. The failed-login
span carries the same method id as the successful one. README order matches the
code: the provider is saved, then the key is checked.
2026-09-29 16:50:03 -04:00

711 lines
23 KiB
Rust

//! Tests for login, logout, account switching, and auth-code dispatchers.
use super::*;
/// A bash command typed while a turn is RUNNING takes the
/// server-authoritative immediate path (Effect + optimistic echo, no local
/// queue entry).
#[test]
fn bash_while_running_is_server_authoritative() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
let effects = dispatch(Action::SendBashCommand("ls -la".into()), &mut app);
let pid = match &effects[0] {
Effect::SendBashCommand {
command, prompt_id, ..
} => {
assert_eq!(command, "ls -la");
prompt_id.clone()
}
other => panic!("expected immediate SendBashCommand, got {other:?}"),
};
assert_eq!(app.agents[&id].session.queue_len(), 0);
let q = app
.shared_prompt_queue("test-session")
.expect("echo present");
assert_eq!(q.len(), 1);
assert_eq!(q[0].id, pid);
assert_eq!(q[0].kind, "bash");
assert_eq!(q[0].text, "ls -la");
}
#[test]
fn auth_complete_triggers_bundle_status_fetch() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
let effects = dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: None,
}),
&mut app,
);
assert!(matches!(app.auth_state, AuthState::Done));
// Pager only refreshes the on-disk catalog snapshot; the bundle
// download runs inside the shell post-auth.
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::FetchBundleStatus))
);
}
#[test]
fn auth_complete_with_deferred_load_also_fetches_status() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
app.deferred_startup.session =
Some(crate::app::session_startup::DeferredSessionStartup::Load {
session_id: "test-session".into(),
session_cwd: None,
chat_kind: false,
});
let effects = dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: None,
}),
&mut app,
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::FetchBundleStatus))
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { .. }))
);
assert!(app.deferred_startup.session.is_none());
}
/// The Moonshot API-key entry flow: picking a row opens the paste box,
/// submitting a key dispatches ONE effect that persists the key and then
/// authenticates with the platform's method id, and the visual flips to the
/// connecting state under the same request seq.
#[test]
fn submit_platform_api_key_dispatches_persist_then_authenticate() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
&mut app,
);
assert!(effects.is_empty(), "entering key entry is UI-only");
let seq = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
..
} => *request_seq,
other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"),
};
let effects = dispatch(Action::SubmitPlatformApiKey("sk-test-key".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistPlatformApiKeyAndAuthenticate {
request_seq,
target,
key,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(
*target,
PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)
);
assert_eq!(key, "sk-test-key");
assert_eq!(
target.method_id().0.as_ref(),
"moonshot-cn",
"authenticate must use the shell's moonshot-cn method id"
);
}
other => panic!("expected exactly the persist+authenticate effect, got {other:?}"),
}
// Same seq, connecting visual: this attempt's AuthComplete/AuthFailed
// still matches.
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
..
} if request_seq == seq
));
// Failed validation lands back on the picker with the error line.
dispatch(
Action::TaskComplete(TaskResult::AuthFailed {
request_seq: seq,
error: "Invalid API key for moonshot-cn".into(),
}),
&mut app,
);
assert!(matches!(
&app.auth_state,
AuthState::Pending { error: Some(e) } if e == "Invalid API key for moonshot-cn"
));
}
/// Esc in the paste box returns to the picker (no error), clears the typed
/// key, and invalidates the seq so stale auth results are dropped.
#[test]
fn cancel_platform_key_entry_returns_to_picker() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi)),
&mut app,
);
app.auth_code_input = "sk-half-typed".into();
let seq_before = app.next_auth_request_seq;
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
assert!(effects.is_empty());
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
assert!(app.auth_code_input.is_empty(), "typed key must be cleared");
assert!(app.next_auth_request_seq > seq_before);
}
/// A submitted empty/whitespace key is a no-op (stays in the paste box).
#[test]
fn submit_platform_api_key_ignores_blank_key() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
&mut app,
);
let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app);
assert!(effects.is_empty());
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
..
}
));
}
/// `/login` from the welcome screen (startup / logged-out) must NOT
/// stash a return view — the normal login-then-load flow is preserved.
#[test]
fn login_from_welcome_does_not_stash_return_view() {
let mut app = test_app();
assert_eq!(app.active_view, ActiveView::Welcome);
dispatch(Action::Login, &mut app);
assert_eq!(app.active_view, ActiveView::Welcome);
assert_eq!(app.auth_return_view, None);
}
/// A second auth-failed turn with no rewindable prompt
/// (`in_flight_prompt == None`) must not clobber the stash from an
/// earlier 401.
#[test]
fn second_auth_failure_does_not_clobber_reauth_stash() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
text: "first prompt".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
agent.session.state = AgentState::TurnRunning;
agent.turn_started_at = Some(std::time::Instant::now());
agent.session.in_flight_prompt = None;
}
dispatch(
Action::TaskComplete(TaskResult::PromptResponse {
agent_id: id,
result: Err("Unauthorized (401)".to_string()),
http_status: Some(401),
prompt_id: None,
}),
&mut app,
);
assert_eq!(
app.agents[&id]
.reauth_stashed_prompt
.as_ref()
.map(|prompt| prompt.text.as_str()),
Some("first prompt"),
"a None in_flight_prompt must not wipe an earlier stash"
);
}
/// Cancelling a mid-session re-auth drops the stashed prompt so it is
/// not silently resubmitted on a later, unrelated login.
#[test]
fn cancel_login_drops_reauth_stashed_prompt() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().reauth_stashed_prompt =
Some(crate::app::agent::InFlightPrompt {
text: "stale".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
dispatch(Action::Login, &mut app);
dispatch(Action::CancelLogin, &mut app);
assert!(
app.agents[&id].reauth_stashed_prompt.is_none(),
"cancelling re-auth must drop the stashed prompt"
);
}
/// Cancelling a mid-session re-auth strips the stale `ReAuthRequired`
/// prompt from scrollback so a later `PromptResponse` cannot re-detect
/// it and re-stash the prompt for silent resubmission.
#[test]
fn cancel_login_strips_reauth_prompt_from_scrollback() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
text: "stale".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
}
dispatch(Action::Login, &mut app);
dispatch(Action::CancelLogin, &mut app);
let sb = &app.agents[&id].scrollback;
let has_reauth = (0..sb.len()).any(|i| {
matches!(
sb.entry(i).map(|e| &e.block),
Some(RenderBlock::SessionEvent(ev)) if matches!(ev.event, SessionEvent::ReAuthRequired)
)
});
assert!(
!has_reauth,
"cancelling re-auth must strip the stale re-auth prompt from scrollback"
);
}
/// Empty `auth_methods` (preferred_method pin unavailable) must not invent
/// `kimi-code` or start an OIDC flow the agent did not advertise.
#[test]
fn login_with_empty_auth_methods_fails_closed() {
let mut app = test_app_with_agent();
app.auth_methods.clear();
app.login_method_id = None;
let effects = dispatch(Action::Login, &mut app);
assert!(
effects.is_empty(),
"must not start Authenticate without an advertised method"
);
assert_eq!(
app.active_view,
ActiveView::Agent(AgentId(0)),
"must stay on the session view"
);
assert!(
matches!(
&app.auth_state,
AuthState::Pending { error: Some(msg) }
if msg.contains("No login method available")
),
"must surface no-login-method error, got {:?}",
app.auth_state
);
assert!(app.login_method_id.is_none());
}
/// Cancelling a mid-session login returns to the session rather than
/// quitting the app, and clears the stashed view + auth state.
#[test]
fn cancel_login_restores_view() {
let mut app = test_app_with_agent();
dispatch(Action::Login, &mut app);
assert_eq!(app.active_view, ActiveView::Welcome);
let effects = dispatch(Action::CancelLogin, &mut app);
assert!(effects.is_empty(), "cancel is pure state, no effects");
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
assert_eq!(app.auth_return_view, None);
assert!(matches!(app.auth_state, AuthState::Done));
}
/// `CancelLogin` outside a mid-session login is a no-op (must not move
/// off the welcome screen or panic).
#[test]
fn cancel_login_noop_without_stashed_view() {
let mut app = test_app();
let effects = dispatch(Action::CancelLogin, &mut app);
assert!(effects.is_empty());
assert_eq!(app.active_view, ActiveView::Welcome);
assert_eq!(app.auth_return_view, None);
}
#[test]
fn auth_complete_extracts_show_resolved_model_from_meta() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
assert!(app.show_resolved_model);
dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: Some(serde_json::json!({ "show_resolved_model": false })),
}),
&mut app,
);
assert!(!app.show_resolved_model);
}
#[test]
fn auth_complete_preserves_show_resolved_model_when_absent() {
let mut app = test_app();
app.show_resolved_model = false;
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: Some(serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap()),
}),
&mut app,
);
assert!(!app.show_resolved_model);
}
/// `LoginWith` must authenticate with EXACTLY the chosen method and adopt
/// its label — even when a different method was resolved earlier.
/// Regression: the picker collapsed every row to the id-less `Login`,
/// which resolved to the first interactive method (kimi-code), so the
/// Grok/Claude/Copilot/Codex rows all opened the Kimi device flow.
#[test]
fn login_with_routes_to_the_chosen_method() {
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
app.auth_methods = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
has_external_api_key: false,
has_cached_token: false,
login_label: None,
},
)
.methods;
// A previous login resolved kimi-code; the explicit choice must win.
app.login_method_id = Some(acp::AuthMethodId::new("kimi-code"));
app.login_label = Some("Kimi Code".into());
let effects = dispatch(
Action::LoginWith(acp::AuthMethodId::new("claude-pro-max")),
&mut app,
);
let authenticated_with = effects.iter().find_map(|e| match e {
Effect::Authenticate { method_id, .. } => Some(method_id.0.to_string()),
_ => None,
});
assert_eq!(
authenticated_with.as_deref(),
Some("claude-pro-max"),
"the chosen provider's own method must ride the Authenticate effect"
);
assert_eq!(
app.login_method_id.as_ref().map(|id| id.0.as_ref()),
Some("claude-pro-max")
);
assert_eq!(
app.login_label.as_deref(),
Some("Claude Pro/Max (subscription)"),
"the picker label must follow the chosen method"
);
assert!(matches!(app.auth_state, AuthState::Authenticating { .. }));
}
/// A `LoginWith` id that is not among the shell-advertised methods fails
/// closed with an error on the picker — silently falling back to the
/// first method is exactly the routing bug this guards against.
#[test]
fn login_with_unknown_method_fails_closed() {
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::LoginWith(acp::AuthMethodId::new("no-such-provider")),
&mut app,
);
assert!(effects.is_empty(), "must not start any auth flow");
assert!(
matches!(
&app.auth_state,
AuthState::Pending { error: Some(e) } if e.contains("no-such-provider")
),
"must surface the unknown method, got {:?}",
app.auth_state
);
}
/// `/login` opens the provider picker (welcome `Pending`) instead of
/// auto-starting a flow — and from inside a session it stashes the view
/// (so Esc/q/Cancel return to it) without emitting any auth effect.
#[test]
fn open_login_picker_shows_picker_and_stashes_view() {
let mut app = test_app_with_agent();
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
let effects = dispatch(Action::OpenLoginPicker, &mut app);
assert!(effects.is_empty(), "the picker itself starts nothing");
assert_eq!(app.active_view, ActiveView::Welcome);
assert_eq!(app.auth_return_view, Some(ActiveView::Agent(AgentId(0))));
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
}
/// A successful login stamps `_meta.connected` on the method that just
/// authenticated, so a later `/login` picker shows its green badge without
/// re-initializing the shell.
#[test]
fn auth_complete_marks_the_authenticated_method_connected() {
use crate::app::app_view::pending_menu_items;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
app.auth_methods = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
has_external_api_key: false,
has_cached_token: false,
login_label: None,
},
)
.methods;
dispatch(
Action::LoginWith(acp::AuthMethodId::new("xai-grok")),
&mut app,
);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
other => panic!("expected Authenticating, got {other:?}"),
};
dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: seq,
meta: None,
}),
&mut app,
);
let items = pending_menu_items(&app.auth_methods, None);
let grok = items
.iter()
.find(|i| i.label().starts_with("xAI Grok"))
.expect("grok row");
assert!(
grok.connected(&app.auth_methods),
"the just-authenticated method must show as connected"
);
let kimi = items
.iter()
.find(|i| i.label().starts_with("Kimi Code"))
.expect("kimi row");
assert!(
!kimi.connected(&app.auth_methods),
"other methods must stay unmarked"
);
}
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
match &app.auth_state {
AuthState::Authenticating {
mode: AuthMode::CustomProviderEntry(step),
..
} => step.field,
other => panic!("expected a custom provider step, got {other:?}"),
}
}
/// URL, name, key; the last Enter emits one persist effect.
#[test]
fn custom_provider_steps_end_in_one_persist_effect() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
&mut app,
);
assert!(effects.is_empty());
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
dispatch(
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
&mut app,
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert_eq!(
app.auth_code_input, "gw-example",
"the name is prefilled from the host"
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
assert_eq!(custom_field(&app), CustomEntryField::Key);
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(provider.name, "gw");
assert_eq!(provider.api, CustomApi::Anthropic);
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-gw");
}
other => panic!("expected exactly the persist effect, got {other:?}"),
}
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
..
} if request_seq == seq
));
assert_eq!(
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
Some("custom:gw")
);
assert!(app.auth_code_input.is_empty());
}
/// A refused field keeps the user on it.
#[test]
fn custom_provider_refusals_stay_on_the_field() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("host.example".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
assert!(app.custom_entry.error.is_some());
dispatch(
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
&mut app,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("anthropic".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert!(app.custom_entry.error.is_some());
}
/// Esc returns to the picker and drops the draft.
#[test]
fn cancel_custom_provider_entry_returns_to_picker() {
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("nope".into()), &mut app);
assert!(app.custom_entry.error.is_some());
let seq_before = app.next_auth_request_seq;
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
assert!(effects.is_empty());
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
assert!(app.custom_entry.error.is_none());
assert!(app.next_auth_request_seq > seq_before);
}