diff --git a/backend/src/fleet/import.rs b/backend/src/fleet/import.rs index 8f140949d..5cb69e546 100644 --- a/backend/src/fleet/import.rs +++ b/backend/src/fleet/import.rs @@ -586,11 +586,22 @@ pub fn normalized_imported_account_status( status: AccountStatus, source: AccountImportSource, access_token: &str, + access_token_expires_at: Option<&DateTime>, ) -> AccountStatus { - if source == AccountImportSource::Sub2Api - && status == AccountStatus::Active - && jwt_expiry(access_token, Utc::now()) != JwtExpiry::Valid - { + if source != AccountImportSource::Sub2Api || status != AccountStatus::Active { + return status; + } + + let now = Utc::now(); + if let Some(expires_at) = access_token_expires_at { + return if now >= *expires_at { + AccountStatus::Expired + } else { + status + }; + } + + if jwt_expiry(access_token, now) != JwtExpiry::Valid { AccountStatus::Expired } else { status diff --git a/backend/src/fleet/manage/import.rs b/backend/src/fleet/manage/import.rs index af729f7fe..9fcf75143 100644 --- a/backend/src/fleet/manage/import.rs +++ b/backend/src/fleet/manage/import.rs @@ -83,16 +83,17 @@ impl AccountManageService { let parsed_status = crate::fleet::import::parse_account_import_status(entry.status.as_deref()) .map_err(|error| AccountManageError::InvalidStatus(error.to_string()))?; - let mut status = crate::fleet::import::normalized_imported_account_status( - parsed_status, - source, - &resolved_tokens.access_token, - ); let access_token_expires_at = resolved_tokens .claims .as_ref() .map(|claims| claims.expires_at) .or(access_token_expires_at); + let mut status = crate::fleet::import::normalized_imported_account_status( + parsed_status, + source, + &resolved_tokens.access_token, + access_token_expires_at.as_ref(), + ); let claims = resolved_tokens.claims.as_ref(); let mut plan_type = claims .and_then(|claims| claims.plan_type.clone()) diff --git a/backend/tests/api/admin/accounts_routes/import_routes.rs b/backend/tests/api/admin/accounts_routes/import_routes.rs index 471d3437e..20a6464ad 100644 --- a/backend/tests/api/admin/accounts_routes/import_routes.rs +++ b/backend/tests/api/admin/accounts_routes/import_routes.rs @@ -237,6 +237,89 @@ async fn admin_accounts_import_should_read_sub2api_backup_access_token_only_acco assert!(stored.next_refresh_at.is_none()); } +#[tokio::test] +async fn admin_accounts_import_should_keep_opaque_sub2api_token_active() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "account_id": "sub2api-opaque-account", + "user_id": "sub2api-opaque-user", + "email": "sub2api-opaque@example.com", + "plan_type": "team", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "reset_at": 1_800_000_000, + "limit_window_seconds": 18_000 + } + } + }))) + .mount(&server) + .await; + let (app, _state, pool, _dir) = admin_accounts_test_app_with_api_base_url( + "admin-accounts-import-sub2api-opaque-at", + 128, + format!("{}/backend-api", server.uri()), + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_sub2api_opaque_at") + .body(Body::from( + json!({ + "sourceFormat": "sub2api", + "exported_at": "2026-07-19T18:05:29Z", + "proxies": [], + "accounts": [{ + "name": "sub2api-opaque@example.com", + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": "at-sub2api-opaque-token", + "chatgpt_account_id": "sub2api-opaque-account", + "chatgpt_user_id": "sub2api-opaque-user", + "email": "sub2api-opaque@example.com", + "plan_type": "team", + "refresh_token": "", + "expires_at": "2099-12-01T00:00:00Z" + }, + "auto_pause_on_expired": true, + "concurrency": 30, + "priority": 1, + "type": "oauth" + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let store = PgAccountStore::new(pool); + let stored = store + .find_by_chatgpt_identity("sub2api-opaque-account", Some("sub2api-opaque-user")) + .await + .unwrap() + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["imported"], 1); + assert_eq!(stored.status, AccountStatus::Active); + assert!(stored.access_token_expires_at.is_some()); + assert!(stored.refresh_token.is_none()); +} + #[tokio::test] async fn admin_accounts_import_should_read_cliproxyapi_codex_auth_files() { let server = MockServer::start().await; diff --git a/backend/tests/dispatch/service.rs b/backend/tests/dispatch/service.rs index 9b2239c96..342ea7495 100644 --- a/backend/tests/dispatch/service.rs +++ b/backend/tests/dispatch/service.rs @@ -212,6 +212,7 @@ const WEBSOCKET_SECOND_ACCOUNT_LIMITED: &str = mod responses_http; mod responses_recovery; mod responses_websocket; +mod sub2api_import; mod usage_logging; async fn test_app_state_with_pool(config: &AppConfig, pool: PgPool) -> AppState { diff --git a/backend/tests/dispatch/service/sub2api_import.rs b/backend/tests/dispatch/service/sub2api_import.rs new file mode 100644 index 000000000..595112592 --- /dev/null +++ b/backend/tests/dispatch/service/sub2api_import.rs @@ -0,0 +1,101 @@ +use super::*; + +#[tokio::test] +async fn sub2api_opaque_access_token_with_future_expiry_should_handle_normal_responses_request() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "account_id": "sub2api-opaque-account", + "user_id": "sub2api-opaque-user", + "email": "sub2api-opaque@example.com", + "plan_type": "team", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "reset_at": 1_800_000_000, + "limit_window_seconds": 18_000 + } + } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/codex/responses")) + .and(header("authorization", "Bearer at-sub2api-opaque-token")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_SUCCESS_SSE), + ) + .expect(1) + .mount(&server) + .await; + + let (pool, _dir) = init_test_db("sub2api-opaque-normal-responses").await; + let api_key = insert_client_api_key(&pool).await; + let state = test_app_state_with_pool( + &test_config(test_database_url(), format!("{}/backend-api", server.uri())), + pool, + ) + .await; + let imported = state + .services + .admin_accounts + .import(json!({ + "sourceFormat": "sub2api", + "exported_at": "2026-07-19T18:05:29Z", + "proxies": [], + "accounts": [{ + "name": "sub2api-opaque@example.com", + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": "at-sub2api-opaque-token", + "chatgpt_account_id": "sub2api-opaque-account", + "chatgpt_user_id": "sub2api-opaque-user", + "email": "sub2api-opaque@example.com", + "plan_type": "team", + "refresh_token": "", + "expires_at": "2099-12-01T00:00:00Z" + }, + "auto_pause_on_expired": true, + "concurrency": 30, + "priority": 1, + "type": "oauth" + }] + })) + .await + .unwrap(); + assert_eq!(imported.imported, 1); + + let app = router::router().with_state(state); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], "resp_response_1"); +}