mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
453 lines
17 KiB
Rust
453 lines
17 KiB
Rust
#![cfg(feature = "http-e2e-tests")]
|
|
mod common;
|
|
use codex_login::LoginServerOptions;
|
|
use codex_login::run_local_login_server_with_options;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
|
|
async fn start_mock_oauth_server(behavior: MockBehavior) -> MockServer {
|
|
let server = MockServer::start().await;
|
|
|
|
match behavior {
|
|
MockBehavior::Noop => {}
|
|
MockBehavior::Success => {
|
|
let id_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "acc-1",
|
|
}
|
|
}));
|
|
let access_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"organization_id": "org-1",
|
|
"project_id": "proj-1",
|
|
"completed_platform_onboarding": true,
|
|
"is_org_owner": false,
|
|
"chatgpt_plan_type": "plus"
|
|
}
|
|
}));
|
|
let payload = serde_json::json!({
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "refresh-1"
|
|
});
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(payload),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
MockBehavior::SuccessTwice => {
|
|
let id_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "acc-1",
|
|
}
|
|
}));
|
|
let access_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"organization_id": "org-1",
|
|
"project_id": "proj-1",
|
|
"completed_platform_onboarding": true,
|
|
"is_org_owner": false,
|
|
"chatgpt_plan_type": "plus"
|
|
}
|
|
}));
|
|
let payload = serde_json::json!({
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "refresh-1"
|
|
});
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(payload),
|
|
)
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
MockBehavior::SuccessNeedsSetup => {
|
|
let id_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "acc-1",
|
|
}
|
|
}));
|
|
let access_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"organization_id": "org-2",
|
|
"project_id": "proj-2",
|
|
"completed_platform_onboarding": false,
|
|
"is_org_owner": true,
|
|
"chatgpt_plan_type": "pro"
|
|
}
|
|
}));
|
|
let payload = serde_json::json!({
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "refresh-1"
|
|
});
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(payload),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
MockBehavior::SuccessIdClaimsOrgProject => {
|
|
let id_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "acc-3",
|
|
"organization_id": "org-id",
|
|
"project_id": "proj-id",
|
|
"completed_platform_onboarding": true,
|
|
"is_org_owner": false
|
|
}
|
|
}));
|
|
let access_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_plan_type": "plus"
|
|
}
|
|
}));
|
|
let payload = serde_json::json!({
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "refresh-1"
|
|
});
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(payload),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
MockBehavior::TokenError => {
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(500))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
MockBehavior::MissingOrgSkipExchange => {
|
|
let id_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": "acc-4"
|
|
}
|
|
}));
|
|
let access_token = make_fake_jwt(serde_json::json!({
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_plan_type": "plus"
|
|
}
|
|
}));
|
|
let payload = serde_json::json!({
|
|
"id_token": id_token,
|
|
"access_token": access_token,
|
|
"refresh_token": "refresh-4"
|
|
});
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(payload),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
server
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum MockBehavior {
|
|
Noop,
|
|
Success,
|
|
SuccessTwice,
|
|
SuccessNeedsSetup,
|
|
SuccessIdClaimsOrgProject,
|
|
TokenError,
|
|
MissingOrgSkipExchange,
|
|
// Old token-exchange fallback behaviors removed
|
|
}
|
|
|
|
use common::make_fake_jwt;
|
|
|
|
fn spawn_login_server_and_wait(
|
|
issuer: String,
|
|
codex_home: &tempfile::TempDir,
|
|
redeem_credits: bool,
|
|
) -> (std::thread::JoinHandle<std::io::Result<()>>, u16) {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
let opts = LoginServerOptions {
|
|
codex_home: codex_home.path().to_path_buf(),
|
|
client_id: "test-client".to_string(),
|
|
issuer,
|
|
port: 0,
|
|
open_browser: false,
|
|
redeem_credits,
|
|
expose_state_endpoint: true,
|
|
testing_timeout_secs: Some(5),
|
|
verbose: false,
|
|
#[cfg(feature = "http-e2e-tests")]
|
|
port_sender: Some(tx),
|
|
};
|
|
|
|
let handle = thread::spawn(move || run_local_login_server_with_options(opts));
|
|
let port = rx.recv().unwrap();
|
|
wait_for_state_endpoint(port, Duration::from_secs(5));
|
|
(handle, port)
|
|
}
|
|
|
|
fn http_get(url: &str) -> (u16, String, Option<String>) {
|
|
let agent = ureq::AgentBuilder::new().redirects(0).build();
|
|
match agent.get(url).call() {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let location = resp.header("Location").map(|s| s.to_string());
|
|
let body = resp.into_string().unwrap_or_default();
|
|
(status as u16, body, location)
|
|
}
|
|
Err(ureq::Error::Status(code, resp)) => {
|
|
let location = resp.header("Location").map(|s| s.to_string());
|
|
let body = resp.into_string().unwrap_or_default();
|
|
(code, body, location)
|
|
}
|
|
Err(err) => panic!("http error: {err}"),
|
|
}
|
|
}
|
|
|
|
fn http_get_follow_redirect(url: &str) -> (u16, String) {
|
|
let agent = ureq::AgentBuilder::new().redirects(5).build();
|
|
match agent.get(url).call() {
|
|
Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()),
|
|
Err(ureq::Error::Status(code, resp)) => (code, resp.into_string().unwrap_or_default()),
|
|
Err(err) => panic!("http error: {err}"),
|
|
}
|
|
}
|
|
|
|
// 1) Happy path: writes auth.json and exits after /success
|
|
#[tokio::test]
|
|
async fn login_server_happy_path() {
|
|
let server = start_mock_oauth_server(MockBehavior::SuccessTwice).await;
|
|
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
|
|
|
|
// Get state via test-only endpoint
|
|
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
|
let (_s, state, _) = http_get(&state_url);
|
|
assert!(!state.is_empty());
|
|
|
|
// Simulate callback
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
// First, capture redirect without following
|
|
let (status, _body, location) = http_get(&cb_url);
|
|
assert_eq!(status, 302);
|
|
let location = location.expect("location header");
|
|
assert!(location.contains("/success"));
|
|
assert!(location.contains("needs_setup=false"));
|
|
assert!(location.contains("plan_type=plus"));
|
|
assert!(location.contains("org_id=org-1"));
|
|
assert!(location.contains("project_id=proj-1"));
|
|
// Now follow redirect (this will invoke the callback a second time)
|
|
let (status, body) = http_get_follow_redirect(&cb_url);
|
|
assert_eq!(status, 200);
|
|
assert!(body.contains("Signed in to Codex CLI"));
|
|
|
|
handle.join().unwrap().unwrap();
|
|
|
|
// Verify auth.json written
|
|
let auth_path = codex_login::get_auth_file(codex_home.path());
|
|
let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
|
|
assert!(auth.openai_api_key.is_none());
|
|
assert!(auth.tokens.as_ref().is_some());
|
|
assert!(!auth.tokens.as_ref().unwrap().access_token.is_empty());
|
|
}
|
|
// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
|
|
#[tokio::test]
|
|
async fn login_server_needs_setup_true_and_params_present() {
|
|
let server = start_mock_oauth_server(MockBehavior::SuccessNeedsSetup).await;
|
|
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
|
|
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
|
let (_s, state, _) = http_get(&state_url);
|
|
assert!(!state.is_empty());
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
let (status, _body, location) = http_get(&cb_url);
|
|
assert_eq!(status, 302);
|
|
let location = location.expect("location header");
|
|
assert!(location.contains("needs_setup=true"));
|
|
assert!(location.contains("plan_type=pro"));
|
|
assert!(location.contains("org_id=org-2"));
|
|
assert!(location.contains("project_id=proj-2"));
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
|
|
// 1c) org/project from ID token only should appear in redirect (fallback logic)
|
|
#[tokio::test]
|
|
async fn login_server_id_token_fallback_for_org_and_project() {
|
|
let server = start_mock_oauth_server(MockBehavior::SuccessIdClaimsOrgProject).await;
|
|
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
|
|
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
|
let (_s, state, _) = http_get(&state_url);
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
let (status, _body, location) = http_get(&cb_url);
|
|
assert_eq!(status, 302);
|
|
let location = location.expect("location header");
|
|
assert!(location.contains("org_id=org-id"));
|
|
assert!(location.contains("project_id=proj-id"));
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
|
|
// 1d) Missing org/project in claims -> skip token-exchange, persist tokens without API key, still success
|
|
#[tokio::test]
|
|
async fn login_server_skips_exchange_when_no_org_or_project() {
|
|
let server = start_mock_oauth_server(MockBehavior::MissingOrgSkipExchange).await;
|
|
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
|
|
let state_url = format!("http://127.0.0.1:{port}/__test/state");
|
|
let (_s, state, _) = http_get(&state_url);
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
let (status, _body, location) = http_get(&cb_url);
|
|
assert_eq!(status, 302);
|
|
let location = location.expect("location header");
|
|
// No org_id/project_id in redirect
|
|
assert!(!location.contains("org_id="));
|
|
assert!(!location.contains("project_id="));
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
|
|
// Verify auth.json OPENAI_API_KEY is null
|
|
let auth_path = codex_login::get_auth_file(codex_home.path());
|
|
let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
|
|
assert!(auth.openai_api_key.is_none());
|
|
}
|
|
|
|
//
|
|
// 2) State mismatch returns 400 and server stays up
|
|
#[tokio::test]
|
|
async fn login_server_state_mismatch() {
|
|
let server = start_mock_oauth_server(MockBehavior::Noop).await;
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
|
|
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong");
|
|
let (status, body) = http_get_follow_redirect(&cb_url);
|
|
assert_eq!(status, 400);
|
|
assert!(body.contains("State parameter mismatch") || body.is_empty());
|
|
|
|
// Stop server
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
|
|
// 3) Missing code returns 400
|
|
#[tokio::test]
|
|
async fn login_server_missing_code() {
|
|
let server = start_mock_oauth_server(MockBehavior::Noop).await;
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
|
|
|
|
// Fetch state
|
|
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
|
|
.call()
|
|
.expect("get state")
|
|
.into_string()
|
|
.unwrap();
|
|
// Missing code
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?state={state}");
|
|
let (status, _body) = http_get_follow_redirect(&cb_url);
|
|
assert_eq!(status, 400);
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
|
|
// 4) Token endpoint error returns 500 (on code exchange) and server stays up
|
|
#[tokio::test]
|
|
async fn login_server_token_exchange_error() {
|
|
let server = start_mock_oauth_server(MockBehavior::TokenError).await;
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, false);
|
|
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
|
|
.call()
|
|
.expect("get state")
|
|
.into_string()
|
|
.unwrap();
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
let (status, _body) = http_get_follow_redirect(&cb_url);
|
|
assert_eq!(status, 500);
|
|
let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
|
|
handle.join().unwrap().unwrap();
|
|
}
|
|
|
|
// 5) Credit redemption errors do not block success
|
|
#[tokio::test]
|
|
async fn login_server_credit_redemption_best_effort() {
|
|
// Mock behavior success for token endpoints
|
|
let server = start_mock_oauth_server(MockBehavior::Success).await;
|
|
let codex_home = TempDir::new().unwrap();
|
|
let issuer = server.uri();
|
|
let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home, true);
|
|
let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state"))
|
|
.call()
|
|
.expect("get state")
|
|
.into_string()
|
|
.unwrap();
|
|
let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
|
|
let (status, _body) = http_get_follow_redirect(&cb_url);
|
|
assert_eq!(status, 200);
|
|
handle.join().unwrap().unwrap();
|
|
// auth.json exists
|
|
assert!(codex_login::get_auth_file(codex_home.path()).exists());
|
|
}
|
|
|
|
fn wait_for_state_endpoint(port: u16, timeout: Duration) {
|
|
let start = std::time::Instant::now();
|
|
loop {
|
|
if start.elapsed() > timeout {
|
|
panic!("server did not expose __test/state within timeout");
|
|
}
|
|
if let Ok(resp) = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call() {
|
|
if resp.status() == 200 {
|
|
break;
|
|
}
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|