mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
Add a separate `xai` provider type for xAI Grok CLI subscription accounts. It is independent of the existing `grok` provider, which reverse-proxies grok.com with browser cookies; behavior of `grok` is unchanged. Account binding uses the xAI device code flow, so no local callback listener is needed and headless deployments can bind accounts. Refresh tokens can also be imported individually or in batches, and are rotated on refresh. OAuth requests default to the cli-chat-proxy Responses API; API keys and compact stay on api.x.ai. Explicit custom gateways are preserved. Only `openai:responses` and `openai:responses:compact` are exposed; Chat, Claude and Gemini clients reach the provider through Aether's existing cross-format conversion rather than new native endpoints. Upstream Responses payloads are sanitized for what xAI actually rejects: `previous_response_id` and `metadata.user_id` are dropped, hosted `tool_choice` is rewritten, `web_search` is restored for converted clients, `image_generation` is stripped on older Grok conversation models, unsupported reasoning effort is removed, and requested `reasoning.encrypted_content` is preserved with a replay policy keyed on the configured provider type rather than the model name. Quota refresh reads /user and /billing?format=credits and stores a structured usage snapshot; a prepaid balance keeps an account selectable after the weekly allowance is exhausted. API-key accounts skip the subscription billing surface. The admin UI shows remaining weekly quota as a labeled bar in the provider drawer and the pool list. Co-Authored-By: Claude Opus 5 <[email protected]>
10548 lines
415 KiB
Rust
10548 lines
415 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use aether_contracts::{
|
|
ExecutionPlan, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
|
};
|
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
|
use aether_data::repository::background_tasks::InMemoryBackgroundTaskRepository;
|
|
use aether_data::repository::management_tokens::{
|
|
InMemoryManagementTokenRepository, ManagementTokenReadRepository,
|
|
};
|
|
use aether_data::repository::oauth_providers::{
|
|
InMemoryOAuthProviderRepository, OAuthProviderReadRepository,
|
|
};
|
|
use aether_data::repository::pool_scores::InMemoryPoolMemberScoreRepository;
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
|
use aether_data::repository::users::{InMemoryUserReadRepository, UserReadRepository};
|
|
use aether_data_contracts::repository::background_tasks::BackgroundTaskReadRepository;
|
|
use aether_data_contracts::repository::pool_scores::{
|
|
GetPoolMemberScoresByIdsQuery, PoolMemberHardState, PoolMemberIdentity, PoolScoreReadRepository,
|
|
};
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
ProviderCatalogKeyCredentialsCasUpdate, ProviderCatalogReadRepository,
|
|
ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
|
};
|
|
use axum::body::{to_bytes, Body, Bytes};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{any, delete, get, patch, post, put};
|
|
use axum::{extract::Request, Json, Router};
|
|
use http::{HeaderMap, HeaderValue, StatusCode};
|
|
use serde_json::{json, Value};
|
|
|
|
use super::super::{
|
|
build_router_with_state, build_state_with_execution_runtime_override, hash_management_token,
|
|
sample_bound_key, sample_endpoint, sample_key, sample_management_token,
|
|
sample_oauth_provider_config, sample_provider, sample_proxy_node, start_server, AppState,
|
|
};
|
|
use crate::admin_api::{
|
|
maybe_build_local_admin_provider_oauth_response, AdminAppState, AdminRequestContext,
|
|
};
|
|
use crate::ai_serving::{
|
|
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
|
|
};
|
|
use crate::audit::AdminAuditEvent;
|
|
use crate::constants::{
|
|
GATEWAY_HEADER, TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER,
|
|
TRUSTED_ADMIN_USER_ID_HEADER, TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
};
|
|
use crate::control::resolve_public_request_context;
|
|
use crate::data::GatewayDataState;
|
|
|
|
const ADMIN_OAUTH_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
|
|
|
|
fn run_admin_oauth_test<F, Fut>(test_name: &'static str, make_future: F)
|
|
where
|
|
F: FnOnce() -> Fut + Send + 'static,
|
|
Fut: std::future::Future<Output = ()> + 'static,
|
|
{
|
|
let handle = std::thread::Builder::new()
|
|
.name(test_name.to_string())
|
|
.stack_size(ADMIN_OAUTH_TEST_STACK_BYTES)
|
|
.spawn(move || {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("test runtime should build");
|
|
runtime.block_on(make_future());
|
|
})
|
|
.expect("admin oauth test thread should spawn");
|
|
|
|
if let Err(payload) = handle.join() {
|
|
std::panic::resume_unwind(payload);
|
|
}
|
|
}
|
|
|
|
fn decrypt_persisted_provider_api_key(key: &StoredProviderCatalogKey) -> String {
|
|
AppState::new()
|
|
.expect("credential test state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.decrypt_provider_catalog_key_api_key(key)
|
|
.expect("persisted api key should decrypt")
|
|
.expect("persisted api key should exist")
|
|
}
|
|
|
|
fn decrypt_persisted_provider_auth_config(key: &StoredProviderCatalogKey) -> String {
|
|
AppState::new()
|
|
.expect("credential test state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.decrypt_provider_catalog_key_auth_config(key)
|
|
.expect("persisted auth config should decrypt")
|
|
.expect("persisted auth config should exist")
|
|
}
|
|
|
|
fn trusted_admin_headers() -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(GATEWAY_HEADER, HeaderValue::from_static("rust-phase3b"));
|
|
headers.insert(
|
|
TRUSTED_ADMIN_USER_ID_HEADER,
|
|
HeaderValue::from_static("admin-user-123"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
HeaderValue::from_static("admin"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_SESSION_ID_HEADER,
|
|
HeaderValue::from_static("session-123"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER,
|
|
HeaderValue::from_static("management-token-123"),
|
|
);
|
|
headers
|
|
}
|
|
|
|
async fn local_admin_provider_oauth_response(
|
|
state: &AppState,
|
|
method: http::Method,
|
|
uri: &str,
|
|
body: Option<serde_json::Value>,
|
|
) -> Response<Body> {
|
|
let headers = trusted_admin_headers();
|
|
let request_context = resolve_public_request_context(
|
|
state,
|
|
&method,
|
|
&uri.parse().expect("uri should parse"),
|
|
&headers,
|
|
"trace-123",
|
|
)
|
|
.await
|
|
.expect("request context should resolve");
|
|
let body_bytes = body.map(|value| Bytes::from(value.to_string()));
|
|
maybe_build_local_admin_provider_oauth_response(
|
|
&AdminAppState::new(state),
|
|
&AdminRequestContext::new(&request_context),
|
|
body_bytes.as_ref(),
|
|
)
|
|
.await
|
|
.expect("local provider oauth response should build")
|
|
.expect("provider oauth route should resolve locally")
|
|
}
|
|
|
|
fn sample_kiro_device_access_token(email: &str) -> String {
|
|
use base64::Engine as _;
|
|
|
|
let header =
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
|
json!({
|
|
"email": email,
|
|
"sub": "kiro-user-123",
|
|
})
|
|
.to_string(),
|
|
);
|
|
format!("{header}.{payload}.sig")
|
|
}
|
|
|
|
fn sample_kiro_device_access_token_without_email() -> String {
|
|
use base64::Engine as _;
|
|
|
|
let header =
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
|
json!({
|
|
"sub": "kiro-user-123",
|
|
})
|
|
.to_string(),
|
|
);
|
|
format!("{header}.{payload}.sig")
|
|
}
|
|
|
|
fn sample_codex_access_token_with_profile_email(email: &str, account_id: &str) -> String {
|
|
sample_codex_access_token_with_profile_email_and_expiry(email, account_id, Some(2_000_000_000))
|
|
}
|
|
|
|
fn sample_codex_access_token_with_profile_email_and_expiry(
|
|
email: &str,
|
|
account_id: &str,
|
|
expires_at: Option<u64>,
|
|
) -> String {
|
|
use base64::Engine as _;
|
|
|
|
let header =
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
|
let mut claims = json!({
|
|
"iss": "https://auth.openai.com",
|
|
"aud": ["https://api.openai.com/v1"],
|
|
"https://api.openai.com/profile": {
|
|
"email": email,
|
|
"email_verified": true,
|
|
},
|
|
"https://api.openai.com/auth": {
|
|
"chatgpt_account_id": account_id,
|
|
},
|
|
});
|
|
if let Some(expires_at) = expires_at {
|
|
claims["exp"] = json!(expires_at);
|
|
}
|
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
|
|
format!("{header}.{payload}.sig")
|
|
}
|
|
|
|
fn codex_import_token_execution_result(request_id: &str) -> serde_json::Value {
|
|
json!({
|
|
"request_id": request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "imported-codex-access-token",
|
|
"refresh_token": "imported-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn codex_quota_execution_result(request_id: &str) -> serde_json::Value {
|
|
json!({
|
|
"request_id": request_id,
|
|
"status_code": 200,
|
|
"headers": {},
|
|
"body": {
|
|
"json_body": {
|
|
"user": {
|
|
"id": "user-codex-123",
|
|
"email": "[email protected]"
|
|
},
|
|
"account": {
|
|
"id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
},
|
|
"plan": {
|
|
"type": "Plus",
|
|
"title": "ChatGPT Plus"
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn windsurf_register_user_execution_result(request_id: &str) -> serde_json::Value {
|
|
json!({
|
|
"request_id": request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"sessionToken": "devin-session-token$registered",
|
|
"name": "Windsurf User",
|
|
"apiServerUrl": "https://server.codeium.com"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn assert_single_provider_oauth_refresh_token_plan<'a>(
|
|
plans: &'a [ExecutionPlan],
|
|
) -> &'a ExecutionPlan {
|
|
let token_plans = plans
|
|
.iter()
|
|
.filter(|plan| plan.request_id == "provider-oauth:refresh-token")
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
token_plans.len(),
|
|
1,
|
|
"expected exactly one provider-oauth refresh-token plan, got {:?}",
|
|
plans
|
|
.iter()
|
|
.map(|plan| plan.request_id.as_str())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert!(
|
|
plans.iter().all(|plan| {
|
|
plan.request_id == "provider-oauth:refresh-token"
|
|
|| plan.request_id.starts_with("codex-quota:")
|
|
}),
|
|
"unexpected execution plans: {:?}",
|
|
plans
|
|
.iter()
|
|
.map(|plan| plan.request_id.as_str())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
token_plans[0]
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_supported_types_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_supported_types_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_supported_types_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_supported_types_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-oauth/supported-types",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/supported-types"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 6);
|
|
assert_eq!(items[0]["provider_type"], "claude_code");
|
|
assert_eq!(
|
|
items[0]["authorize_url"],
|
|
"https://claude.ai/oauth/authorize"
|
|
);
|
|
assert_eq!(
|
|
items[0]["token_url"],
|
|
"https://platform.claude.com/v1/oauth/token"
|
|
);
|
|
assert_eq!(
|
|
items[0]["redirect_uri"],
|
|
"https://platform.claude.com/oauth/code/callback"
|
|
);
|
|
assert_eq!(items[0]["supports_cookie_authorization"], true);
|
|
assert_eq!(items[1]["provider_type"], "codex");
|
|
assert_eq!(items[2]["provider_type"], "chatgpt_web");
|
|
assert_eq!(items[3]["provider_type"], "gemini_cli");
|
|
assert_eq!(items[4]["provider_type"], "antigravity");
|
|
assert_eq!(items[5]["provider_type"], "windsurf");
|
|
assert!(items[1..]
|
|
.iter()
|
|
.all(|item| item["supports_cookie_authorization"] == false));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_authorizes_claude_cookie_without_persisting_cookie() {
|
|
run_admin_oauth_test(
|
|
"gateway_authorizes_claude_cookie_without_persisting_cookie",
|
|
gateway_authorizes_claude_cookie_without_persisting_cookie_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_authorizes_claude_cookie_without_persisting_cookie_impl() {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let json_body = match plan.request_id.as_str() {
|
|
"provider-oauth:claude-cookie-organizations" => json!([
|
|
{"uuid": "org-personal", "raven_type": "personal"},
|
|
{"uuid": "org-team", "raven_type": "team"}
|
|
]),
|
|
"provider-oauth:claude-cookie-authorize" => {
|
|
let state = plan
|
|
.body
|
|
.json_body
|
|
.as_ref()
|
|
.and_then(|body| body.get("state"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("authorize plan should contain state");
|
|
let mut redirect =
|
|
url::Url::parse("https://platform.claude.com/oauth/code/callback")
|
|
.expect("redirect URL should parse");
|
|
redirect
|
|
.query_pairs_mut()
|
|
.append_pair("code", "claude-authorization-code")
|
|
.append_pair("state", state);
|
|
json!({"redirect_uri": redirect.to_string()})
|
|
}
|
|
"provider-oauth:exchange-code" => json!({
|
|
"access_token": "sk-ant-oat01-created",
|
|
"refresh_token": "sk-ant-ort01-created",
|
|
"expires_in": 3600,
|
|
"organization": {"uuid": "org-team"},
|
|
"account": {
|
|
"uuid": "account-claude-123",
|
|
"email_address": "[email protected]"
|
|
}
|
|
}),
|
|
unexpected => panic!("unexpected execution plan: {unexpected}"),
|
|
};
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {"content-type": "application/json"},
|
|
"body": {"json_body": json_body}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-claude", "claude", 10);
|
|
provider.provider_type = "claude_code".to_string();
|
|
provider.proxy = Some(json!({
|
|
"mode": "tunnel",
|
|
"node_id": "proxy-node-claude",
|
|
"url": "http://proxy.example:8080",
|
|
"enabled": true
|
|
}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-claude-messages",
|
|
"provider-claude",
|
|
"claude:messages",
|
|
"https://api.anthropic.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let mut tunnel_node = sample_proxy_node("proxy-node-claude");
|
|
tunnel_node.status = "online".to_string();
|
|
tunnel_node.tunnel_mode = true;
|
|
tunnel_node.tunnel_connected = true;
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![tunnel_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"tunnel.attachments.proxy-node-claude".to_string(),
|
|
json!({
|
|
"gateway_instance_id": "gateway-owner",
|
|
"relay_base_url": "http://gateway-owner.internal",
|
|
"tunnel_generation": "test-generation-1",
|
|
"conn_count": 1,
|
|
"observed_at_unix_secs": 4_102_444_800u64,
|
|
}),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"claude_code_cookie_base_url",
|
|
"https://claude.example",
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"claude_code",
|
|
"https://platform.example/v1/oauth/token",
|
|
);
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-claude/cookie-authorize",
|
|
Some(json!({
|
|
"cookie": "Cookie: other=value; sessionKey=sk-ant-sid01-secret; theme=dark",
|
|
"name": "claude-cookie-account"
|
|
})),
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let payload: serde_json::Value = serde_json::from_slice(
|
|
&to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read"),
|
|
)
|
|
.expect("response should be JSON");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "claude_code");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["temporary"], false);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-claude".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 1);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&keys[0]);
|
|
assert_eq!(decrypted_api_key, "sk-ant-oat01-created");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&keys[0]);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["refresh_token"], "sk-ant-ort01-created");
|
|
assert_eq!(auth_config["org_uuid"], "org-team");
|
|
assert_eq!(auth_config["account_uuid"], "account-claude-123");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert!(!decrypted_auth_config.contains("sk-ant-sid01-secret"));
|
|
assert!(!decrypted_auth_config.contains("sessionKey"));
|
|
assert!(auth_config.get("cookie").is_none());
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 3);
|
|
for plan in &plans[..2] {
|
|
assert_eq!(
|
|
plan.headers.get("cookie").map(String::as_str),
|
|
Some("sessionKey=sk-ant-sid01-secret")
|
|
);
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("false")
|
|
);
|
|
assert_eq!(
|
|
plan.proxy.as_ref().and_then(|proxy| proxy.mode.as_deref()),
|
|
Some("tunnel")
|
|
);
|
|
assert!(plan.transport_profile.is_none());
|
|
}
|
|
let token_plan = &plans[2];
|
|
assert_eq!(token_plan.request_id, "provider-oauth:exchange-code");
|
|
assert!(!token_plan.headers.contains_key("cookie"));
|
|
assert!(token_plan
|
|
.body
|
|
.json_body
|
|
.as_ref()
|
|
.is_some_and(|body| body.get("scope").is_none()));
|
|
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_authorizes_claude_cookies_as_redacted_task() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_authorizes_claude_cookies_as_redacted_task",
|
|
gateway_batch_authorizes_claude_cookies_as_redacted_task_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_authorizes_claude_cookies_as_redacted_task_impl() {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let json_body = match plan.request_id.as_str() {
|
|
"provider-oauth:claude-cookie-organizations" => {
|
|
json!([{"uuid": "org-team", "raven_type": "team"}])
|
|
}
|
|
"provider-oauth:claude-cookie-authorize" => {
|
|
let cookie = plan
|
|
.headers
|
|
.get("cookie")
|
|
.map(String::as_str)
|
|
.expect("authorize plan should contain cookie");
|
|
let label = if cookie.contains("batch-sid-one") {
|
|
"one"
|
|
} else if cookie.contains("batch-sid-two") {
|
|
"two"
|
|
} else {
|
|
panic!("unexpected Cookie authorization input")
|
|
};
|
|
let state = plan
|
|
.body
|
|
.json_body
|
|
.as_ref()
|
|
.and_then(|body| body.get("state"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("authorize plan should contain state");
|
|
let mut redirect =
|
|
url::Url::parse("https://platform.claude.com/oauth/code/callback")
|
|
.expect("redirect URL should parse");
|
|
redirect
|
|
.query_pairs_mut()
|
|
.append_pair("code", format!("code-{label}").as_str())
|
|
.append_pair("state", state);
|
|
json!({"redirect_uri": redirect.to_string()})
|
|
}
|
|
"provider-oauth:exchange-code" => {
|
|
let code = plan
|
|
.body
|
|
.json_body
|
|
.as_ref()
|
|
.and_then(|body| body.get("code"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("token plan should contain code");
|
|
let label = code.strip_prefix("code-").expect("code should be tagged");
|
|
json!({
|
|
"access_token": format!("sk-ant-oat01-{label}"),
|
|
"refresh_token": format!("sk-ant-ort01-{label}"),
|
|
"expires_in": 3600,
|
|
"organization": {"uuid": "org-team"},
|
|
"account": {
|
|
"uuid": format!("account-{label}"),
|
|
"email_address": format!("{label}@example.com")
|
|
}
|
|
})
|
|
}
|
|
unexpected => panic!("unexpected execution plan: {unexpected}"),
|
|
};
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {"content-type": "application/json"},
|
|
"body": {"json_body": json_body}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-claude", "claude", 10);
|
|
provider.provider_type = "claude_code".to_string();
|
|
provider.proxy = Some(json!({
|
|
"mode": "tunnel",
|
|
"node_id": "proxy-node-claude",
|
|
"url": "http://proxy.example:8080",
|
|
"enabled": true
|
|
}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-claude-messages",
|
|
"provider-claude",
|
|
"claude:messages",
|
|
"https://api.anthropic.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let background_task_repository = Arc::new(InMemoryBackgroundTaskRepository::default());
|
|
let mut tunnel_node = sample_proxy_node("proxy-node-claude");
|
|
tunnel_node.status = "online".to_string();
|
|
tunnel_node.tunnel_mode = true;
|
|
tunnel_node.tunnel_connected = true;
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![tunnel_node]));
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"tunnel.attachments.proxy-node-claude".to_string(),
|
|
json!({
|
|
"gateway_instance_id": "gateway-owner",
|
|
"relay_base_url": "http://gateway-owner.internal",
|
|
"tunnel_generation": "test-generation-1",
|
|
"conn_count": 1,
|
|
"observed_at_unix_secs": 4_102_444_800u64,
|
|
}),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
|
.with_background_task_repository_for_tests(background_task_repository.clone()),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"claude_code_cookie_base_url",
|
|
"https://claude.example",
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"claude_code",
|
|
"https://platform.example/v1/oauth/token",
|
|
);
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks",
|
|
Some(json!({
|
|
"cookies": [
|
|
"sessionKey=batch-sid-one",
|
|
"foo=bar",
|
|
"Cookie: sessionKey=batch-sid-one",
|
|
"sessionKey=batch-sid-two"
|
|
]
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let submitted: serde_json::Value = serde_json::from_slice(
|
|
&to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("submitted body should read"),
|
|
)
|
|
.expect("submitted body should parse");
|
|
assert_eq!(submitted["status"], "submitted");
|
|
assert_eq!(submitted["total"], 4);
|
|
assert_eq!(submitted["import_kind"], "cookie_authorize");
|
|
let task_id = submitted["task_id"]
|
|
.as_str()
|
|
.expect("task id should exist")
|
|
.to_string();
|
|
assert!(task_id.starts_with("claude-cookie-"));
|
|
|
|
let mut status_payload = Value::Null;
|
|
for _ in 0..80 {
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::GET,
|
|
format!(
|
|
"/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks/{task_id}"
|
|
)
|
|
.as_str(),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
status_payload = serde_json::from_slice(
|
|
&to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("status body should read"),
|
|
)
|
|
.expect("status body should parse");
|
|
if status_payload["status"] == "completed" {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
}
|
|
|
|
assert_eq!(status_payload["status"], "completed");
|
|
assert_eq!(status_payload["import_kind"], "cookie_authorize");
|
|
assert_eq!(status_payload["total"], 4);
|
|
assert_eq!(status_payload["processed"], 4);
|
|
assert_eq!(status_payload["success"], 2);
|
|
assert_eq!(status_payload["failed"], 2);
|
|
assert_eq!(status_payload["created_count"], 2);
|
|
assert_eq!(status_payload["replaced_count"], 0);
|
|
let error_samples = status_payload["error_samples"]
|
|
.as_array()
|
|
.expect("error samples should be an array");
|
|
assert_eq!(error_samples.len(), 2);
|
|
assert_eq!(error_samples[0]["index"], 1);
|
|
assert_eq!(error_samples[1]["index"], 2);
|
|
|
|
let status_text = status_payload.to_string();
|
|
for forbidden in ["sessionKey", "batch-sid-one", "batch-sid-two"] {
|
|
assert!(!status_text.contains(forbidden), "forbidden={forbidden}");
|
|
}
|
|
let raw_task_state = state
|
|
.load_provider_oauth_batch_task_for_tests(
|
|
format!("provider_oauth_batch_task:{task_id}").as_str(),
|
|
)
|
|
.expect("raw task state should exist");
|
|
for forbidden in ["sessionKey", "batch-sid-one", "batch-sid-two"] {
|
|
assert!(
|
|
!raw_task_state.contains(forbidden),
|
|
"raw task state contains {forbidden}"
|
|
);
|
|
}
|
|
let persisted_task_state = state
|
|
.runtime_kv_get(format!("provider_oauth_batch_task:{task_id}").as_str())
|
|
.await
|
|
.expect("runtime task state lookup should succeed")
|
|
.expect("runtime task state should exist");
|
|
assert!(crate::handlers::shared::runtime_secret_payload_is_sealed(
|
|
&persisted_task_state
|
|
));
|
|
for forbidden in ["sessionKey", "batch-sid-one", "batch-sid-two"] {
|
|
assert!(
|
|
!persisted_task_state.contains(forbidden),
|
|
"persisted task state contains {forbidden}"
|
|
);
|
|
}
|
|
let background_run = background_task_repository
|
|
.find_run(&task_id)
|
|
.await
|
|
.expect("background run should load")
|
|
.expect("background run should exist");
|
|
let background_text = serde_json::to_string(&background_run)
|
|
.expect("background run should serialize for assertion");
|
|
for forbidden in ["sessionKey", "batch-sid-one", "batch-sid-two"] {
|
|
assert!(
|
|
!background_text.contains(forbidden),
|
|
"forbidden={forbidden}"
|
|
);
|
|
}
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-claude".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 2);
|
|
for key in &keys {
|
|
let auth_config = decrypt_persisted_provider_auth_config(key);
|
|
assert!(!auth_config.contains("batch-sid"));
|
|
assert!(!auth_config.contains("sessionKey"));
|
|
}
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 6);
|
|
assert_eq!(
|
|
plans
|
|
.iter()
|
|
.filter(|plan| plan.request_id == "provider-oauth:claude-cookie-organizations")
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
plans
|
|
.iter()
|
|
.filter(|plan| plan.request_id == "provider-oauth:claude-cookie-authorize")
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
plans
|
|
.iter()
|
|
.filter(|plan| plan.request_id == "provider-oauth:exchange-code")
|
|
.count(),
|
|
2
|
|
);
|
|
assert!(plans.iter().all(|plan| {
|
|
plan.proxy.as_ref().and_then(|proxy| proxy.mode.as_deref()) == Some("tunnel")
|
|
}));
|
|
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_authorize_for_windsurf_browser() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_authorize_for_windsurf_browser",
|
|
gateway_handles_admin_provider_oauth_device_authorize_for_windsurf_browser_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_authorize_for_windsurf_browser_impl() {
|
|
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
|
|
provider.provider_type = "windsurf".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-windsurf-chat",
|
|
"provider-windsurf",
|
|
"openai:chat",
|
|
"https://server.codeium.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-windsurf/device-authorize",
|
|
Some(json!({
|
|
"auth_type": "browser",
|
|
"login_option": "github",
|
|
"proxy_node_id": "proxy-node-windsurf"
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
let session_id = payload["session_id"]
|
|
.as_str()
|
|
.expect("session_id should exist");
|
|
assert_eq!(payload["auth_type"], "browser");
|
|
assert_eq!(payload["login_option"], "github");
|
|
assert_eq!(payload["redirect_uri"], "show-auth-token");
|
|
assert_eq!(payload["callback_required"], true);
|
|
|
|
let authorization_url = payload["verification_uri_complete"]
|
|
.as_str()
|
|
.expect("authorization url should exist");
|
|
let parsed = url::Url::parse(authorization_url).expect("authorization url should parse");
|
|
let params = parsed
|
|
.query_pairs()
|
|
.into_owned()
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
assert_eq!(
|
|
parsed.as_str().split('?').next(),
|
|
Some("https://windsurf.com/windsurf/signin")
|
|
);
|
|
assert_eq!(
|
|
params.get("response_type").map(String::as_str),
|
|
Some("token")
|
|
);
|
|
assert_eq!(params.get("state").map(String::as_str), Some(session_id));
|
|
assert_eq!(
|
|
params.get("redirect_uri").map(String::as_str),
|
|
Some("show-auth-token")
|
|
);
|
|
assert_eq!(
|
|
params.get("redirect_parameters_type").map(String::as_str),
|
|
Some("query")
|
|
);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
|
|
.expect("device session should be stored");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["provider_id"], "provider-windsurf");
|
|
assert_eq!(stored["auth_type"], "browser");
|
|
assert_eq!(stored["social_provider"], "github");
|
|
assert_eq!(stored["redirect_uri"], "show-auth-token");
|
|
assert_eq!(stored["proxy_node_id"], "proxy-node-windsurf");
|
|
assert_eq!(stored["status"], "pending");
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_generic_oauth_start_for_windsurf_provider() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_generic_oauth_start_for_windsurf_provider",
|
|
gateway_rejects_generic_oauth_start_for_windsurf_provider_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_generic_oauth_start_for_windsurf_provider_impl() {
|
|
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
|
|
provider.provider_type = "windsurf".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-windsurf/start",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert!(
|
|
payload["detail"]
|
|
.as_str()
|
|
.is_some_and(|detail| detail.contains("浏览器登录")),
|
|
"payload={payload}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_generic_oauth_start_for_xai_provider() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_generic_oauth_start_for_xai_provider",
|
|
gateway_rejects_generic_oauth_start_for_xai_provider_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_generic_oauth_start_for_xai_provider_impl() {
|
|
let mut provider = sample_provider("provider-xai", "xai", 10);
|
|
provider.provider_type = "xai".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-xai/start",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert!(
|
|
payload["detail"].as_str().is_some_and(|detail| {
|
|
detail.contains("设备授权") || detail.contains("导入凭据")
|
|
}),
|
|
"payload={payload}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_authorize_for_xai() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_authorize_for_xai",
|
|
gateway_handles_admin_provider_oauth_device_authorize_for_xai_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_authorize_for_xai_impl() {
|
|
let authorize_hits = Arc::new(Mutex::new(0usize));
|
|
let authorize_hits_clone = Arc::clone(&authorize_hits);
|
|
let oidc_server = Router::new().fallback(any(move |_request: Request| {
|
|
let authorize_hits_inner = Arc::clone(&authorize_hits_clone);
|
|
async move {
|
|
*authorize_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"device_code": "xai-device-code",
|
|
"user_code": "XAI-CODE",
|
|
"verification_uri": "https://auth.x.ai/activate",
|
|
"verification_uri_complete": "https://auth.x.ai/activate?user_code=XAI-CODE",
|
|
"expires_in": 600,
|
|
"interval": 5,
|
|
}))
|
|
}
|
|
}));
|
|
|
|
let mut provider = sample_provider("provider-xai", "xai", 10);
|
|
provider.provider_type = "xai".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-xai-responses",
|
|
"provider-xai",
|
|
"openai:responses",
|
|
"https://cli-chat-proxy.grok.com/v1",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let (oidc_url, oidc_handle) = start_server(oidc_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"xai_device",
|
|
format!("{oidc_url}/oauth2/device/code"),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("xai", format!("{oidc_url}/oauth2/token"));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-xai/device-authorize",
|
|
Some(json!({ "proxy_node_id": "proxy-node-xai" })),
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
let session_id = payload["session_id"]
|
|
.as_str()
|
|
.expect("session_id should exist")
|
|
.to_string();
|
|
assert_eq!(payload["user_code"], "XAI-CODE");
|
|
assert_eq!(payload["verification_uri"], "https://auth.x.ai/activate");
|
|
assert_eq!(
|
|
payload["verification_uri_complete"],
|
|
"https://auth.x.ai/activate?user_code=XAI-CODE"
|
|
);
|
|
assert_eq!(payload["auth_type"], "device");
|
|
assert!(payload.get("callback_required").is_none() || payload["callback_required"] == false);
|
|
assert_eq!(*authorize_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
|
|
.expect("device session should be stored");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["provider_id"], "provider-xai");
|
|
assert_eq!(stored["device_code"], "xai-device-code");
|
|
assert_eq!(stored["auth_type"], "device");
|
|
assert_eq!(stored["redirect_uri"], format!("{oidc_url}/oauth2/token"));
|
|
assert_eq!(stored["proxy_node_id"], "proxy-node-xai");
|
|
assert_eq!(stored["status"], "pending");
|
|
|
|
oidc_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_poll_for_xai() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_poll_for_xai",
|
|
gateway_handles_admin_provider_oauth_device_poll_for_xai_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_poll_for_xai_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let access_token = sample_kiro_device_access_token("[email protected]");
|
|
let id_token = access_token.clone();
|
|
let token_server = Router::new().fallback(any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let access_token = access_token.clone();
|
|
let id_token = id_token.clone();
|
|
async move {
|
|
let hit = {
|
|
let mut hits = token_hits_inner.lock().expect("mutex should lock");
|
|
*hits += 1;
|
|
*hits
|
|
};
|
|
if hit == 1 {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({ "error": "authorization_pending" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
Json(json!({
|
|
"access_token": access_token,
|
|
"refresh_token": "xai-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"id_token": id_token,
|
|
}))
|
|
.into_response()
|
|
}
|
|
}));
|
|
|
|
let mut provider = sample_provider("provider-xai", "xai", 10);
|
|
provider.provider_type = "xai".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-xai-responses",
|
|
"provider-xai",
|
|
"openai:responses",
|
|
"https://cli-chat-proxy.grok.com/v1",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let resolved_token_url = format!("{token_url}/oauth2/token");
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-xai",
|
|
json!({
|
|
"provider_id": "provider-xai",
|
|
"region": "",
|
|
"client_id": "b1a00492-073a-47ea-816f-4c329264a828",
|
|
"client_secret": "",
|
|
"device_code": "xai-device-code",
|
|
"auth_type": "device",
|
|
"social_provider": null,
|
|
"code_verifier": null,
|
|
"redirect_uri": resolved_token_url,
|
|
"machine_id": null,
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("xai", resolved_token_url.clone());
|
|
|
|
let pending = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-xai/device-poll",
|
|
Some(json!({ "session_id": "session-xai" })),
|
|
)
|
|
.await;
|
|
let pending_body = to_bytes(pending.into_body(), usize::MAX)
|
|
.await
|
|
.expect("pending body should read");
|
|
let pending_payload: serde_json::Value =
|
|
serde_json::from_slice(&pending_body).expect("pending json should parse");
|
|
assert_eq!(
|
|
pending_payload["status"], "pending",
|
|
"payload={pending_payload}"
|
|
);
|
|
|
|
let authorized = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-xai/device-poll",
|
|
Some(json!({ "session_id": "session-xai" })),
|
|
)
|
|
.await;
|
|
let status = authorized.status();
|
|
let body = to_bytes(authorized.into_body(), usize::MAX)
|
|
.await
|
|
.expect("authorized body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["status"], "authorized");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], false);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 2);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-xai")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "authorized");
|
|
let key_id = stored["key_id"]
|
|
.as_str()
|
|
.expect("key_id should be stored")
|
|
.to_string();
|
|
assert_eq!(payload["key_id"], key_id);
|
|
|
|
let persisted = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert_eq!(persisted.auth_type, "oauth");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "xai");
|
|
assert_eq!(auth_config["auth_method"], "oauth");
|
|
assert_eq!(auth_config["using_api"], false);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_one_time_token() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_poll_for_windsurf_one_time_token",
|
|
gateway_handles_admin_provider_oauth_device_poll_for_windsurf_one_time_token_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_one_time_token_impl() {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
if plan.request_id == "provider-oauth:windsurf-register:new" {
|
|
return Json(windsurf_register_user_execution_result(&plan.request_id));
|
|
}
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
|
|
provider.provider_type = "windsurf".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-windsurf-chat",
|
|
"provider-windsurf",
|
|
"openai:chat",
|
|
"https://server.codeium.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let mut proxy_node = sample_proxy_node("proxy-node-windsurf");
|
|
proxy_node.status = "online".to_string();
|
|
proxy_node.is_manual = true;
|
|
proxy_node.tunnel_mode = false;
|
|
proxy_node.tunnel_connected = false;
|
|
proxy_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![proxy_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-windsurf",
|
|
json!({
|
|
"provider_id": "provider-windsurf",
|
|
"region": "",
|
|
"client_id": "",
|
|
"client_secret": "",
|
|
"device_code": "",
|
|
"auth_type": "browser",
|
|
"social_provider": "google",
|
|
"code_verifier": null,
|
|
"redirect_uri": "show-auth-token",
|
|
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": "proxy-node-windsurf",
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
);
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
|
|
Some(json!({
|
|
"session_id": "session-windsurf",
|
|
"token": "ott$browser-token"
|
|
})),
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["status"], "authorized");
|
|
assert_eq!(payload["replaced"], false);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-windsurf")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "authorized");
|
|
let key_id = stored["key_id"]
|
|
.as_str()
|
|
.expect("key_id should be stored")
|
|
.to_string();
|
|
assert_eq!(payload["key_id"], key_id);
|
|
|
|
let persisted = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert_eq!(persisted.auth_type, "oauth");
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-windsurf", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&persisted);
|
|
assert_eq!(decrypted_api_key, "devin-session-token$registered");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "windsurf");
|
|
assert_eq!(auth_config["auth_method"], "browser");
|
|
assert_eq!(auth_config["register_source"], "new");
|
|
assert_eq!(auth_config["social_provider"], "google");
|
|
|
|
{
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let register_plan = plans
|
|
.iter()
|
|
.find(|plan| plan.request_id == "provider-oauth:windsurf-register:new")
|
|
.expect("register plan should execute");
|
|
assert_eq!(register_plan.method, "POST");
|
|
assert_eq!(
|
|
register_plan.content_type.as_deref(),
|
|
Some("application/proto")
|
|
);
|
|
assert!(register_plan.body.json_body.is_none());
|
|
let encoded_body = register_plan
|
|
.body
|
|
.body_bytes_b64
|
|
.as_deref()
|
|
.expect("register body should be bytes");
|
|
use base64::Engine as _;
|
|
let body_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded_body)
|
|
.expect("register body should decode");
|
|
let mut expected_body = vec![0x0a, "ott$browser-token".len() as u8];
|
|
expected_body.extend_from_slice(b"ott$browser-token");
|
|
assert_eq!(body_bytes, expected_body);
|
|
assert_eq!(
|
|
register_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-windsurf")
|
|
);
|
|
}
|
|
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_windsurf_callback_state_mismatch_and_missing_token() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_windsurf_callback_state_mismatch_and_missing_token",
|
|
gateway_rejects_windsurf_callback_state_mismatch_and_missing_token_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_windsurf_callback_state_mismatch_and_missing_token_impl() {
|
|
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
|
|
provider.provider_type = "windsurf".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-windsurf-chat",
|
|
"provider-windsurf",
|
|
"openai:chat",
|
|
"https://server.codeium.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-windsurf",
|
|
json!({
|
|
"provider_id": "provider-windsurf",
|
|
"region": "",
|
|
"client_id": "",
|
|
"client_secret": "",
|
|
"device_code": "",
|
|
"auth_type": "browser",
|
|
"social_provider": "google",
|
|
"code_verifier": null,
|
|
"redirect_uri": "show-auth-token",
|
|
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
);
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
|
|
Some(json!({
|
|
"session_id": "session-windsurf",
|
|
"callback_url": "https://windsurf.com/show-auth-token?token=ott$wrong-state&state=wrong-state"
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["status"], "error");
|
|
assert!(payload["error"]
|
|
.as_str()
|
|
.is_some_and(|error| error.contains("state")));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
|
|
Some(json!({
|
|
"session_id": "session-windsurf",
|
|
"callback_url": "https://windsurf.com/show-auth-token?state=session-windsurf"
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["status"], "error");
|
|
assert!(payload["error"]
|
|
.as_str()
|
|
.is_some_and(|error| error.contains("token")));
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_authorize_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_authorize_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_device_authorize_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_authorize_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let register_hits = Arc::new(Mutex::new(0usize));
|
|
let register_hits_clone = Arc::clone(®ister_hits);
|
|
let authorize_hits = Arc::new(Mutex::new(0usize));
|
|
let authorize_hits_clone = Arc::clone(&authorize_hits);
|
|
let oidc_server = Router::new()
|
|
.route(
|
|
"/client/register",
|
|
post(move |_request: Request| {
|
|
let register_hits_inner = Arc::clone(®ister_hits_clone);
|
|
async move {
|
|
*register_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"clientId": "kiro-device-client",
|
|
"clientSecret": "kiro-device-secret",
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/device_authorization",
|
|
post(move |_request: Request| {
|
|
let authorize_hits_inner = Arc::clone(&authorize_hits_clone);
|
|
async move {
|
|
*authorize_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"deviceCode": "device-code-123",
|
|
"userCode": "USER-CODE",
|
|
"verificationUri": "https://device.example.com/verify",
|
|
"verificationUriComplete": "https://device.example.com/verify?user_code=USER-CODE",
|
|
"expiresIn": 600,
|
|
"interval": 5,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (oidc_url, oidc_handle) = start_server(oidc_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"seed-session",
|
|
json!({"status":"seed"}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_device_register",
|
|
format!("{oidc_url}/client/register"),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_device_authorize",
|
|
format!("{oidc_url}/device_authorization"),
|
|
);
|
|
let gateway = build_router_with_state(state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/device-authorize"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"start_url": "https://view.awsapps.com/start",
|
|
"region": "us-east-1",
|
|
"proxy_node_id": "proxy-node-kiro",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
let session_id = payload["session_id"]
|
|
.as_str()
|
|
.expect("session_id should exist")
|
|
.to_string();
|
|
assert_eq!(payload["user_code"], "USER-CODE");
|
|
assert_eq!(
|
|
payload["verification_uri_complete"],
|
|
"https://device.example.com/verify?user_code=USER-CODE"
|
|
);
|
|
assert_eq!(payload["expires_in"], 600);
|
|
assert_eq!(payload["interval"], 5);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*register_hits.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*authorize_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
|
|
.expect("device session should be stored");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["provider_id"], "provider-kiro");
|
|
assert_eq!(stored["region"], "us-east-1");
|
|
assert_eq!(stored["client_id"], "kiro-device-client");
|
|
assert_eq!(stored["client_secret"], "kiro-device-secret");
|
|
assert_eq!(stored["device_code"], "device-code-123");
|
|
assert_eq!(stored["proxy_node_id"], "proxy-node-kiro");
|
|
assert_eq!(stored["status"], "pending");
|
|
|
|
gateway_handle.abort();
|
|
oidc_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_authorize_for_kiro_google_social() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_authorize_for_kiro_google_social",
|
|
gateway_handles_admin_provider_oauth_device_authorize_for_kiro_google_social_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_authorize_for_kiro_google_social_impl() {
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_portal",
|
|
"https://portal.example.com/signin",
|
|
);
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-kiro/device-authorize",
|
|
Some(json!({
|
|
"auth_type": "google"
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
let session_id = payload["session_id"]
|
|
.as_str()
|
|
.expect("session_id should exist");
|
|
assert_eq!(payload["auth_type"], "google");
|
|
assert_eq!(payload["callback_required"], true);
|
|
assert_eq!(payload["redirect_uri"], "http://localhost:49153");
|
|
|
|
let authorization_url = payload["verification_uri_complete"]
|
|
.as_str()
|
|
.expect("authorization url should exist");
|
|
let parsed = url::Url::parse(authorization_url).expect("authorization url should parse");
|
|
let params = parsed
|
|
.query_pairs()
|
|
.into_owned()
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
assert_eq!(
|
|
parsed.as_str().split('?').next(),
|
|
Some("https://portal.example.com/signin")
|
|
);
|
|
assert_eq!(
|
|
params.get("redirect_uri").map(String::as_str),
|
|
Some("http://localhost:49153")
|
|
);
|
|
assert_eq!(params.get("state").map(String::as_str), Some(session_id));
|
|
assert_eq!(
|
|
params.get("code_challenge_method").map(String::as_str),
|
|
Some("S256")
|
|
);
|
|
assert_eq!(
|
|
params.get("redirect_from").map(String::as_str),
|
|
Some("KiroIDE")
|
|
);
|
|
assert_eq!(
|
|
params.get("login_option").map(String::as_str),
|
|
Some("google")
|
|
);
|
|
assert!(params
|
|
.get("code_challenge")
|
|
.is_some_and(|value| !value.is_empty()));
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
|
|
.expect("device session should be stored");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["provider_id"], "provider-kiro");
|
|
assert_eq!(stored["auth_type"], "social");
|
|
assert_eq!(stored["social_provider"], "Google");
|
|
assert_eq!(stored["redirect_uri"], "http://localhost:49153");
|
|
assert!(stored["code_verifier"]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert!(stored["machine_id"]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert_eq!(stored["status"], "pending");
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_poll_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_poll_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_device_poll_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_poll_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let access_token = sample_kiro_device_access_token("[email protected]");
|
|
let expected_access_token = access_token.clone();
|
|
let token_server = Router::new().route(
|
|
"/token",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let access_token_inner = access_token.clone();
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"accessToken": access_token_inner,
|
|
"refreshToken": "kiro-device-refresh-token",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-123",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "kiro-device-client",
|
|
"client_secret": "kiro-device-secret",
|
|
"device_code": "device-code-123",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": "proxy-node-kiro",
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("kiro_device_poll", format!("{token_url}/token"))
|
|
.with_provider_oauth_token_url_for_tests("kiro_idc_refresh", token_url.to_string());
|
|
let gateway = build_router_with_state(state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/device-poll"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.header(
|
|
TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER,
|
|
"management-token-123",
|
|
)
|
|
.json(&json!({
|
|
"session_id": "session-123"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["status"], "authorized");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], false);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 2);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-123")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "authorized");
|
|
assert_eq!(stored["email"], "[email protected]");
|
|
assert_eq!(stored["replaced"], false);
|
|
let key_id = stored["key_id"]
|
|
.as_str()
|
|
.expect("key_id should be stored")
|
|
.to_string();
|
|
assert_eq!(payload["key_id"], key_id);
|
|
|
|
let persisted = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert_eq!(persisted.auth_type, "oauth");
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-kiro", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&persisted);
|
|
assert_eq!(decrypted_api_key, expected_access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["auth_method"], "idc");
|
|
assert_eq!(auth_config["refresh_token"], "kiro-device-refresh-token");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["client_id"], "kiro-device-client");
|
|
assert_eq!(auth_config["client_secret"], "kiro-device-secret");
|
|
assert_eq!(auth_config["region"], "us-east-1");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_poll_for_kiro_social_callback() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_poll_for_kiro_social_callback",
|
|
gateway_handles_admin_provider_oauth_device_poll_for_kiro_social_callback_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_poll_for_kiro_social_callback_impl() {
|
|
let token_requests = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
|
let token_requests_clone = Arc::clone(&token_requests);
|
|
let access_token = sample_kiro_device_access_token("[email protected]");
|
|
let expected_access_token = access_token.clone();
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |request: Request| {
|
|
let token_requests_inner = Arc::clone(&token_requests_clone);
|
|
let access_token_inner = access_token.clone();
|
|
async move {
|
|
let user_agent = request
|
|
.headers()
|
|
.get("user-agent")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let raw_body = String::from_utf8(
|
|
to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read")
|
|
.to_vec(),
|
|
)
|
|
.expect("body should be utf8");
|
|
token_requests_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push((user_agent, raw_body));
|
|
Json(json!({
|
|
"accessToken": access_token_inner,
|
|
"refreshToken": "kiro-social-refresh-token",
|
|
"profileArn": "arn:aws:kiro:profile/social",
|
|
"idToken": "id-token-123",
|
|
"tokenType": "Bearer",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-social",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "",
|
|
"client_secret": "",
|
|
"device_code": "",
|
|
"auth_type": "social",
|
|
"social_provider": "Github",
|
|
"code_verifier": "verifier-123",
|
|
"redirect_uri": "http://localhost:49153",
|
|
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_token",
|
|
format!("{token_url}/oauth/token"),
|
|
);
|
|
let gateway = build_router_with_state(state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/device-poll"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.header(
|
|
TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER,
|
|
"management-token-123",
|
|
)
|
|
.json(&json!({
|
|
"session_id": "session-social",
|
|
"callback_url": "http://localhost:49153/signin/callback?login_option=github&code=social-code-123&state=session-social"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["status"], "authorized");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], false);
|
|
|
|
{
|
|
let requests = token_requests.lock().expect("mutex should lock");
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
requests[0].0,
|
|
"KiroIDE-0.6.18-123e4567-e89b-12d3-a456-426614174000"
|
|
);
|
|
assert!(requests[0].1.contains("\"code\":\"social-code-123\""));
|
|
assert!(requests[0].1.contains("\"code_verifier\":\"verifier-123\""));
|
|
assert!(requests[0].1.contains(
|
|
"\"redirect_uri\":\"http://localhost:49153/signin/callback?login_option=github\""
|
|
));
|
|
}
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-social")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "authorized");
|
|
assert_eq!(stored["email"], "[email protected]");
|
|
let key_id = stored["key_id"]
|
|
.as_str()
|
|
.expect("key_id should be stored")
|
|
.to_string();
|
|
assert_eq!(payload["key_id"], key_id);
|
|
|
|
let persisted = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert_eq!(persisted.name, "[email protected] (Github)");
|
|
assert_eq!(persisted.auth_type, "oauth");
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&persisted);
|
|
assert_eq!(decrypted_api_key, expected_access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["provider"], "Github");
|
|
assert_eq!(auth_config["auth_method"], "social");
|
|
assert_eq!(auth_config["refresh_token"], "kiro-social-refresh-token");
|
|
assert_eq!(auth_config["profile_arn"], "arn:aws:kiro:profile/social");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(
|
|
auth_config["machine_id"],
|
|
"123e4567-e89b-12d3-a456-426614174000"
|
|
);
|
|
assert_eq!(auth_config["kiro_version"], "0.6.18");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_keeps_admin_provider_oauth_device_poll_pending_for_authorization_pending_error() {
|
|
run_admin_oauth_test(
|
|
"gateway_keeps_admin_provider_oauth_device_poll_pending_for_authorization_pending_error",
|
|
gateway_keeps_admin_provider_oauth_device_poll_pending_for_authorization_pending_error_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_keeps_admin_provider_oauth_device_poll_pending_for_authorization_pending_error_impl(
|
|
) {
|
|
let token_server = Router::new().route(
|
|
"/token",
|
|
post(move |_request: Request| async move {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"error": "authorization_pending",
|
|
"error_description": "waiting for user confirmation",
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-pending",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "kiro-device-client",
|
|
"client_secret": "kiro-device-secret",
|
|
"device_code": "device-code-123",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("kiro_device_poll", format!("{token_url}/token"));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-kiro/device-poll",
|
|
Some(json!({ "session_id": "session-pending" })),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(
|
|
response.extensions().get::<AdminAuditEvent>().is_none(),
|
|
"pending state should not attach terminal audit"
|
|
);
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["status"], "pending");
|
|
assert_eq!(payload["replaced"], false);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-pending")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "pending");
|
|
assert_eq!(stored["error_msg"], serde_json::Value::Null);
|
|
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_revalidates_kiro_device_poll_via_idc_refresh_and_backfills_email() {
|
|
run_admin_oauth_test(
|
|
"gateway_revalidates_kiro_device_poll_via_idc_refresh_and_backfills_email",
|
|
gateway_revalidates_kiro_device_poll_via_idc_refresh_and_backfills_email_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_revalidates_kiro_device_poll_via_idc_refresh_and_backfills_email_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_requests = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
let token_requests_clone = Arc::clone(&token_requests);
|
|
let initial_access_token = sample_kiro_device_access_token_without_email();
|
|
let refreshed_access_token = sample_kiro_device_access_token_without_email();
|
|
let expected_refreshed_access_token = refreshed_access_token.clone();
|
|
let token_server = Router::new().route(
|
|
"/token",
|
|
post(move |request: Request| {
|
|
let token_requests_inner = Arc::clone(&token_requests_clone);
|
|
let initial_access_token_inner = initial_access_token.clone();
|
|
let refreshed_access_token_inner = refreshed_access_token.clone();
|
|
async move {
|
|
let raw_body = String::from_utf8(
|
|
to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read")
|
|
.to_vec(),
|
|
)
|
|
.expect("body should be utf8");
|
|
token_requests_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(raw_body.clone());
|
|
if raw_body.contains("urn:ietf:params:oauth:grant-type:device_code") {
|
|
return Json(json!({
|
|
"accessToken": initial_access_token_inner,
|
|
"refreshToken": "kiro-device-refresh-token-initial",
|
|
"expiresIn": 1800,
|
|
}))
|
|
.into_response();
|
|
}
|
|
if raw_body.contains("\"grantType\":\"refresh_token\"") {
|
|
return Json(json!({
|
|
"accessToken": refreshed_access_token_inner,
|
|
"refreshToken": "kiro-device-refresh-token-rotated",
|
|
"expiresIn": 2400,
|
|
}))
|
|
.into_response();
|
|
}
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"error": "unexpected_request",
|
|
"body": raw_body,
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}),
|
|
);
|
|
|
|
let usage_hits = Arc::new(Mutex::new(0usize));
|
|
let usage_hits_clone = Arc::clone(&usage_hits);
|
|
let usage_server = Router::new().route(
|
|
"/getUsageLimits",
|
|
get(move |_request: Request| {
|
|
let usage_hits_inner = Arc::clone(&usage_hits_clone);
|
|
async move {
|
|
*usage_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"subscriptionInfo": {
|
|
"subscriptionTitle": "KIRO PRO+"
|
|
},
|
|
"usageBreakdownList": [{
|
|
"currentUsageWithPrecision": 5.0,
|
|
"usageLimitWithPrecision": 20.0,
|
|
"nextDateReset": 1_900_000_000u64
|
|
}],
|
|
"desktopUserInfo": {
|
|
"email": "[email protected]"
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let (usage_url, usage_handle) = start_server(usage_server).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-refresh-email",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "kiro-device-client",
|
|
"client_secret": "kiro-device-secret",
|
|
"device_code": "device-code-123",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": "proxy-node-kiro",
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("kiro_device_poll", format!("{token_url}/token"))
|
|
.with_provider_oauth_token_url_for_tests("kiro_idc_refresh", token_url.to_string())
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_device_email",
|
|
format!("{usage_url}/getUsageLimits"),
|
|
);
|
|
let gateway = build_router_with_state(state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/device-poll"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.header(
|
|
TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER,
|
|
"management-token-123",
|
|
)
|
|
.json(&json!({
|
|
"session_id": "session-refresh-email"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["status"], "authorized");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], false);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*usage_hits.lock().expect("mutex should lock"), 1);
|
|
{
|
|
let requests = token_requests.lock().expect("mutex should lock");
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.any(|body| body.contains("urn:ietf:params:oauth:grant-type:device_code")),
|
|
"requests={requests:?}"
|
|
);
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.any(|body| body.contains("\"grantType\":\"refresh_token\"")),
|
|
"requests={requests:?}"
|
|
);
|
|
}
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests("device_auth_session:session-refresh-email")
|
|
.expect("device session should persist");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["status"], "authorized");
|
|
assert_eq!(stored["email"], "[email protected]");
|
|
assert_eq!(stored["replaced"], false);
|
|
let key_id = stored["key_id"]
|
|
.as_str()
|
|
.expect("key_id should be stored")
|
|
.to_string();
|
|
assert_eq!(payload["key_id"], key_id);
|
|
|
|
let persisted = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert_eq!(persisted.auth_type, "oauth");
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-kiro", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&persisted);
|
|
assert_eq!(decrypted_api_key, expected_refreshed_access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["auth_method"], "idc");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"kiro-device-refresh-token-rotated"
|
|
);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["client_id"], "kiro-device-client");
|
|
assert_eq!(auth_config["client_secret"], "kiro-device-secret");
|
|
assert_eq!(auth_config["region"], "us-east-1");
|
|
|
|
gateway_handle.abort();
|
|
usage_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn local_admin_provider_oauth_device_poll_attaches_audit_only_when_transition_reaches_terminal_state(
|
|
) {
|
|
run_admin_oauth_test(
|
|
"local_admin_provider_oauth_device_poll_attaches_audit_only_when_transition_reaches_terminal_state",
|
|
local_admin_provider_oauth_device_poll_attaches_audit_only_when_transition_reaches_terminal_state_impl,
|
|
);
|
|
}
|
|
|
|
async fn local_admin_provider_oauth_device_poll_attaches_audit_only_when_transition_reaches_terminal_state_impl(
|
|
) {
|
|
let access_token = sample_kiro_device_access_token("[email protected]");
|
|
let token_server = Router::new().route(
|
|
"/token",
|
|
post(move |_request: Request| {
|
|
let access_token_inner = access_token.clone();
|
|
async move {
|
|
Json(json!({
|
|
"accessToken": access_token_inner,
|
|
"refreshToken": "kiro-device-refresh-token",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
|
|
let terminal_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-terminal",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "kiro-device-client",
|
|
"client_secret": "kiro-device-secret",
|
|
"device_code": "device-code-123",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "pending",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": null,
|
|
"email": null,
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("kiro_device_poll", format!("{token_url}/token"))
|
|
.with_provider_oauth_token_url_for_tests("kiro_idc_refresh", token_url.to_string());
|
|
|
|
let terminal_response = local_admin_provider_oauth_response(
|
|
&terminal_state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-kiro/device-poll",
|
|
Some(json!({ "session_id": "session-terminal" })),
|
|
)
|
|
.await;
|
|
assert_eq!(terminal_response.status(), StatusCode::OK);
|
|
let terminal_audit = terminal_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.expect("terminal transition should attach audit");
|
|
assert_eq!(
|
|
terminal_audit.event_name,
|
|
"admin_provider_oauth_device_authorization_completed"
|
|
);
|
|
assert_eq!(
|
|
terminal_audit.action,
|
|
"poll_provider_oauth_device_authorization_terminal_state"
|
|
);
|
|
assert_eq!(terminal_audit.target_type, "provider_oauth_device_session");
|
|
assert_eq!(terminal_audit.target_id, "session-terminal");
|
|
|
|
let non_terminal_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
))
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"session-existing-authorized",
|
|
json!({
|
|
"provider_id": "provider-kiro",
|
|
"region": "us-east-1",
|
|
"client_id": "kiro-device-client",
|
|
"client_secret": "kiro-device-secret",
|
|
"device_code": "device-code-123",
|
|
"interval": 5,
|
|
"expires_at_unix_secs": 4_102_444_800u64,
|
|
"status": "authorized",
|
|
"proxy_node_id": null,
|
|
"created_at_unix_ms": 1_711_000_000u64,
|
|
"key_id": "key-existing",
|
|
"email": "[email protected]",
|
|
"replaced": false,
|
|
"error_msg": null,
|
|
}),
|
|
);
|
|
|
|
let non_terminal_response = local_admin_provider_oauth_response(
|
|
&non_terminal_state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-kiro/device-poll",
|
|
Some(json!({ "session_id": "session-existing-authorized" })),
|
|
)
|
|
.await;
|
|
assert_eq!(non_terminal_response.status(), StatusCode::OK);
|
|
assert!(
|
|
non_terminal_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.is_none(),
|
|
"existing terminal session should not attach a new audit event"
|
|
);
|
|
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_device_authorize_via_execution_runtime_proxy_node() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_device_authorize_via_execution_runtime_proxy_node",
|
|
gateway_handles_admin_provider_oauth_device_authorize_via_execution_runtime_proxy_node_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_device_authorize_via_execution_runtime_proxy_node_impl(
|
|
) {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
|
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-kiro"));
|
|
assert_eq!(
|
|
plan.headers.get("host").map(String::as_str),
|
|
Some("oidc.us-east-1.amazonaws.com")
|
|
);
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
if plan.request_id == "kiro_device_register" {
|
|
assert_eq!(plan.url, "https://oidc.us-east-1.amazonaws.com/client/register");
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"clientId": "kiro-device-client",
|
|
"clientSecret": "kiro-device-secret"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
assert_eq!(plan.request_id, "kiro_device_authorize");
|
|
assert_eq!(
|
|
plan.url,
|
|
"https://oidc.us-east-1.amazonaws.com/device_authorization"
|
|
);
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"deviceCode": "device-code-123",
|
|
"userCode": "USER-CODE",
|
|
"verificationUri": "https://device.example.com/verify",
|
|
"verificationUriComplete": "https://device.example.com/verify?user_code=USER-CODE",
|
|
"expiresIn": 600,
|
|
"interval": 5
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let mut manual_node = sample_proxy_node("proxy-node-kiro");
|
|
manual_node.status = "online".to_string();
|
|
manual_node.is_manual = true;
|
|
manual_node.tunnel_mode = false;
|
|
manual_node.tunnel_connected = false;
|
|
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository),
|
|
)
|
|
.with_provider_oauth_device_session_entry_for_tests(
|
|
"seed-session",
|
|
json!({"status":"seed"}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_device_register",
|
|
"https://oidc.us-east-1.amazonaws.com/client/register",
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_device_authorize",
|
|
"https://oidc.us-east-1.amazonaws.com/device_authorization",
|
|
);
|
|
let gateway = build_router_with_state(state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/device-authorize"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"start_url": "https://view.awsapps.com/start",
|
|
"region": "us-east-1",
|
|
"proxy_node_id": "proxy-node-kiro",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
let session_id = payload["session_id"]
|
|
.as_str()
|
|
.expect("session_id should exist")
|
|
.to_string();
|
|
assert_eq!(payload["user_code"], "USER-CODE");
|
|
assert_eq!(payload["expires_in"], 600);
|
|
assert_eq!(payload["interval"], 5);
|
|
|
|
let stored = state
|
|
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
|
|
.expect("device session should be stored");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&stored).expect("device session json should parse");
|
|
assert_eq!(stored["proxy_node_id"], "proxy-node-kiro");
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_start_key_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_start_key_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_start_key_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_start_key_locally_with_trusted_admin_principal_impl()
|
|
{
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-oauth/keys/key-codex-oauth/start",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
|
|
let mut key = sample_bound_key(
|
|
"key-codex-oauth",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"oauth-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.is_active = false;
|
|
key.error_count = Some(7);
|
|
key.health_by_format = Some(json!({
|
|
"openai:chat": {"consecutive_failures": 3}
|
|
}));
|
|
key.circuit_breaker_by_format = Some(json!({
|
|
"openai:chat": {"state": "open"}
|
|
}));
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth/start"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response.headers().get(http::header::CACHE_CONTROL),
|
|
Some(&HeaderValue::from_static("no-store"))
|
|
);
|
|
assert_eq!(
|
|
response.headers().get(http::header::PRAGMA),
|
|
Some(&HeaderValue::from_static("no-cache"))
|
|
);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(
|
|
payload["redirect_uri"],
|
|
"http://localhost:1455/auth/callback"
|
|
);
|
|
assert!(payload["authorization_url"]
|
|
.as_str()
|
|
.is_some_and(|url| url.contains("state=")));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_start_provider_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_start_provider_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_start_provider_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_start_provider_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-oauth/providers/provider-codex/start",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/start"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response.headers().get(http::header::CACHE_CONTROL),
|
|
Some(&HeaderValue::from_static("no-store"))
|
|
);
|
|
assert_eq!(
|
|
response.headers().get(http::header::PRAGMA),
|
|
Some(&HeaderValue::from_static("no-cache"))
|
|
);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(
|
|
payload["redirect_uri"],
|
|
"http://localhost:1455/auth/callback"
|
|
);
|
|
assert!(payload["authorization_url"]
|
|
.as_str()
|
|
.is_some_and(|url| url.contains("state=")));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_batch_import_task_status_locally_with_trusted_admin_principal(
|
|
) {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_batch_import_task_status_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_batch_import_task_status_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_batch_import_task_status_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/task-123",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_provider_oauth_batch_task_entry_for_tests(
|
|
"task-123",
|
|
json!({
|
|
"task_id": "task-123",
|
|
"provider_id": "provider-codex",
|
|
"provider_type": "codex",
|
|
"status": "completed",
|
|
"total": 2,
|
|
"processed": 2,
|
|
"success": 1,
|
|
"failed": 1,
|
|
"created_count": 0,
|
|
"replaced_count": 1,
|
|
"progress_percent": 100,
|
|
"message": "导入完成:成功 1,失败 1",
|
|
"error": null,
|
|
"error_samples": [{
|
|
"index": 1,
|
|
"status": "error",
|
|
"error": "Token 验证失败: invalid_grant",
|
|
"replaced": false
|
|
}],
|
|
"created_at": 1700000001u64,
|
|
"started_at": 1700000002u64,
|
|
"finished_at": 1700000003u64,
|
|
"updated_at": 1700000004u64,
|
|
}),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/task-123"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["task_id"], "task-123");
|
|
assert_eq!(payload["provider_id"], "provider-codex");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["status"], "completed");
|
|
assert_eq!(payload["total"], 2);
|
|
assert_eq!(payload["processed"], 2);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 1);
|
|
assert_eq!(payload["created_count"], 0);
|
|
assert_eq!(payload["replaced_count"], 1);
|
|
assert_eq!(payload["progress_percent"], 100);
|
|
assert_eq!(payload["error_samples"].as_array().map(Vec::len), Some(1));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn local_admin_provider_oauth_batch_task_status_attaches_audit_only_for_terminal_states() {
|
|
run_admin_oauth_test(
|
|
"local_admin_provider_oauth_batch_task_status_attaches_audit_only_for_terminal_states",
|
|
local_admin_provider_oauth_batch_task_status_attaches_audit_only_for_terminal_states_impl,
|
|
);
|
|
}
|
|
|
|
async fn local_admin_provider_oauth_batch_task_status_attaches_audit_only_for_terminal_states_impl()
|
|
{
|
|
let completed_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_provider_oauth_batch_task_entry_for_tests(
|
|
"task-completed",
|
|
json!({
|
|
"task_id": "task-completed",
|
|
"provider_id": "provider-codex",
|
|
"provider_type": "codex",
|
|
"status": "completed",
|
|
"total": 1,
|
|
"processed": 1,
|
|
"success": 1,
|
|
"failed": 0,
|
|
"progress_percent": 100,
|
|
"message": "导入完成",
|
|
"error": null,
|
|
"error_samples": [],
|
|
"created_at": 1700000001u64,
|
|
"started_at": 1700000002u64,
|
|
"finished_at": 1700000003u64,
|
|
"updated_at": 1700000004u64,
|
|
}),
|
|
);
|
|
let completed_response = local_admin_provider_oauth_response(
|
|
&completed_state,
|
|
http::Method::GET,
|
|
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/task-completed",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(completed_response.status(), StatusCode::OK);
|
|
let completed_audit = completed_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.expect("completed task should attach audit");
|
|
assert_eq!(
|
|
completed_audit.event_name,
|
|
"admin_provider_oauth_batch_task_completed_viewed"
|
|
);
|
|
assert_eq!(
|
|
completed_audit.action,
|
|
"view_provider_oauth_batch_task_terminal_state"
|
|
);
|
|
assert_eq!(completed_audit.target_type, "provider_oauth_batch_task");
|
|
assert_eq!(completed_audit.target_id, "provider-codex:task-completed");
|
|
|
|
let processing_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_provider_oauth_batch_task_entry_for_tests(
|
|
"task-processing",
|
|
json!({
|
|
"task_id": "task-processing",
|
|
"provider_id": "provider-codex",
|
|
"provider_type": "codex",
|
|
"status": "processing",
|
|
"total": 3,
|
|
"processed": 1,
|
|
"success": 1,
|
|
"failed": 0,
|
|
"progress_percent": 33,
|
|
"message": "处理中",
|
|
"error": null,
|
|
"error_samples": [],
|
|
"created_at": 1700000001u64,
|
|
"started_at": 1700000002u64,
|
|
"finished_at": null,
|
|
"updated_at": 1700000003u64,
|
|
}),
|
|
);
|
|
let processing_response = local_admin_provider_oauth_response(
|
|
&processing_state,
|
|
http::Method::GET,
|
|
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/task-processing",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(processing_response.status(), StatusCode::OK);
|
|
assert!(
|
|
processing_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.is_none(),
|
|
"processing task status should not attach audit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_principal",
|
|
gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
let raw_body = String::from_utf8(
|
|
to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read")
|
|
.to_vec(),
|
|
)
|
|
.expect("body should be utf8");
|
|
token_hits_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(raw_body.clone());
|
|
if raw_body.contains("refresh_token=batch-refresh-success") {
|
|
Json(json!({
|
|
"access_token": "batch-imported-codex-access-token",
|
|
"refresh_token": "batch-imported-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-batch-123",
|
|
"account_user_id": "acct-user-batch-123",
|
|
"plan_type": "plus",
|
|
"user_id": "user-batch-123",
|
|
}))
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"error": "invalid_grant",
|
|
"error_description": "refresh token invalid",
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-codex-batch-duplicate",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"stale-batch-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = false;
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","email":"[email protected]","account_id":"acct-batch-123","account_user_id":"acct-user-batch-123","plan_type":"plus","refresh_token":"old-refresh-token"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "batch-refresh-success\nbatch-refresh-error\n",
|
|
"proxy_node_id": "proxy-node-batch-import"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 2);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 1);
|
|
let results = payload["results"]
|
|
.as_array()
|
|
.expect("results should be array");
|
|
assert_eq!(results.len(), 2);
|
|
assert_eq!(results[0]["status"], "success");
|
|
assert_eq!(results[0]["key_id"], "key-codex-batch-duplicate");
|
|
assert_eq!(results[0]["replaced"], true);
|
|
assert_eq!(results[1]["status"], "error");
|
|
assert!(
|
|
results[1]["error"]
|
|
.as_str()
|
|
.expect("error should be string")
|
|
.contains("Token 验证失败"),
|
|
"payload={payload}"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(token_hits.lock().expect("mutex should lock").len(), 2);
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-batch-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert!(persisted.is_active);
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-batch-import", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "batch-imported-codex-access-token");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_imports_chatgpt_web_access_tokens_with_pool_hints() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_imports_chatgpt_web_access_tokens_with_pool_hints",
|
|
gateway_batch_imports_chatgpt_web_access_tokens_with_pool_hints_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_imports_chatgpt_web_access_tokens_with_pool_hints_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "unexpected refresh exchange"})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-chatgpt-web", "chatgpt_web", 10);
|
|
provider.provider_type = "chatgpt_web".to_string();
|
|
provider.config = Some(json!({"pool_advanced": {}}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-chatgpt-web-image",
|
|
"provider-chatgpt-web",
|
|
"openai:image",
|
|
"https://chatgpt.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let pool_score_repository = Arc::new(InMemoryPoolMemberScoreRepository::default());
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_pool_score_repository_for_tests(Arc::clone(&pool_score_repository))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"chatgpt_web",
|
|
format!("{token_url}/oauth/token"),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let credentials = json!([
|
|
{
|
|
"accessToken": "chatgpt-web-batch-access-token",
|
|
"expiresAt": 2_100_000_000u64,
|
|
"email": "[email protected]",
|
|
"accountId": "acct-pool-image",
|
|
"accountUserId": "user-pool-image__acct-pool-image",
|
|
"planType": "plus",
|
|
"userId": "user-pool-image"
|
|
}
|
|
])
|
|
.to_string();
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-chatgpt-web/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": credentials,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-chatgpt-web".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(2_100_000_000));
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "chatgpt-web-batch-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "chatgpt_web");
|
|
assert_eq!(auth_config["access_token_import_temporary"], true);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-pool-image");
|
|
assert_eq!(
|
|
auth_config["account_user_id"],
|
|
"user-pool-image__acct-pool-image"
|
|
);
|
|
assert_eq!(auth_config["plan_type"], "plus");
|
|
assert_eq!(auth_config["user_id"], "user-pool-image");
|
|
|
|
let score_scope = provider_key_pool_score_scope();
|
|
let score_identity =
|
|
PoolMemberIdentity::provider_api_key("provider-chatgpt-web", persisted.id.clone());
|
|
let scores = pool_score_repository
|
|
.get_pool_member_scores_by_ids(&GetPoolMemberScoresByIdsQuery {
|
|
ids: vec![provider_key_pool_score_id(&score_identity, &score_scope)],
|
|
})
|
|
.await
|
|
.expect("pool score should load");
|
|
assert_eq!(scores.len(), 1);
|
|
assert_eq!(scores[0].member_id, persisted.id);
|
|
assert_eq!(scores[0].hard_state, PoolMemberHardState::Unknown);
|
|
assert!(scores[0].score > 0.0);
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal",
|
|
gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_starts_admin_provider_oauth_batch_import_task_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"access_token": "task-imported-codex-access-token",
|
|
"refresh_token": "task-imported-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-task-123",
|
|
"account_user_id": "acct-user-task-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let submit_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "task-batch-refresh-success"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("submit request should succeed");
|
|
|
|
assert_eq!(submit_response.status(), StatusCode::OK);
|
|
let submit_payload: serde_json::Value = submit_response
|
|
.json()
|
|
.await
|
|
.expect("submit payload should parse");
|
|
assert_eq!(submit_payload["status"], "submitted");
|
|
assert_eq!(submit_payload["total"], 1);
|
|
let task_id = submit_payload["task_id"]
|
|
.as_str()
|
|
.expect("task id should exist")
|
|
.to_string();
|
|
|
|
let mut status_payload = serde_json::Value::Null;
|
|
for _ in 0..40 {
|
|
let response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/{task_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("status request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
status_payload = response.json().await.expect("status payload should parse");
|
|
if status_payload["status"] == "completed" {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
status_payload["status"], "completed",
|
|
"payload={status_payload}"
|
|
);
|
|
assert_eq!(status_payload["total"], 1);
|
|
assert_eq!(status_payload["processed"], 1);
|
|
assert_eq!(status_payload["success"], 1);
|
|
assert_eq!(status_payload["failed"], 0);
|
|
assert_eq!(status_payload["created_count"], 1);
|
|
assert_eq!(status_payload["replaced_count"], 0);
|
|
assert_eq!(status_payload["progress_percent"], 100);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 1);
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_updates_admin_provider_oauth_batch_import_task_progress() {
|
|
run_admin_oauth_test(
|
|
"gateway_updates_admin_provider_oauth_batch_import_task_progress",
|
|
gateway_updates_admin_provider_oauth_batch_import_task_progress_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_updates_admin_provider_oauth_batch_import_task_progress_impl() {
|
|
let upstream = Router::new().fallback(any(|| async {
|
|
(StatusCode::OK, Body::from("quota refresh body"))
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
let hit = {
|
|
let mut guard = token_hits_inner.lock().expect("mutex should lock");
|
|
*guard += 1;
|
|
*guard
|
|
};
|
|
if hit == 2 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
}
|
|
Json(json!({
|
|
"access_token": format!("progress-codex-access-token-{hit}"),
|
|
"refresh_token": format!("progress-codex-refresh-token-{hit}"),
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": format!("progress-{hit}@example.com"),
|
|
"account_id": format!("acct-progress-{hit}"),
|
|
"account_user_id": format!("acct-user-progress-{hit}"),
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let submit_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "progress-refresh-one\nprogress-refresh-two"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("submit request should succeed");
|
|
|
|
assert_eq!(submit_response.status(), StatusCode::OK);
|
|
let submit_payload: serde_json::Value = submit_response
|
|
.json()
|
|
.await
|
|
.expect("submit payload should parse");
|
|
let task_id = submit_payload["task_id"]
|
|
.as_str()
|
|
.expect("task id should exist")
|
|
.to_string();
|
|
|
|
let mut progress_payload = serde_json::Value::Null;
|
|
for _ in 0..50 {
|
|
let response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/{task_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("status request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
progress_payload = response.json().await.expect("status payload should parse");
|
|
if progress_payload["status"] == "processing" && progress_payload["processed"] == 1 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
progress_payload["status"], "processing",
|
|
"payload={progress_payload}"
|
|
);
|
|
assert_eq!(progress_payload["total"], 2);
|
|
assert_eq!(progress_payload["processed"], 1);
|
|
assert_eq!(progress_payload["success"], 1);
|
|
assert_eq!(progress_payload["failed"], 0);
|
|
assert_eq!(progress_payload["created_count"], 1);
|
|
assert_eq!(progress_payload["progress_percent"], 50);
|
|
|
|
let mut completed_payload = progress_payload;
|
|
for _ in 0..50 {
|
|
let response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks/{task_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("status request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
completed_payload = response.json().await.expect("status payload should parse");
|
|
if completed_payload["status"] == "completed" {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
completed_payload["status"], "completed",
|
|
"payload={completed_payload}"
|
|
);
|
|
assert_eq!(completed_payload["processed"], 2);
|
|
assert_eq!(completed_payload["success"], 2);
|
|
assert_eq!(completed_payload["progress_percent"], 100);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 2);
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_principal",
|
|
gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_principal_impl() {
|
|
#[derive(Debug, Clone)]
|
|
struct SeenTokenRequest {
|
|
content_type: String,
|
|
body: String,
|
|
}
|
|
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let seen_token = Arc::new(Mutex::new(None::<SeenTokenRequest>));
|
|
let seen_token_clone = Arc::clone(&seen_token);
|
|
let namespace_race_repository = Arc::new(Mutex::new(
|
|
None::<Arc<InMemoryProviderCatalogReadRepository>>,
|
|
));
|
|
let namespace_race_repository_clone = Arc::clone(&namespace_race_repository);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let seen_token_inner = Arc::clone(&seen_token_clone);
|
|
let namespace_race_repository_inner = Arc::clone(&namespace_race_repository_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
let (parts, body) = request.into_parts();
|
|
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
|
*seen_token_inner.lock().expect("mutex should lock") = Some(SeenTokenRequest {
|
|
content_type: parts
|
|
.headers
|
|
.get(http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
body: String::from_utf8(raw_body.to_vec())
|
|
.expect("token request body should be utf8"),
|
|
});
|
|
let repository = namespace_race_repository_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("provider catalog repository should be installed");
|
|
repository
|
|
.upsert_key_upstream_metadata_namespace(
|
|
"key-codex-oauth",
|
|
"codex",
|
|
&json!({
|
|
"primary_used_percent": 80.0,
|
|
"account_quota_request_id": "concurrent-refresh"
|
|
}),
|
|
Some(1_800_000_001),
|
|
)
|
|
.await
|
|
.expect("concurrent Codex namespace update should succeed");
|
|
Json(json!({
|
|
"access_token": "new-codex-access-token",
|
|
"refresh_token": "new-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expiresAt": 4_102_444_800u64,
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.config = Some(json!({"pool_advanced": {}}));
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"__placeholder__",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
|
|
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] token invalid".to_string());
|
|
key.error_count = Some(7);
|
|
key.health_by_format = Some(json!({
|
|
"openai:chat": {"consecutive_failures": 3}
|
|
}));
|
|
key.circuit_breaker_by_format = Some(json!({
|
|
"openai:chat": {"state": "open"}
|
|
}));
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"primary_used_percent": 100.0,
|
|
"primary_reset_at": 4_200_000_000u64,
|
|
"account_quota_reset_fence_unix_ms": 1_800_000_000_000u64,
|
|
"account_quota_reset_fence_id": "old-account-fence",
|
|
"account_quota_reset_processed_ids": ["old-account-reset"],
|
|
"account_quota_reset_pending": true
|
|
},
|
|
"unrelated_runtime": {
|
|
"preserved": true
|
|
}
|
|
}));
|
|
key.status_snapshot = Some(json!({
|
|
"oauth": {
|
|
"code": "invalid"
|
|
},
|
|
"quota": {
|
|
"provider_type": "codex",
|
|
"usage_ratio": 1.0,
|
|
"windows": [{
|
|
"kind": "primary",
|
|
"usage": 100.0,
|
|
"reset_at": 4_200_000_000u64
|
|
}]
|
|
}
|
|
}));
|
|
|
|
let score_identity = PoolMemberIdentity::provider_api_key("provider-codex", "key-codex-oauth");
|
|
let score_scope = provider_key_pool_score_scope();
|
|
let invalid_score = build_provider_key_pool_score_upsert(
|
|
&key,
|
|
"codex",
|
|
None,
|
|
1_700_000_000,
|
|
aether_pool_core::PoolMemberScoreRules::default(),
|
|
)
|
|
.into_stored();
|
|
assert_eq!(invalid_score.hard_state, PoolMemberHardState::AuthInvalid);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![],
|
|
vec![key],
|
|
));
|
|
*namespace_race_repository.lock().expect("mutex should lock") =
|
|
Some(Arc::clone(&provider_catalog_repository));
|
|
let pool_score_repository =
|
|
Arc::new(InMemoryPoolMemberScoreRepository::seed(vec![invalid_score]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_pool_score_repository_for_tests(Arc::clone(&pool_score_repository))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_state_entry_for_tests(
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
json!({
|
|
"nonce": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"key_id": "key-codex-oauth",
|
|
"provider_id": "provider-codex",
|
|
"provider_type": "codex",
|
|
"pkce_verifier": "verifier-codex-123",
|
|
"initiated_by_user_id": "admin-user-123",
|
|
"initiated_by_session_id": "session-123",
|
|
"created_at": aether_admin::provider::state::current_unix_secs(),
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let wrong_session_response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth/complete"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-attacker")
|
|
.json(&json!({
|
|
"callback_url": "http://localhost:1455/auth/callback?code=code-codex-123&state=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("mismatched-session request should succeed");
|
|
assert_eq!(wrong_session_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth/complete"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"callback_url": "http://localhost:1455/auth/callback?code=code-codex-123&state=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["expires_at"], 4_102_444_800u64);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["account_state_recheck_attempted"], false);
|
|
assert_eq!(
|
|
payload["account_state_recheck_error"],
|
|
serde_json::Value::Null
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let seen_token = seen_token
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("token request should be recorded");
|
|
assert_eq!(seen_token.content_type, "application/x-www-form-urlencoded");
|
|
assert!(seen_token.body.contains("grant_type=authorization_code"));
|
|
assert!(seen_token
|
|
.body
|
|
.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
|
|
assert!(seen_token.body.contains("code=code-codex-123"));
|
|
assert!(seen_token.body.contains("code_verifier=verifier-codex-123"));
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(4_102_444_800));
|
|
assert!(persisted.is_active);
|
|
assert_eq!(persisted.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(persisted.oauth_invalid_reason, None);
|
|
assert_eq!(persisted.error_count, Some(0));
|
|
assert_eq!(persisted.health_by_format, Some(json!({})));
|
|
assert_eq!(persisted.circuit_breaker_by_format, Some(json!({})));
|
|
assert_eq!(
|
|
persisted
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(Value::as_object)
|
|
.and_then(|metadata| metadata.get("codex"))
|
|
.and_then(Value::as_object)
|
|
.map(|codex| codex.keys().cloned().collect::<Vec<_>>()),
|
|
Some(vec!["credential_generation".to_string()]),
|
|
"explicit Codex reauthorization must not carry quota/reset state across accounts"
|
|
);
|
|
assert!(persisted
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/credential_generation"))
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|generation| !generation.is_empty()));
|
|
assert_eq!(
|
|
persisted
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.get("unrelated_runtime")),
|
|
Some(&json!({"preserved": true}))
|
|
);
|
|
assert_eq!(
|
|
persisted
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|snapshot| snapshot.get("quota")),
|
|
Some(&Value::Null)
|
|
);
|
|
let scores = pool_score_repository
|
|
.get_pool_member_scores_by_ids(&GetPoolMemberScoresByIdsQuery {
|
|
ids: vec![provider_key_pool_score_id(&score_identity, &score_scope)],
|
|
})
|
|
.await
|
|
.expect("pool score should load");
|
|
assert_eq!(scores.len(), 1);
|
|
assert!(
|
|
scores[0].hard_state.schedulable(),
|
|
"OAuth completion should replace AuthInvalid with a schedulable score"
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "new-codex-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(auth_config["refresh_token"], "new-codex-refresh-token");
|
|
assert_eq!(auth_config["expires_at"], 4_102_444_800u64);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
|
assert_eq!(auth_config["plan_type"], "plus");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_completes_admin_provider_oauth_provider_locally_with_trusted_admin_principal",
|
|
gateway_completes_admin_provider_oauth_provider_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_admin_principal_impl()
|
|
{
|
|
#[derive(Debug, Clone)]
|
|
struct SeenTokenRequest {
|
|
content_type: String,
|
|
body: String,
|
|
}
|
|
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let seen_token = Arc::new(Mutex::new(None::<SeenTokenRequest>));
|
|
let seen_token_clone = Arc::clone(&seen_token);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let seen_token_inner = Arc::clone(&seen_token_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
let (parts, body) = request.into_parts();
|
|
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
|
*seen_token_inner.lock().expect("mutex should lock") = Some(SeenTokenRequest {
|
|
content_type: parts
|
|
.headers
|
|
.get(http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
body: String::from_utf8(raw_body.to_vec())
|
|
.expect("token request body should be utf8"),
|
|
});
|
|
Json(json!({
|
|
"access_token": "provider-codex-access-token",
|
|
"refresh_token": "provider-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_at": 4_102_444_800u64,
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-codex-inactive-duplicate",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"stale-codex-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = false;
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","refresh_token":"old-refresh-token"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_state_entry_for_tests(
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
json!({
|
|
"nonce": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
"key_id": "",
|
|
"provider_id": "provider-codex",
|
|
"provider_type": "codex",
|
|
"pkce_verifier": "verifier-provider-codex-123",
|
|
"initiated_by_user_id": "admin-user-123",
|
|
"initiated_by_session_id": "session-123",
|
|
"created_at": aether_admin::provider::state::current_unix_secs(),
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/complete"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"callback_url": "http://localhost:1455/auth/callback?code=provider-code-123&state=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
"proxy_node_id": "proxy-node-codex-oauth",
|
|
"name": "should-not-override-inactive-name"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["key_id"], "key-codex-inactive-duplicate");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["expires_at"], 4_102_444_800u64);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let seen_token = seen_token
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("token request should be recorded");
|
|
assert_eq!(seen_token.content_type, "application/x-www-form-urlencoded");
|
|
assert!(seen_token.body.contains("grant_type=authorization_code"));
|
|
assert!(seen_token.body.contains("code=provider-code-123"));
|
|
assert!(seen_token
|
|
.body
|
|
.contains("code_verifier=verifier-provider-codex-123"));
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-inactive-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert!(persisted.is_active);
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(4_102_444_800));
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-codex-oauth", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "provider-codex-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(auth_config["refresh_token"], "provider-codex-refresh-token");
|
|
assert_eq!(auth_config["expires_at"], 4_102_444_800u64);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
|
assert_eq!(auth_config["plan_type"], "plus");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email() {
|
|
run_admin_oauth_test(
|
|
"gateway_names_new_antigravity_oauth_account_from_google_userinfo_email",
|
|
|| {
|
|
assert_antigravity_oauth_account_uses_google_userinfo_email(
|
|
"complete",
|
|
json!({
|
|
"callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
|
}),
|
|
)
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email() {
|
|
run_admin_oauth_test(
|
|
"gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email",
|
|
|| {
|
|
assert_antigravity_oauth_account_uses_google_userinfo_email(
|
|
"import-refresh-token",
|
|
json!({"refresh_token": "antigravity-import-refresh-token"}),
|
|
)
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email() {
|
|
run_admin_oauth_test(
|
|
"gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email",
|
|
|| {
|
|
assert_antigravity_oauth_account_uses_google_userinfo_email(
|
|
"batch-import",
|
|
json!({"credentials": "antigravity-import-refresh-token"}),
|
|
)
|
|
},
|
|
);
|
|
}
|
|
|
|
async fn assert_antigravity_oauth_account_uses_google_userinfo_email(
|
|
operation: &str,
|
|
request_body: Value,
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let user_info_hits = Arc::new(Mutex::new(0usize));
|
|
let user_info_hits_clone = Arc::clone(&user_info_hits);
|
|
let seen_user_info_authorization = Arc::new(Mutex::new(None::<String>));
|
|
let seen_user_info_authorization_clone = Arc::clone(&seen_user_info_authorization);
|
|
let google_server = Router::new()
|
|
.route(
|
|
"/oauth/token",
|
|
post(move || {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"access_token": "antigravity-access-token",
|
|
"refresh_token": "antigravity-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"scope": "https://www.googleapis.com/auth/userinfo.email"
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/oauth/userinfo",
|
|
get(move |headers: HeaderMap| {
|
|
let user_info_hits_inner = Arc::clone(&user_info_hits_clone);
|
|
let seen_authorization_inner = Arc::clone(&seen_user_info_authorization_clone);
|
|
async move {
|
|
*user_info_hits_inner.lock().expect("mutex should lock") += 1;
|
|
*seen_authorization_inner.lock().expect("mutex should lock") = headers
|
|
.get(http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned);
|
|
Json(json!({
|
|
"email": "[email protected]",
|
|
"verified_email": true,
|
|
"name": "Antigravity User"
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-antigravity", "antigravity", 10);
|
|
provider.provider_type = "antigravity".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-antigravity",
|
|
"provider-antigravity",
|
|
"gemini:generate_content",
|
|
"https://daily-cloudcode-pa.googleapis.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (google_url, google_handle) = start_server(google_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_state_entry_for_tests(
|
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
json!({
|
|
"nonce": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
"key_id": "",
|
|
"provider_id": "provider-antigravity",
|
|
"provider_type": "antigravity",
|
|
"pkce_verifier": "verifier-antigravity-123",
|
|
"initiated_by_user_id": "admin-user-123",
|
|
"initiated_by_session_id": "session-123",
|
|
"created_at": aether_admin::provider::state::current_unix_secs(),
|
|
}),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"antigravity",
|
|
format!("{google_url}/oauth/token"),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"antigravity_user_info",
|
|
format!("{google_url}/oauth/userinfo"),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-antigravity/{operation}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
let account_result = if operation == "batch-import" {
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 0);
|
|
assert_eq!(payload["results"][0]["status"], "success");
|
|
assert_eq!(
|
|
payload["results"][0]["key_name"],
|
|
"[email protected]"
|
|
);
|
|
&payload["results"][0]
|
|
} else {
|
|
assert_eq!(payload["provider_type"], "antigravity");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
&payload
|
|
};
|
|
assert_eq!(account_result["replaced"], false);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*user_info_hits.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(
|
|
seen_user_info_authorization
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.as_deref(),
|
|
Some("Bearer antigravity-access-token")
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let key_id = account_result["key_id"]
|
|
.as_str()
|
|
.expect("created key id should be returned")
|
|
.to_string();
|
|
let persisted_keys = provider_catalog_repository
|
|
.list_keys_by_ids(std::slice::from_ref(&key_id))
|
|
.await
|
|
.expect("created key should load");
|
|
let persisted = persisted_keys.first().expect("created key should exist");
|
|
assert_eq!(persisted.name, "[email protected]");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["refresh_token"], "antigravity-refresh-token");
|
|
|
|
gateway_handle.abort();
|
|
google_handle.abort();
|
|
upstream_handle.abort();
|
|
drop(upstream_url);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted_admin_principal",
|
|
gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
#[derive(Debug, Clone)]
|
|
struct SeenTokenRequest {
|
|
content_type: String,
|
|
body: String,
|
|
}
|
|
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let seen_token = Arc::new(Mutex::new(None::<SeenTokenRequest>));
|
|
let seen_token_clone = Arc::clone(&seen_token);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |headers: HeaderMap, body: Bytes| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let seen_token_inner = Arc::clone(&seen_token_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
*seen_token_inner.lock().expect("mutex should lock") = Some(SeenTokenRequest {
|
|
content_type: headers
|
|
.get(http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
body: String::from_utf8(body.to_vec()).unwrap_or_default(),
|
|
});
|
|
Json(json!({
|
|
"access_token": "imported-codex-access-token",
|
|
"refresh_token": "imported-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-codex-import-duplicate",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"stale-imported-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = false;
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","refresh_token":"old-refresh-token"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"proxy_node_id": "proxy-node-codex-import",
|
|
"name": "should-not-override-inactive-name"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["key_id"], "key-codex-import-duplicate");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let seen_token = seen_token
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("token request should be recorded");
|
|
assert_eq!(seen_token.content_type, "application/x-www-form-urlencoded");
|
|
assert!(seen_token.body.contains("grant_type=refresh_token"));
|
|
assert!(seen_token
|
|
.body
|
|
.contains("refresh_token=provider-import-refresh-token"));
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-import-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert!(persisted.is_active);
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-codex-import", "enabled": true}))
|
|
);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "imported-codex-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(auth_config["refresh_token"], "imported-codex-refresh-token");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
|
assert_eq!(auth_config["plan_type"], "plus");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_account() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_codex_access_token_without_refresh_token_as_temporary_account",
|
|
gateway_imports_codex_access_token_without_refresh_token_as_temporary_account_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_account_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move || {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "unexpected refresh exchange"})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let access_token =
|
|
sample_codex_access_token_with_profile_email("[email protected]", "acct-profile-123");
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"access_token": access_token,
|
|
"name": "temporary-codex-access-token",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], false);
|
|
assert_eq!(payload["temporary"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(2_000_000_000));
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(auth_config["access_token_import_temporary"], true);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-profile-123");
|
|
assert!(auth_config.get("refresh_token").is_none());
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_codex_header_authorization_without_overwriting_payload_access_token() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_codex_header_authorization_without_overwriting_payload_access_token",
|
|
gateway_imports_codex_header_authorization_without_overwriting_payload_access_token_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_codex_header_authorization_without_overwriting_payload_access_token_impl()
|
|
{
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let access_token =
|
|
sample_codex_access_token_with_profile_email("[email protected]", "acct-profile-123");
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"access_token": access_token,
|
|
"headers": {
|
|
"authorization": "Bearer imported-session-token",
|
|
"chatgpt-account-id": "acct-header"
|
|
},
|
|
"name": "temporary-codex-header-auth",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["access_token"], access_token);
|
|
assert_eq!(
|
|
auth_config["headers"]["authorization"],
|
|
"Bearer imported-session-token"
|
|
);
|
|
assert_eq!(auth_config["headers"]["chatgpt-account-id"], "acct-header");
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account",
|
|
gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_chatgpt_web_access_token_without_refresh_token_as_temporary_account_impl()
|
|
{
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move || {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "unexpected refresh exchange"})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-chatgpt-web", "chatgpt_web", 10);
|
|
provider.provider_type = "chatgpt_web".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-chatgpt-web-image",
|
|
"provider-chatgpt-web",
|
|
"openai:image",
|
|
"https://chatgpt.com",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"chatgpt_web",
|
|
format!("{token_url}/oauth/token"),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let access_token =
|
|
sample_codex_access_token_with_profile_email("[email protected]", "acct-image-123");
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-chatgpt-web/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"access_token": access_token,
|
|
"name": "temporary-chatgpt-web-access-token",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "chatgpt_web");
|
|
assert_eq!(payload["has_refresh_token"], false);
|
|
assert_eq!(payload["temporary"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-chatgpt-web".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(2_000_000_000));
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "chatgpt_web");
|
|
assert_eq!(auth_config["access_token_import_temporary"], true);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-image-123");
|
|
assert!(auth_config.get("refresh_token").is_none());
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp",
|
|
gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp_impl() {
|
|
let access_token = sample_codex_access_token_with_profile_email_and_expiry(
|
|
"[email protected]",
|
|
"acct-opaque-123",
|
|
None,
|
|
);
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-responses",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"access_token": access_token,
|
|
"expiresAt": 2_100_000_000u64,
|
|
"name": "temporary-codex-opaque-access-token",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["expires_at"], 2_100_000_000u64);
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert_eq!(persisted.expires_at_unix_secs, Some(2_100_000_000));
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["expires_at"], 2_100_000_000u64);
|
|
assert_eq!(auth_config["access_token_import_temporary"], true);
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_admin_provider_oauth_refresh_token_over_active_expired_duplicate() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_admin_provider_oauth_refresh_token_over_active_expired_duplicate",
|
|
gateway_imports_admin_provider_oauth_refresh_token_over_active_expired_duplicate_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_admin_provider_oauth_refresh_token_over_active_expired_duplicate_impl() {
|
|
#[derive(Debug, Clone)]
|
|
struct SeenTokenRequest {
|
|
content_type: String,
|
|
body: String,
|
|
}
|
|
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let seen_token = Arc::new(Mutex::new(None::<SeenTokenRequest>));
|
|
let seen_token_clone = Arc::clone(&seen_token);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |headers: HeaderMap, body: Bytes| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let seen_token_inner = Arc::clone(&seen_token_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
*seen_token_inner.lock().expect("mutex should lock") = Some(SeenTokenRequest {
|
|
content_type: headers
|
|
.get(http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
body: String::from_utf8(body.to_vec()).unwrap_or_default(),
|
|
});
|
|
Json(json!({
|
|
"access_token": "imported-expired-codex-access-token",
|
|
"refresh_token": "imported-expired-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-codex-import-expired-duplicate",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"stale-imported-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = true;
|
|
existing_key.expires_at_unix_secs = Some(1);
|
|
existing_key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
|
|
existing_key.oauth_invalid_reason =
|
|
Some("[REFRESH_FAILED] refresh_token 无效、已过期或已撤销,请重新登录授权".to_string());
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","refresh_token":"old-refresh-token","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"proxy_node_id": "proxy-node-codex-import",
|
|
"name": "should-not-override-active-expired-name"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["key_id"], "key-codex-import-expired-duplicate");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let seen_token = seen_token
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("token request should be recorded");
|
|
assert_eq!(seen_token.content_type, "application/x-www-form-urlencoded");
|
|
assert!(seen_token.body.contains("grant_type=refresh_token"));
|
|
assert!(seen_token
|
|
.body
|
|
.contains("refresh_token=provider-import-refresh-token"));
|
|
|
|
let reloaded = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-import-expired-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
let persisted = reloaded.first().expect("persisted key should exist");
|
|
assert!(persisted.is_active);
|
|
assert_eq!(
|
|
persisted.proxy,
|
|
Some(json!({"node_id": "proxy-node-codex-import", "enabled": true}))
|
|
);
|
|
assert_eq!(persisted.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(persisted.oauth_invalid_reason, None);
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(persisted);
|
|
assert_eq!(decrypted_api_key, "imported-expired-codex-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(persisted);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"imported-expired-codex-refresh-token"
|
|
);
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
|
assert_eq!(auth_config["plan_type"], "plus");
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_import_invalidate_cached_oauth_entry_before_followup_resolution() {
|
|
run_admin_oauth_test(
|
|
"gateway_import_invalidate_cached_oauth_entry_before_followup_resolution",
|
|
gateway_import_invalidate_cached_oauth_entry_before_followup_resolution_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_import_invalidate_cached_oauth_entry_before_followup_resolution_impl() {
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |body: Bytes| async move {
|
|
let body_text = String::from_utf8(body.to_vec()).unwrap_or_default();
|
|
if body_text.contains("refresh_token=old-refresh-token") {
|
|
Json(json!({
|
|
"access_token": "cached-old-codex-access-token",
|
|
"refresh_token": "cached-old-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
} else {
|
|
assert!(
|
|
body_text.contains("refresh_token=provider-import-refresh-token"),
|
|
"unexpected token request body: {body_text}"
|
|
);
|
|
Json(json!({
|
|
"access_token": "imported-fresh-codex-access-token",
|
|
"refresh_token": "imported-fresh-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-codex-import-cache-duplicate",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"stale-imported-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.expires_at_unix_secs = Some(1);
|
|
existing_key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
|
|
existing_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","refresh_token":"old-refresh-token","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let app_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token"))
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh);
|
|
|
|
let stale_transport = app_state
|
|
.read_provider_transport_snapshot(
|
|
"provider-codex",
|
|
"endpoint-codex-chat",
|
|
"key-codex-import-cache-duplicate",
|
|
)
|
|
.await
|
|
.expect("transport should load")
|
|
.expect("transport should exist");
|
|
let cached_entry = app_state
|
|
.force_local_oauth_refresh_entry(&stale_transport)
|
|
.await
|
|
.expect("initial refresh should succeed")
|
|
.expect("initial refresh should return cached entry");
|
|
assert_eq!(
|
|
cached_entry.auth_header_value,
|
|
"Bearer cached-old-codex-access-token"
|
|
);
|
|
provider_catalog_repository
|
|
.update_key_oauth_runtime_state(
|
|
"key-codex-import-cache-duplicate",
|
|
Some(1_700_000_000),
|
|
Some("[OAUTH_EXPIRED] token invalidated"),
|
|
Some(1_700_000_000),
|
|
)
|
|
.await
|
|
.expect("oauth invalid marker should be seeded through the runtime mutation");
|
|
|
|
let gateway = build_router_with_state(app_state.clone());
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"name": "should-not-override-cache-duplicate-name"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["key_id"], "key-codex-import-cache-duplicate");
|
|
assert_eq!(payload["replaced"], true);
|
|
|
|
let fresh_transport = app_state
|
|
.read_provider_transport_snapshot(
|
|
"provider-codex",
|
|
"endpoint-codex-chat",
|
|
"key-codex-import-cache-duplicate",
|
|
)
|
|
.await
|
|
.expect("transport should load")
|
|
.expect("transport should exist");
|
|
let resolved = app_state
|
|
.resolve_local_oauth_request_auth(&fresh_transport)
|
|
.await
|
|
.expect("oauth auth should resolve")
|
|
.expect("oauth auth should exist");
|
|
match resolved {
|
|
crate::provider_transport::LocalResolvedOAuthRequestAuth::Header { value, .. } => {
|
|
assert_eq!(value, "Bearer imported-fresh-codex-access-token");
|
|
}
|
|
crate::provider_transport::LocalResolvedOAuthRequestAuth::Kiro(_) => {
|
|
panic!("codex should resolve to header auth")
|
|
}
|
|
}
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_kiro_single_refresh_token_import_with_clear_error() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_kiro_single_refresh_token_import_with_clear_error",
|
|
gateway_rejects_kiro_single_refresh_token_import_with_clear_error_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_kiro_single_refresh_token_import_with_clear_error_impl() {
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![{
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
provider
|
|
}],
|
|
vec![sample_endpoint(
|
|
"endpoint-kiro-chat",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"https://service.kiro.dev",
|
|
)],
|
|
Vec::new(),
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
|
|
let response = local_admin_provider_oauth_response(
|
|
&state,
|
|
http::Method::POST,
|
|
"/api/admin/provider-oauth/providers/provider-kiro/import-refresh-token",
|
|
Some(json!({
|
|
"refresh_token": "kiro-refresh-token"
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let payload: serde_json::Value = serde_json::from_slice(
|
|
&to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read"),
|
|
)
|
|
.expect("json body should parse");
|
|
assert_eq!(
|
|
payload["detail"],
|
|
json!("Kiro 不支持单条 Refresh Token 导入,请使用批量导入或设备授权。")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_proxy_node() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_proxy_node",
|
|
gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_proxy_node_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_proxy_node_impl()
|
|
{
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
|
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-import"));
|
|
let timeouts = plan.timeouts.as_ref().expect("timeouts should exist");
|
|
assert_eq!(timeouts.connect_ms, Some(60_000));
|
|
assert_eq!(timeouts.read_ms, Some(60_000));
|
|
assert_eq!(timeouts.write_ms, Some(60_000));
|
|
assert_eq!(timeouts.pool_ms, Some(60_000));
|
|
assert_eq!(timeouts.total_ms, Some(60_000));
|
|
if plan.request_id == "provider-oauth:refresh-token" {
|
|
assert_eq!(plan.method, "POST");
|
|
assert_eq!(plan.url, "https://oauth.example/oauth/token");
|
|
assert_eq!(
|
|
plan.headers.get("content-type").map(String::as_str),
|
|
Some("application/x-www-form-urlencoded")
|
|
);
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
Json(codex_import_token_execution_result(&plan.request_id))
|
|
} else {
|
|
assert!(
|
|
plan.request_id.starts_with("codex-quota:"),
|
|
"unexpected execution plan: {}",
|
|
plan.request_id
|
|
);
|
|
Json(codex_quota_execution_result(&plan.request_id))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let mut manual_node = sample_proxy_node("proxy-node-codex-import");
|
|
manual_node.status = "online".to_string();
|
|
manual_node.is_manual = true;
|
|
manual_node.tunnel_mode = false;
|
|
manual_node.tunnel_connected = false;
|
|
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"proxy_node_id": "proxy-node-codex-import",
|
|
"name": "codex-import"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["replaced"], false);
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(
|
|
keys[0].proxy,
|
|
Some(json!({"node_id": "proxy-node-codex-import", "enabled": true}))
|
|
);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let token_plan = assert_single_provider_oauth_refresh_token_plan(&plans);
|
|
assert_eq!(
|
|
token_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-codex-import")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_provider_proxy_before_system_proxy(
|
|
) {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_provider_proxy_before_system_proxy",
|
|
gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_provider_proxy_before_system_proxy_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_provider_proxy_before_system_proxy_impl(
|
|
) {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
|
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-provider"));
|
|
if plan.request_id == "provider-oauth:refresh-token" {
|
|
Json(codex_import_token_execution_result(&plan.request_id))
|
|
} else {
|
|
assert!(
|
|
plan.request_id.starts_with("codex-quota:"),
|
|
"unexpected execution plan: {}",
|
|
plan.request_id
|
|
);
|
|
Json(codex_quota_execution_result(&plan.request_id))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.proxy = Some(json!({"node_id":"proxy-node-codex-provider","enabled":true}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let mut provider_node = sample_proxy_node("proxy-node-codex-provider");
|
|
provider_node.status = "online".to_string();
|
|
provider_node.is_manual = true;
|
|
provider_node.tunnel_mode = false;
|
|
provider_node.tunnel_connected = false;
|
|
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
|
let mut system_node = sample_proxy_node("proxy-node-codex-system");
|
|
system_node.status = "online".to_string();
|
|
system_node.is_manual = true;
|
|
system_node.tunnel_mode = false;
|
|
system_node.tunnel_connected = false;
|
|
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
|
provider_node,
|
|
system_node,
|
|
]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"system_proxy_node_id".to_string(),
|
|
json!("proxy-node-codex-system"),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"name": "codex-import"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(keys[0].proxy, None);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let token_plan = assert_single_provider_oauth_refresh_token_plan(&plans);
|
|
assert_eq!(
|
|
token_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-codex-provider")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy() {
|
|
run_admin_oauth_test(
|
|
"gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy",
|
|
gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy_impl(
|
|
) {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
|
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-system"));
|
|
if plan.request_id == "provider-oauth:refresh-token" {
|
|
Json(codex_import_token_execution_result(&plan.request_id))
|
|
} else {
|
|
assert!(
|
|
plan.request_id.starts_with("codex-quota:"),
|
|
"unexpected execution plan: {}",
|
|
plan.request_id
|
|
);
|
|
Json(codex_quota_execution_result(&plan.request_id))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let mut manual_node = sample_proxy_node("proxy-node-codex-system");
|
|
manual_node.status = "online".to_string();
|
|
manual_node.is_manual = true;
|
|
manual_node.tunnel_mode = false;
|
|
manual_node.tunnel_connected = false;
|
|
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"system_proxy_node_id".to_string(),
|
|
json!("proxy-node-codex-system"),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"name": "codex-import"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let token_plan = assert_single_provider_oauth_refresh_token_plan(&plans);
|
|
assert_eq!(
|
|
token_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-codex-system")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_import_refresh_token_surfaces_execution_runtime_error_detail() {
|
|
run_admin_oauth_test(
|
|
"gateway_import_refresh_token_surfaces_execution_runtime_error_detail",
|
|
gateway_import_refresh_token_surfaces_execution_runtime_error_detail_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_import_refresh_token_surfaces_execution_runtime_error_detail_impl() {
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(|| async { StatusCode::INTERNAL_SERVER_ERROR }),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-chat",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"refresh_token": "provider-import-refresh-token",
|
|
"name": "codex-import"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "payload={payload}");
|
|
assert_eq!(
|
|
payload["detail"], "Refresh Token 验证失败: token exchange 失败",
|
|
"payload={payload}"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal",
|
|
gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal_impl()
|
|
{
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let refresh_hits_clone = Arc::clone(&refresh_hits);
|
|
let refresh_server = Router::new().route(
|
|
"/refreshToken",
|
|
post(move |_request: Request| {
|
|
let refresh_hits_inner = Arc::clone(&refresh_hits_clone);
|
|
async move {
|
|
*refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"accessToken": sample_kiro_device_access_token("[email protected]"),
|
|
"refreshToken": "kiro-batch-refresh-token-new",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-kiro-chat",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"https://service.kiro.dev",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-kiro-batch-duplicate",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"stale-kiro-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = false;
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"kiro","auth_method":"social","email":"[email protected]","refresh_token":"kiro-batch-refresh-old"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (refresh_url, refresh_handle) = start_server(refresh_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_refresh",
|
|
refresh_url.to_string(),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "kiro-batch-refresh-token",
|
|
"proxy_node_id": "proxy-node-kiro-batch"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("payload should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 0);
|
|
let results = payload["results"]
|
|
.as_array()
|
|
.expect("results should be array");
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0]["status"], "success");
|
|
assert_eq!(results[0]["key_id"], "key-kiro-batch-duplicate");
|
|
assert_eq!(results[0]["auth_method"], "social");
|
|
assert_eq!(results[0]["replaced"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-kiro-batch-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert!(stored_key.is_active);
|
|
assert_eq!(
|
|
stored_key.proxy,
|
|
Some(json!({"node_id": "proxy-node-kiro-batch", "enabled": true}))
|
|
);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["auth_method"], "social");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(auth_config["refresh_token"], "kiro-batch-refresh-token-new");
|
|
|
|
gateway_handle.abort();
|
|
refresh_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_imports_admin_provider_oauth_kiro_over_active_expired_duplicate() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_imports_admin_provider_oauth_kiro_over_active_expired_duplicate",
|
|
gateway_batch_imports_admin_provider_oauth_kiro_over_active_expired_duplicate_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_imports_admin_provider_oauth_kiro_over_active_expired_duplicate_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let refresh_hits_clone = Arc::clone(&refresh_hits);
|
|
let refresh_server = Router::new().route(
|
|
"/refreshToken",
|
|
post(move |_request: Request| {
|
|
let refresh_hits_inner = Arc::clone(&refresh_hits_clone);
|
|
async move {
|
|
*refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"accessToken": sample_kiro_device_access_token("[email protected]"),
|
|
"refreshToken": "kiro-batch-refresh-token-replaced",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-kiro-chat",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"https://service.kiro.dev",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-kiro-batch-expired-duplicate",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"stale-kiro-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = true;
|
|
existing_key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
|
|
existing_key.oauth_invalid_reason = Some("Kiro Token 无效或已过期".to_string());
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"kiro","auth_method":"social","email":"[email protected]","refresh_token":"kiro-batch-refresh-old"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (refresh_url, refresh_handle) = start_server(refresh_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_refresh",
|
|
refresh_url.to_string(),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "kiro-batch-refresh-token",
|
|
"proxy_node_id": "proxy-node-kiro-batch"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("payload should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["success"], 1, "payload={payload}");
|
|
assert_eq!(payload["failed"], 0);
|
|
let results = payload["results"]
|
|
.as_array()
|
|
.expect("results should be array");
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0]["status"], "success");
|
|
assert_eq!(results[0]["key_id"], "key-kiro-batch-expired-duplicate");
|
|
assert_eq!(results[0]["auth_method"], "social");
|
|
assert_eq!(results[0]["replaced"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-kiro-batch-expired-duplicate".to_string()])
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert!(stored_key.is_active);
|
|
assert_eq!(
|
|
stored_key.proxy,
|
|
Some(json!({"node_id": "proxy-node-kiro-batch", "enabled": true}))
|
|
);
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["auth_method"], "social");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"kiro-batch-refresh-token-replaced"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
refresh_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_proxy_node() {
|
|
run_admin_oauth_test(
|
|
"gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_proxy_node",
|
|
gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_proxy_node_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_proxy_node_impl() {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
assert_eq!(
|
|
plan.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-kiro-batch-runtime")
|
|
);
|
|
if plan.request_id == "provider-oauth:kiro-social-refresh" {
|
|
assert_eq!(plan.url, "https://oauth.example/refreshToken");
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
return Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"accessToken": sample_kiro_device_access_token("[email protected]"),
|
|
"refreshToken": "kiro-runtime-refresh-token-new",
|
|
"expiresIn": 1800,
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
assert_eq!(plan.request_id, "kiro-quota:key-kiro-batch-runtime");
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"subscriptionInfo": {
|
|
"subscriptionTitle": "KIRO PRO+"
|
|
},
|
|
"usageBreakdownList": [{
|
|
"currentUsageWithPrecision": 5.0,
|
|
"usageLimitWithPrecision": 20.0,
|
|
"nextDateReset": 1_900_000_000u64
|
|
}],
|
|
"desktopUserInfo": {
|
|
"email": "[email protected]"
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-kiro-chat",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"https://service.kiro.dev",
|
|
);
|
|
|
|
let mut existing_key = sample_key(
|
|
"key-kiro-batch-runtime",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"stale-kiro-runtime-access-token",
|
|
);
|
|
existing_key.auth_type = "oauth".to_string();
|
|
existing_key.is_active = false;
|
|
existing_key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"kiro","auth_method":"social","email":"[email protected]","refresh_token":"kiro-runtime-refresh-token-old"}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![existing_key],
|
|
));
|
|
let mut manual_node = sample_proxy_node("proxy-node-kiro-batch-runtime");
|
|
manual_node.status = "online".to_string();
|
|
manual_node.is_manual = true;
|
|
manual_node.tunnel_mode = false;
|
|
manual_node.tunnel_connected = false;
|
|
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_refresh",
|
|
"https://oauth.example",
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "kiro-runtime-refresh-token-old",
|
|
"proxy_node_id": "proxy-node-kiro-batch-runtime"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("payload should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["success"], 1);
|
|
assert_eq!(payload["failed"], 0);
|
|
assert_eq!(payload["results"][0]["status"], "success");
|
|
assert_eq!(payload["results"][0]["key_id"], "key-kiro-batch-runtime");
|
|
assert_eq!(payload["results"][0]["replaced"], true);
|
|
|
|
for _ in 0..40 {
|
|
let plan_count = execution_plans.lock().expect("mutex should lock").len();
|
|
if plan_count == 2 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
{
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
}
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-kiro-batch-runtime".to_string()])
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("persisted key should exist");
|
|
assert!(stored_key.is_active);
|
|
assert_eq!(
|
|
stored_key.proxy,
|
|
Some(json!({"node_id": "proxy-node-kiro-batch-runtime", "enabled": true}))
|
|
);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["email"], "[email protected]");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"kiro-runtime-refresh-token-new"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_starts_admin_provider_oauth_kiro_batch_import_task_locally_with_trusted_admin_principal()
|
|
{
|
|
run_admin_oauth_test(
|
|
"gateway_starts_admin_provider_oauth_kiro_batch_import_task_locally_with_trusted_admin_principal",
|
|
gateway_starts_admin_provider_oauth_kiro_batch_import_task_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_starts_admin_provider_oauth_kiro_batch_import_task_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let refresh_hits_clone = Arc::clone(&refresh_hits);
|
|
let refresh_server = Router::new().route(
|
|
"/refreshToken",
|
|
post(move |_request: Request| {
|
|
let refresh_hits_inner = Arc::clone(&refresh_hits_clone);
|
|
async move {
|
|
*refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"accessToken": sample_kiro_device_access_token("[email protected]"),
|
|
"refreshToken": "kiro-task-refresh-token-new",
|
|
"expiresIn": 1800,
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-kiro-chat",
|
|
"provider-kiro",
|
|
"kiro:generateAssistantResponse",
|
|
"https://service.kiro.dev",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (refresh_url, refresh_handle) = start_server(refresh_server).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_provider_oauth_token_url_for_tests(
|
|
"kiro_social_refresh",
|
|
refresh_url.to_string(),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let submit_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/batch-import/tasks"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"credentials": "kiro-task-refresh-token"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("submit request should succeed");
|
|
|
|
assert_eq!(submit_response.status(), StatusCode::OK);
|
|
let submit_payload: serde_json::Value = submit_response
|
|
.json()
|
|
.await
|
|
.expect("submit payload should parse");
|
|
assert_eq!(submit_payload["status"], "submitted");
|
|
let task_id = submit_payload["task_id"]
|
|
.as_str()
|
|
.expect("task id should exist")
|
|
.to_string();
|
|
|
|
let mut status_payload = serde_json::Value::Null;
|
|
for _ in 0..40 {
|
|
let response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-kiro/batch-import/tasks/{task_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("status request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
status_payload = response.json().await.expect("status payload should parse");
|
|
if status_payload["status"] == "completed" {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
status_payload["status"], "completed",
|
|
"payload={status_payload}"
|
|
);
|
|
assert_eq!(status_payload["total"], 1);
|
|
assert_eq!(status_payload["processed"], 1);
|
|
assert_eq!(status_payload["success"], 1);
|
|
assert_eq!(status_payload["failed"], 0);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-kiro".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert_eq!(keys.len(), 1);
|
|
|
|
gateway_handle.abort();
|
|
refresh_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_marks_lazy_codex_oauth_refresh_failures_as_invalid() {
|
|
run_admin_oauth_test(
|
|
"gateway_marks_lazy_codex_oauth_refresh_failures_as_invalid",
|
|
gateway_marks_lazy_codex_oauth_refresh_failures_as_invalid_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_marks_lazy_codex_oauth_refresh_failures_as_invalid_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({
|
|
"error": {
|
|
"message": "Your refresh token has already been used to generate a new access token. Please try signing in again.",
|
|
"type": "invalid_request_error",
|
|
"param": serde_json::Value::Null,
|
|
"code": "refresh_token_reused"
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-lazy",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.expires_at_unix_secs = Some(1);
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"used-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let state = AppState::new()
|
|
.expect("state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh);
|
|
|
|
let transport = state
|
|
.read_provider_transport_snapshot(
|
|
"provider-codex",
|
|
"endpoint-codex-cli",
|
|
"key-codex-oauth-lazy",
|
|
)
|
|
.await
|
|
.expect("transport snapshot should load")
|
|
.expect("transport snapshot should exist");
|
|
let resolved = state
|
|
.resolve_local_oauth_request_auth(&transport)
|
|
.await
|
|
.expect("refresh-token reuse should degrade into oauth-unavailable");
|
|
|
|
assert_eq!(resolved, None);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-lazy".to_string()])
|
|
.await
|
|
.expect("keys should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("oauth key should exist");
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 已被使用并轮换,请重新登录授权")
|
|
);
|
|
let oauth_snapshot = stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|snapshot| snapshot.get("oauth"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("oauth status snapshot should exist");
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("code")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("invalid")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("source")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("oauth_refresh")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("requires_reauth")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_principal",
|
|
gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"access_token": "refreshed-codex-access-token",
|
|
"refresh_token": "refreshed-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SeenExecutionRuntimeRequest {
|
|
url: String,
|
|
authorization: String,
|
|
}
|
|
|
|
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeRequest>));
|
|
let seen_execution_runtime_clone = Arc::clone(&seen_execution_runtime);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |request: Request| {
|
|
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
|
|
async move {
|
|
let plan: aether_contracts::ExecutionPlan = serde_json::from_slice(
|
|
&to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read"),
|
|
)
|
|
.expect("plan should parse");
|
|
*seen_execution_runtime_inner
|
|
.lock()
|
|
.expect("mutex should lock") = Some(SeenExecutionRuntimeRequest {
|
|
url: plan.url.clone(),
|
|
authorization: plan
|
|
.headers
|
|
.get("authorization")
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
});
|
|
let result = aether_contracts::ExecutionResult {
|
|
request_id: plan.request_id,
|
|
candidate_id: None,
|
|
status_code: 401,
|
|
headers: std::collections::BTreeMap::new(),
|
|
response_observation: None,
|
|
body: None,
|
|
telemetry: None,
|
|
error: None,
|
|
};
|
|
(StatusCode::OK, Json(result))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
|
|
key.oauth_invalid_reason = Some("[REFRESH_FAILED] stale token".to_string());
|
|
key.status_snapshot = Some(json!({
|
|
"oauth": {
|
|
"code": "expired",
|
|
"label": "已过期",
|
|
"reason": "Access Token 已过期,等待自动续期",
|
|
"expires_at": 1u64,
|
|
"invalid_at": 1_700_000_000u64,
|
|
"source": "expires_at",
|
|
"requires_reauth": false,
|
|
"expiring_soon": false
|
|
},
|
|
"account": {
|
|
"code": "ok",
|
|
"label": null,
|
|
"reason": null,
|
|
"blocked": false,
|
|
"source": null,
|
|
"recoverable": false
|
|
},
|
|
"quota": {
|
|
"code": "unknown",
|
|
"label": null,
|
|
"reason": null,
|
|
"exhausted": false,
|
|
"usage_ratio": null,
|
|
"updated_at": null,
|
|
"reset_seconds": null,
|
|
"plan_type": null
|
|
}
|
|
}));
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
let account_state_recheck_attempted = payload["account_state_recheck_attempted"]
|
|
.as_bool()
|
|
.expect("account_state_recheck_attempted should be bool");
|
|
if account_state_recheck_attempted {
|
|
let account_state_recheck_error = payload["account_state_recheck_error"]
|
|
.as_str()
|
|
.expect("account_state_recheck_error should be string when attempted");
|
|
assert!(
|
|
account_state_recheck_error == "wham/usage API 返回状态码 401"
|
|
|| account_state_recheck_error == "wham/usage API 返回状态码 403"
|
|
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
|
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
payload["account_state_recheck_error"],
|
|
serde_json::Value::Null
|
|
);
|
|
}
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
let seen_execution_runtime_request = seen_execution_runtime
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone()
|
|
.expect("execution runtime request should be captured");
|
|
assert_eq!(
|
|
seen_execution_runtime_request.url,
|
|
"https://chatgpt.com/backend-api/wham/usage"
|
|
);
|
|
assert_eq!(
|
|
seen_execution_runtime_request.authorization,
|
|
"Bearer refreshed-codex-access-token"
|
|
);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-refresh".to_string()])
|
|
.await
|
|
.expect("keys should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("refreshed key should exist");
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&stored_key);
|
|
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
|
if account_state_recheck_attempted
|
|
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 401"
|
|
{
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] Codex Token 已过期 (401)")
|
|
);
|
|
} else if account_state_recheck_attempted
|
|
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 403"
|
|
{
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert!(stored_key
|
|
.oauth_invalid_reason
|
|
.as_deref()
|
|
.is_some_and(|reason| reason.contains("(403)")));
|
|
} else {
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
}
|
|
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
serde_json::Value::String("refreshed-codex-refresh-token".to_string())
|
|
);
|
|
assert_eq!(
|
|
auth_config["email"],
|
|
serde_json::Value::String("[email protected]".to_string())
|
|
);
|
|
let status_snapshot = stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("status snapshot should exist");
|
|
let oauth_snapshot = status_snapshot
|
|
.get("oauth")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("oauth snapshot should exist");
|
|
assert_eq!(
|
|
oauth_snapshot.get("expires_at"),
|
|
auth_config.get("expires_at")
|
|
);
|
|
if stored_key
|
|
.oauth_invalid_reason
|
|
.as_deref()
|
|
.is_some_and(|reason| reason.starts_with("[OAUTH_EXPIRED]"))
|
|
{
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("code")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("expired")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("label")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("已过期")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot.get("reason"),
|
|
Some(&json!("OAuth token has expired"))
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("requires_reauth")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("expiring_soon")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
} else if stored_key.oauth_invalid_reason.is_some() {
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("code")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("invalid")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("label")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("已失效")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("reason")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|reason| !reason.trim().is_empty()),
|
|
true
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("requires_reauth")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("expiring_soon")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("code")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("expiring")
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("label")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("即将过期")
|
|
);
|
|
assert_eq!(oauth_snapshot.get("reason"), Some(&serde_json::Value::Null));
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("requires_reauth")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
oauth_snapshot
|
|
.get("expiring_soon")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(true)
|
|
);
|
|
}
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
token_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_manual_codex_oauth_refresh_reconciles_missing_fixed_endpoint() {
|
|
run_admin_oauth_test(
|
|
"gateway_manual_codex_oauth_refresh_reconciles_missing_fixed_endpoint",
|
|
gateway_manual_codex_oauth_refresh_reconciles_missing_fixed_endpoint_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_manual_codex_oauth_refresh_reconciles_missing_fixed_endpoint_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"access_token": "refreshed-codex-access-token",
|
|
"refresh_token": "refreshed-codex-refresh-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let seen_endpoint_id = Arc::new(Mutex::new(None::<String>));
|
|
let seen_endpoint_id_clone = Arc::clone(&seen_endpoint_id);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |request: Request| {
|
|
let seen_endpoint_id_inner = Arc::clone(&seen_endpoint_id_clone);
|
|
async move {
|
|
let plan: ExecutionPlan = serde_json::from_slice(
|
|
&to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read"),
|
|
)
|
|
.expect("plan should parse");
|
|
*seen_endpoint_id_inner.lock().expect("mutex should lock") =
|
|
Some(plan.endpoint_id.clone());
|
|
let result = aether_contracts::ExecutionResult {
|
|
request_id: plan.request_id,
|
|
candidate_id: None,
|
|
status_code: 200,
|
|
headers: std::collections::BTreeMap::new(),
|
|
response_observation: None,
|
|
body: Some(aether_contracts::ResponseBody {
|
|
json_body: Some(json!({
|
|
"plan_type": "plus",
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": 12.5,
|
|
"window_minutes": 300
|
|
},
|
|
"secondary_window": {
|
|
"used_percent": 55.0,
|
|
"window_minutes": 10080
|
|
}
|
|
}
|
|
})),
|
|
body_bytes_b64: None,
|
|
}),
|
|
telemetry: None,
|
|
error: None,
|
|
};
|
|
(StatusCode::OK, Json(result))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex-missing-endpoint", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let mut key = sample_key(
|
|
"key-codex-missing-endpoint-refresh",
|
|
"provider-codex-missing-endpoint",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
Vec::new(),
|
|
vec![key],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-missing-endpoint-refresh/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "codex");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["account_state_recheck_attempted"], true);
|
|
assert_eq!(
|
|
payload["account_state_recheck_error"],
|
|
serde_json::Value::Null
|
|
);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let endpoints = provider_catalog_repository
|
|
.list_endpoints_by_provider_ids(&["provider-codex-missing-endpoint".to_string()])
|
|
.await
|
|
.expect("endpoints should read");
|
|
let responses_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:responses")
|
|
.expect("openai responses endpoint should be reconciled");
|
|
assert!(endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.api_format == "openai:search"));
|
|
assert_eq!(
|
|
responses_endpoint.base_url,
|
|
"https://chatgpt.com/backend-api/codex"
|
|
);
|
|
assert_eq!(
|
|
*seen_endpoint_id.lock().expect("mutex should lock"),
|
|
Some(responses_endpoint.id.clone())
|
|
);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-missing-endpoint-refresh".to_string()])
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("refreshed key should exist");
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&stored_key);
|
|
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "codex");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"refreshed-codex-refresh-token"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint() {
|
|
run_admin_oauth_test(
|
|
"gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint",
|
|
gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_impl() {
|
|
run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(None, None, true).await;
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance() {
|
|
run_admin_oauth_test(
|
|
"gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance",
|
|
gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance_impl() {
|
|
let mut endpoint = sample_endpoint(
|
|
"endpoint-kiro-disabled-maintenance",
|
|
"provider-kiro-oauth-refresh",
|
|
"claude:messages",
|
|
"https://q.{region}.amazonaws.com",
|
|
);
|
|
endpoint.is_active = false;
|
|
|
|
run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(
|
|
Some(endpoint),
|
|
Some("endpoint-kiro-disabled-maintenance"),
|
|
false,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(
|
|
initial_endpoint: Option<StoredProviderCatalogEndpoint>,
|
|
expected_endpoint_id: Option<&str>,
|
|
expected_endpoint_active: bool,
|
|
) {
|
|
let refreshed_access_token = sample_kiro_device_access_token("[email protected]");
|
|
let expected_access_token = refreshed_access_token.clone();
|
|
let refreshed_refresh_token = "s".repeat(120);
|
|
let expected_refresh_token = refreshed_refresh_token.clone();
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/refreshToken",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
let access_token_inner = refreshed_access_token.clone();
|
|
let refresh_token_inner = refreshed_refresh_token.clone();
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
Json(json!({
|
|
"accessToken": access_token_inner,
|
|
"refreshToken": refresh_token_inner,
|
|
"expiresIn": 3600,
|
|
"profileArn": "arn:aws:kiro:profile/manual-refresh"
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let seen_endpoint_id = Arc::new(Mutex::new(None::<String>));
|
|
let seen_endpoint_id_clone = Arc::clone(&seen_endpoint_id);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |request: Request| {
|
|
let seen_endpoint_id_inner = Arc::clone(&seen_endpoint_id_clone);
|
|
async move {
|
|
let plan: ExecutionPlan = serde_json::from_slice(
|
|
&to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read"),
|
|
)
|
|
.expect("plan should parse");
|
|
*seen_endpoint_id_inner.lock().expect("mutex should lock") =
|
|
Some(plan.endpoint_id.clone());
|
|
let result = aether_contracts::ExecutionResult {
|
|
request_id: plan.request_id,
|
|
candidate_id: None,
|
|
status_code: 200,
|
|
headers: std::collections::BTreeMap::new(),
|
|
response_observation: None,
|
|
body: Some(aether_contracts::ResponseBody {
|
|
json_body: Some(json!({
|
|
"subscriptionInfo": {
|
|
"subscriptionTitle": "KIRO PRO"
|
|
},
|
|
"usageBreakdownList": [{
|
|
"currentUsageWithPrecision": 2.0,
|
|
"usageLimitWithPrecision": 10.0,
|
|
"nextDateReset": 1_900_000_000u64
|
|
}],
|
|
"desktopUserInfo": {
|
|
"email": "[email protected]"
|
|
}
|
|
})),
|
|
body_bytes_b64: None,
|
|
}),
|
|
telemetry: None,
|
|
error: None,
|
|
};
|
|
(StatusCode::OK, Json(result))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-kiro-oauth-refresh", "kiro", 10);
|
|
provider.provider_type = "kiro".to_string();
|
|
let mut key = sample_key(
|
|
"key-kiro-oauth-refresh",
|
|
"provider-kiro-oauth-refresh",
|
|
"claude:messages",
|
|
"stale-kiro-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
&json!({
|
|
"provider_type": "kiro",
|
|
"auth_method": "social",
|
|
"refresh_token": "r".repeat(120),
|
|
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"kiro_version": "1.2.3",
|
|
"expires_at": 1u64
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
initial_endpoint.into_iter().collect(),
|
|
vec![key],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::kiro::KiroOAuthRefreshAdapter::default()
|
|
.with_refresh_base_urls(Some(token_url), None),
|
|
)
|
|
as Arc<dyn crate::provider_transport::oauth_refresh::LocalOAuthRefreshAdapter>,
|
|
]);
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-kiro-oauth-refresh/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "kiro");
|
|
assert_eq!(payload["has_refresh_token"], true);
|
|
assert_eq!(payload["account_state_recheck_attempted"], true);
|
|
assert_eq!(
|
|
payload["account_state_recheck_error"],
|
|
serde_json::Value::Null
|
|
);
|
|
assert!(
|
|
*token_hits.lock().expect("mutex should lock") >= 1,
|
|
"Kiro refresh endpoint should be called"
|
|
);
|
|
|
|
let endpoints = provider_catalog_repository
|
|
.list_endpoints_by_provider_ids(&["provider-kiro-oauth-refresh".to_string()])
|
|
.await
|
|
.expect("endpoints should read");
|
|
assert_eq!(endpoints.len(), 1);
|
|
assert_eq!(endpoints[0].api_format, "claude:messages");
|
|
assert_eq!(endpoints[0].base_url, "https://q.{region}.amazonaws.com");
|
|
assert_eq!(endpoints[0].is_active, expected_endpoint_active);
|
|
if let Some(expected_endpoint_id) = expected_endpoint_id {
|
|
assert_eq!(endpoints[0].id, expected_endpoint_id);
|
|
}
|
|
assert_eq!(
|
|
*seen_endpoint_id.lock().expect("mutex should lock"),
|
|
Some(endpoints[0].id.clone())
|
|
);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-kiro-oauth-refresh".to_string()])
|
|
.await
|
|
.expect("keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("refreshed key should exist");
|
|
let decrypted_api_key = decrypt_persisted_provider_api_key(&stored_key);
|
|
assert_eq!(decrypted_api_key, expected_access_token);
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["provider_type"], "kiro");
|
|
assert_eq!(auth_config["refresh_token"], expected_refresh_token);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_marks_manual_oauth_refresh_failures_as_invalid_in_pool_payload() {
|
|
run_admin_oauth_test(
|
|
"gateway_marks_manual_oauth_refresh_failures_as_invalid_in_pool_payload",
|
|
gateway_marks_manual_oauth_refresh_failures_as_invalid_in_pool_payload_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_marks_manual_oauth_refresh_failures_as_invalid_in_pool_payload_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({
|
|
"error": {
|
|
"message": "Your refresh token has already been used to generate a new access token. Please try signing in again.",
|
|
"type": "invalid_request_error",
|
|
"param": serde_json::Value::Null,
|
|
"code": "refresh_token_reused"
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"pool_advanced": {
|
|
"enabled": true
|
|
}
|
|
})),
|
|
);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh-invalid",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.expires_at_unix_secs = Some(4_102_444_800);
|
|
key.status_snapshot = Some(json!({
|
|
"oauth": {
|
|
"code": "valid",
|
|
"label": "有效",
|
|
"reason": serde_json::Value::Null,
|
|
"expires_at": 4_102_444_800u64,
|
|
"invalid_at": serde_json::Value::Null,
|
|
"source": "expires_at",
|
|
"requires_reauth": false,
|
|
"expiring_soon": false
|
|
},
|
|
"account": {
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"blocked": false,
|
|
"source": serde_json::Value::Null,
|
|
"recoverable": false
|
|
},
|
|
"quota": {
|
|
"code": "unknown",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"exhausted": false,
|
|
"usage_ratio": serde_json::Value::Null,
|
|
"updated_at": serde_json::Value::Null,
|
|
"reset_seconds": serde_json::Value::Null,
|
|
"plan_type": serde_json::Value::Null
|
|
}
|
|
}));
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"used-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":4102444800}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let refresh_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh-invalid/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("refresh request should succeed");
|
|
|
|
assert_eq!(refresh_response.status(), StatusCode::BAD_REQUEST);
|
|
let refresh_payload: serde_json::Value = refresh_response
|
|
.json()
|
|
.await
|
|
.expect("refresh payload should parse");
|
|
assert_eq!(
|
|
refresh_payload["detail"],
|
|
json!("Token 刷新失败:refresh_token 已被使用并轮换,请重新登录授权")
|
|
);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let pool_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("pool request should succeed");
|
|
|
|
assert_eq!(pool_response.status(), StatusCode::OK);
|
|
let pool_payload: serde_json::Value = pool_response
|
|
.json()
|
|
.await
|
|
.expect("pool payload should parse");
|
|
let keys = pool_payload["keys"]
|
|
.as_array()
|
|
.expect("keys should be array");
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(
|
|
keys[0]["oauth_invalid_reason"],
|
|
json!("[REFRESH_FAILED] OAuth token refresh failed")
|
|
);
|
|
assert_eq!(
|
|
keys[0]["status_snapshot"]["oauth"]["code"],
|
|
json!("reauth_required")
|
|
);
|
|
assert_eq!(
|
|
keys[0]["status_snapshot"]["oauth"]["reason"],
|
|
json!("OAuth token refresh failed")
|
|
);
|
|
assert_eq!(
|
|
keys[0]["status_snapshot"]["oauth"]["source"],
|
|
json!("oauth_refresh")
|
|
);
|
|
assert_eq!(
|
|
keys[0]["status_snapshot"]["oauth"]["requires_reauth"],
|
|
json!(true)
|
|
);
|
|
assert_eq!(
|
|
keys[0]["status_snapshot"]["oauth"]["usable_until_expiry"],
|
|
json!(true)
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_expiry() {
|
|
run_admin_oauth_test(
|
|
"gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_expiry",
|
|
gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_expiry_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_expiry_impl() {
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let token_server = Router::new().route(
|
|
"/oauth/token",
|
|
post(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({
|
|
"error": {
|
|
"message": "Could not validate your refresh token. Please try signing in again.",
|
|
"type": "invalid_request_error",
|
|
"param": serde_json::Value::Null,
|
|
"code": "refresh_token_expired"
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"pool_advanced": {
|
|
"enabled": true,
|
|
"auto_remove_banned_keys": true
|
|
}
|
|
})),
|
|
);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh-expired",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"expired-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.expires_at_unix_secs = Some(1);
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"expired-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
let (token_url, token_handle) = start_server(token_server).await;
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
|
),
|
|
]);
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let refresh_response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh-expired/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("refresh request should succeed");
|
|
|
|
assert_eq!(refresh_response.status(), StatusCode::OK);
|
|
let refresh_payload: serde_json::Value = refresh_response
|
|
.json()
|
|
.await
|
|
.expect("refresh payload should parse");
|
|
assert_eq!(refresh_payload["status"], json!("auto_removed"));
|
|
assert_eq!(refresh_payload["message"], json!("已自动删除"));
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-refresh-expired".to_string()])
|
|
.await
|
|
.expect("keys should read");
|
|
assert!(keys.is_empty());
|
|
|
|
gateway_handle.abort();
|
|
token_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_provider_proxy_before_system_proxy(
|
|
) {
|
|
run_admin_oauth_test(
|
|
"gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_provider_proxy_before_system_proxy",
|
|
gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_provider_proxy_before_system_proxy_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_provider_proxy_before_system_proxy_impl(
|
|
) {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "refreshed-codex-access-token",
|
|
"refresh_token": "refreshed-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.proxy = Some(json!({"node_id":"proxy-node-provider","enabled":true}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh-provider",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
let mut provider_node = sample_proxy_node("proxy-node-provider");
|
|
provider_node.status = "online".to_string();
|
|
provider_node.is_manual = true;
|
|
provider_node.tunnel_mode = false;
|
|
provider_node.tunnel_connected = false;
|
|
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
|
let mut system_node = sample_proxy_node("proxy-node-system");
|
|
system_node.status = "online".to_string();
|
|
system_node.is_manual = true;
|
|
system_node.tunnel_mode = false;
|
|
system_node.tunnel_connected = false;
|
|
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
|
provider_node,
|
|
system_node,
|
|
]));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"system_proxy_node_id".to_string(),
|
|
json!("proxy-node-system"),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh-provider/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let refresh_plan = plans
|
|
.iter()
|
|
.find(|plan| plan.request_id == "provider-oauth:local-refresh-token")
|
|
.expect("local refresh plan should exist");
|
|
assert_eq!(
|
|
refresh_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-provider")
|
|
);
|
|
assert_eq!(
|
|
refresh_plan
|
|
.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_refreshes_admin_provider_oauth_key_tunnel_proxy_with_direct_refresh_controls() {
|
|
run_admin_oauth_test(
|
|
"gateway_refreshes_admin_provider_oauth_key_tunnel_proxy_with_direct_refresh_controls",
|
|
gateway_refreshes_admin_provider_oauth_key_tunnel_proxy_with_direct_refresh_controls_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_refreshes_admin_provider_oauth_key_tunnel_proxy_with_direct_refresh_controls_impl()
|
|
{
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "refreshed-codex-access-token",
|
|
"refresh_token": "refreshed-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.proxy = Some(json!({
|
|
"mode": "tunnel",
|
|
"node_id": "proxy-node-tunnel",
|
|
"enabled": true,
|
|
"tunnel_base_url": "http://gateway-owner.internal"
|
|
}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh-tunnel",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
let mut tunnel_node = sample_proxy_node("proxy-node-tunnel");
|
|
tunnel_node.status = "online".to_string();
|
|
tunnel_node.tunnel_mode = true;
|
|
tunnel_node.tunnel_connected = true;
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![tunnel_node]));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"tunnel.attachments.proxy-node-tunnel".to_string(),
|
|
json!({
|
|
"gateway_instance_id": "gateway-owner",
|
|
"relay_base_url": "http://gateway-owner.internal",
|
|
"tunnel_generation": "test-generation-1",
|
|
"conn_count": 1,
|
|
"observed_at_unix_secs": 4_102_444_800u64,
|
|
}),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh-tunnel/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let refresh_plan = plans
|
|
.iter()
|
|
.find(|plan| plan.request_id == "provider-oauth:local-refresh-token")
|
|
.expect("local refresh plan should exist");
|
|
assert_eq!(
|
|
refresh_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.mode.as_deref()),
|
|
Some("tunnel")
|
|
);
|
|
assert_eq!(
|
|
refresh_plan
|
|
.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
assert_eq!(
|
|
refresh_plan
|
|
.headers
|
|
.get(EXECUTION_REQUEST_HTTP1_ONLY_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_consecutive_manual_oauth_refresh_uses_rotated_refresh_token() {
|
|
run_admin_oauth_test(
|
|
"gateway_consecutive_manual_oauth_refresh_uses_rotated_refresh_token",
|
|
gateway_consecutive_manual_oauth_refresh_uses_rotated_refresh_token_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_consecutive_manual_oauth_refresh_uses_rotated_refresh_token_impl() {
|
|
let refresh_request_bodies = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
let refresh_request_bodies_clone = Arc::clone(&refresh_request_bodies);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let refresh_request_bodies_inner = Arc::clone(&refresh_request_bodies_clone);
|
|
async move {
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
use base64::Engine as _;
|
|
|
|
let body_text = plan
|
|
.body
|
|
.body_bytes_b64
|
|
.as_deref()
|
|
.and_then(|body| {
|
|
base64::engine::general_purpose::STANDARD.decode(body).ok()
|
|
})
|
|
.and_then(|body| String::from_utf8(body).ok())
|
|
.unwrap_or_default();
|
|
refresh_request_bodies_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(body_text.clone());
|
|
|
|
let (access_token, refresh_token) =
|
|
if body_text.contains("refresh_token=old-codex-refresh-token") {
|
|
(
|
|
"refreshed-codex-access-token",
|
|
"rotated-codex-refresh-token",
|
|
)
|
|
} else if body_text.contains("refresh_token=rotated-codex-refresh-token") {
|
|
(
|
|
"refreshed-codex-access-token-2",
|
|
"rotated-codex-refresh-token-2",
|
|
)
|
|
} else {
|
|
return Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 401,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"error": {
|
|
"message": "unexpected refresh token"
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
};
|
|
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-consecutive-refresh",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
for _ in 0..2 {
|
|
let response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-consecutive-refresh/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
let bodies = refresh_request_bodies
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone();
|
|
assert_eq!(bodies.len(), 2);
|
|
assert!(
|
|
bodies[0].contains("refresh_token=old-codex-refresh-token"),
|
|
"unexpected first refresh body: {}",
|
|
bodies[0]
|
|
);
|
|
assert!(
|
|
bodies[1].contains("refresh_token=rotated-codex-refresh-token"),
|
|
"unexpected second refresh body: {}",
|
|
bodies[1]
|
|
);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-consecutive-refresh".to_string()])
|
|
.await
|
|
.expect("keys should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("refreshed key should exist");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(
|
|
auth_config["refresh_token"],
|
|
"rotated-codex-refresh-token-2"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_concurrent_manual_oauth_refresh_reuses_winner_after_lock_wait() {
|
|
run_admin_oauth_test(
|
|
"gateway_concurrent_manual_oauth_refresh_reuses_winner_after_lock_wait",
|
|
gateway_concurrent_manual_oauth_refresh_reuses_winner_after_lock_wait_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_concurrent_manual_oauth_refresh_reuses_winner_after_lock_wait_impl() {
|
|
let refresh_request_bodies = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
let refresh_request_bodies_clone = Arc::clone(&refresh_request_bodies);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let refresh_request_bodies_inner = Arc::clone(&refresh_request_bodies_clone);
|
|
async move {
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
use base64::Engine as _;
|
|
|
|
let body_text = plan
|
|
.body
|
|
.body_bytes_b64
|
|
.as_deref()
|
|
.and_then(|body| {
|
|
base64::engine::general_purpose::STANDARD.decode(body).ok()
|
|
})
|
|
.and_then(|body| String::from_utf8(body).ok())
|
|
.unwrap_or_default();
|
|
refresh_request_bodies_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(body_text.clone());
|
|
|
|
let (access_token, refresh_token, delay_ms) =
|
|
if body_text.contains("refresh_token=old-codex-refresh-token") {
|
|
(
|
|
"refreshed-codex-access-token",
|
|
"rotated-codex-refresh-token",
|
|
200u64,
|
|
)
|
|
} else if body_text.contains("refresh_token=rotated-codex-refresh-token") {
|
|
(
|
|
"refreshed-codex-access-token-2",
|
|
"rotated-codex-refresh-token-2",
|
|
0u64,
|
|
)
|
|
} else {
|
|
return Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 401,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"error": {
|
|
"message": "Could not validate your refresh token. Please try signing in again."
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
};
|
|
|
|
if delay_ms > 0 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
|
}
|
|
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.proxy = Some(json!({"node_id":"proxy-node-provider","enabled":true}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-concurrent-refresh",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.proxy = Some(json!({"node_id":"proxy-node-key","enabled":true}));
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
let mut key_node = sample_proxy_node("proxy-node-key");
|
|
key_node.status = "online".to_string();
|
|
key_node.is_manual = true;
|
|
key_node.tunnel_mode = false;
|
|
key_node.tunnel_connected = false;
|
|
key_node.proxy_url = Some("http://proxy-key.example:8080".to_string());
|
|
let mut provider_node = sample_proxy_node("proxy-node-provider");
|
|
provider_node.status = "online".to_string();
|
|
provider_node.is_manual = true;
|
|
provider_node.tunnel_mode = false;
|
|
provider_node.tunnel_connected = false;
|
|
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
|
key_node,
|
|
provider_node,
|
|
]));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
let refresh_url = format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-concurrent-refresh/refresh"
|
|
);
|
|
|
|
let request_a = client
|
|
.post(refresh_url.clone())
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send();
|
|
let request_b = client
|
|
.post(refresh_url)
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send();
|
|
let (response_a, response_b) = tokio::join!(request_a, request_b);
|
|
let response_a = response_a.expect("first request should succeed");
|
|
let response_b = response_b.expect("second request should succeed");
|
|
|
|
assert_eq!(response_a.status(), StatusCode::OK);
|
|
assert_eq!(response_b.status(), StatusCode::OK);
|
|
|
|
let bodies = refresh_request_bodies
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone();
|
|
assert_eq!(bodies.len(), 1);
|
|
assert!(
|
|
bodies[0].contains("refresh_token=old-codex-refresh-token"),
|
|
"unexpected first refresh body: {}",
|
|
bodies[0]
|
|
);
|
|
|
|
let stored_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-concurrent-refresh".to_string()])
|
|
.await
|
|
.expect("keys should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("refreshed key should exist");
|
|
let decrypted_auth_config = decrypt_persisted_provider_auth_config(&stored_key);
|
|
let auth_config: serde_json::Value =
|
|
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
|
assert_eq!(auth_config["refresh_token"], "rotated-codex-refresh-token");
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_manual_oauth_refresh_prefers_fresher_transport_auth_config_over_stale_runtime_cache() {
|
|
run_admin_oauth_test(
|
|
"gateway_manual_oauth_refresh_prefers_fresher_transport_auth_config_over_stale_runtime_cache",
|
|
gateway_manual_oauth_refresh_prefers_fresher_transport_auth_config_over_stale_runtime_cache_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_manual_oauth_refresh_prefers_fresher_transport_auth_config_over_stale_runtime_cache_impl(
|
|
) {
|
|
let refresh_request_bodies = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
let refresh_request_bodies_clone = Arc::clone(&refresh_request_bodies);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let refresh_request_bodies_inner = Arc::clone(&refresh_request_bodies_clone);
|
|
async move {
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
use base64::Engine as _;
|
|
|
|
let body_text = plan
|
|
.body
|
|
.body_bytes_b64
|
|
.as_deref()
|
|
.and_then(|body| {
|
|
base64::engine::general_purpose::STANDARD.decode(body).ok()
|
|
})
|
|
.and_then(|body| String::from_utf8(body).ok())
|
|
.unwrap_or_default();
|
|
refresh_request_bodies_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(body_text.clone());
|
|
|
|
if body_text.contains("refresh_token=old-codex-refresh-token") {
|
|
return Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "cached-codex-access-token",
|
|
"refresh_token": "cached-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
if body_text.contains("refresh_token=fresh-codex-refresh-token") {
|
|
return Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "fresh-codex-access-token-2",
|
|
"refresh_token": "fresh-codex-refresh-token-2",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 401,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"error": {
|
|
"message": "Could not validate your refresh token. Please try signing in again."
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-stale-cache",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1,"updated_at":1700000001}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let app_state = build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh);
|
|
|
|
let stale_transport = app_state
|
|
.read_provider_transport_snapshot(
|
|
"provider-codex",
|
|
"endpoint-codex-cli",
|
|
"key-codex-oauth-stale-cache",
|
|
)
|
|
.await
|
|
.expect("transport should load")
|
|
.expect("transport should exist");
|
|
let cached_entry = app_state
|
|
.force_local_oauth_refresh_entry(&stale_transport)
|
|
.await
|
|
.expect("initial refresh should succeed")
|
|
.expect("initial refresh should return cached entry");
|
|
assert_eq!(
|
|
cached_entry.auth_header_value,
|
|
"Bearer cached-codex-access-token"
|
|
);
|
|
|
|
let fresh_auth_config = app_state
|
|
.seal_provider_catalog_key_auth_config(
|
|
"provider-codex",
|
|
"key-codex-oauth-stale-cache",
|
|
r#"{"provider_type":"codex","refresh_token":"fresh-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1,"updated_at":4102444810}"#,
|
|
)
|
|
.expect("updated auth config ciphertext should build");
|
|
let remotely_updated_key = provider_catalog_repository
|
|
.list_keys_by_ids(&["key-codex-oauth-stale-cache".to_string()])
|
|
.await
|
|
.expect("provider key should reload")
|
|
.pop()
|
|
.expect("provider key should exist");
|
|
let expected_encrypted_api_key = remotely_updated_key.encrypted_api_key.clone();
|
|
let expected_encrypted_auth_config = remotely_updated_key.encrypted_auth_config.clone();
|
|
assert!(provider_catalog_repository
|
|
.compare_and_swap_key_credentials(&ProviderCatalogKeyCredentialsCasUpdate {
|
|
key_id: remotely_updated_key.id.clone(),
|
|
expected_provider_id: remotely_updated_key.provider_id.clone(),
|
|
expected_encrypted_api_key,
|
|
expected_encrypted_auth_config,
|
|
encrypted_api_key: remotely_updated_key.encrypted_api_key.clone(),
|
|
encrypted_auth_config: Some(fresh_auth_config),
|
|
})
|
|
.await
|
|
.expect("OAuth credential should update remotely"));
|
|
|
|
let gateway = build_router_with_state(app_state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-stale-cache/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let bodies = refresh_request_bodies
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.clone();
|
|
assert_eq!(bodies.len(), 2);
|
|
assert!(
|
|
bodies[0].contains("refresh_token=old-codex-refresh-token"),
|
|
"unexpected first refresh body: {}",
|
|
bodies[0]
|
|
);
|
|
assert!(
|
|
bodies[1].contains("refresh_token=fresh-codex-refresh-token"),
|
|
"unexpected second refresh body: {}",
|
|
bodies[1]
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy(
|
|
) {
|
|
run_admin_oauth_test(
|
|
"gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy",
|
|
gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy_impl(
|
|
) {
|
|
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
|
let execution_plans_clone = Arc::clone(&execution_plans);
|
|
let execution_runtime = Router::new().route(
|
|
"/v1/execute/sync",
|
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
|
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
|
async move {
|
|
execution_plans_inner
|
|
.lock()
|
|
.expect("mutex should lock")
|
|
.push(plan.clone());
|
|
if plan.request_id == "provider-oauth:local-refresh-token" {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"access_token": "refreshed-codex-access-token",
|
|
"refresh_token": "refreshed-codex-refresh-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 1800,
|
|
"scope": "openid email profile offline_access",
|
|
"email": "[email protected]",
|
|
"account_id": "acct-codex-123",
|
|
"plan_type": "plus"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
provider.proxy = Some(json!({"node_id":"proxy-node-provider","enabled":true}));
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-oauth-refresh",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"stale-codex-access-token",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.proxy = Some(json!({"node_id":"proxy-node-key","enabled":true}));
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
|
)
|
|
.expect("auth config ciphertext should build"),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
let mut key_node = sample_proxy_node("proxy-node-key");
|
|
key_node.status = "online".to_string();
|
|
key_node.is_manual = true;
|
|
key_node.tunnel_mode = false;
|
|
key_node.tunnel_connected = false;
|
|
key_node.proxy_url = Some("http://proxy-key.example:8080".to_string());
|
|
let mut provider_node = sample_proxy_node("proxy-node-provider");
|
|
provider_node.status = "online".to_string();
|
|
provider_node.is_manual = true;
|
|
provider_node.tunnel_mode = false;
|
|
provider_node.tunnel_connected = false;
|
|
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
|
let mut system_node = sample_proxy_node("proxy-node-system");
|
|
system_node.status = "online".to_string();
|
|
system_node.is_manual = true;
|
|
system_node.tunnel_mode = false;
|
|
system_node.tunnel_connected = false;
|
|
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
|
key_node,
|
|
provider_node,
|
|
system_node,
|
|
]));
|
|
|
|
let oauth_refresh =
|
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
|
Arc::new(
|
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
|
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
|
),
|
|
]);
|
|
|
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
|
let gateway = build_router_with_state(
|
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
|
.with_system_config_values_for_tests(vec![(
|
|
"system_proxy_node_id".to_string(),
|
|
json!("proxy-node-system"),
|
|
)])
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
let refresh_plan = plans
|
|
.iter()
|
|
.find(|plan| plan.request_id == "provider-oauth:local-refresh-token")
|
|
.expect("local refresh plan should exist");
|
|
assert_eq!(
|
|
refresh_plan
|
|
.proxy
|
|
.as_ref()
|
|
.and_then(|proxy| proxy.node_id.as_deref()),
|
|
Some("proxy-node-key")
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_provider_oauth_unavailable_routes_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_provider_oauth_unavailable_routes_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_provider_oauth_unavailable_routes_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_provider_oauth_unavailable_routes_locally_with_trusted_admin_principal_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
for path in [
|
|
"/api/admin/provider-oauth/providers/provider-123/import-refresh-token",
|
|
"/api/admin/provider-oauth/providers/provider-123/cookie-authorize/tasks",
|
|
"/api/admin/provider-oauth/providers/provider-123/batch-import",
|
|
"/api/admin/provider-oauth/providers/provider-123/batch-import/tasks",
|
|
"/api/admin/provider-oauth/providers/provider-123/device-authorize",
|
|
"/api/admin/provider-oauth/providers/provider-123/device-poll",
|
|
] {
|
|
let response = client
|
|
.post(format!("{gateway_url}{path}"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], "Admin provider OAuth data unavailable");
|
|
}
|
|
|
|
let refresh_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-123/refresh"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(refresh_response.status(), StatusCode::NOT_FOUND);
|
|
let refresh_payload: serde_json::Value = refresh_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(refresh_payload["detail"], "Key 不存在");
|
|
|
|
let complete_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/keys/key-123/complete"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(complete_response.status(), StatusCode::BAD_REQUEST);
|
|
let complete_payload: serde_json::Value = complete_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(complete_payload["detail"], "请求体必须是合法的 JSON 对象");
|
|
|
|
let provider_complete_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-123/complete"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(provider_complete_response.status(), StatusCode::BAD_REQUEST);
|
|
let provider_complete_payload: serde_json::Value = provider_complete_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(
|
|
provider_complete_payload["detail"],
|
|
"请求体必须是合法的 JSON 对象"
|
|
);
|
|
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_oauth_supported_types_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/supported-types",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/oauth/supported-types"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0]["provider_type"], "linuxdo");
|
|
assert_eq!(items[0]["display_name"], "Linux Do");
|
|
assert_eq!(items[1]["provider_type"], "custom_oidc");
|
|
assert_eq!(items[1]["display_name"], "Custom OIDC");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_oauth_provider_list_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_oauth_provider_list_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_oauth_provider_list_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_oauth_provider_list_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::seed(vec![
|
|
sample_oauth_provider_config("linuxdo"),
|
|
]));
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/oauth/providers"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["provider_type"], "linuxdo");
|
|
assert_eq!(items[0]["has_secret"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_principal",
|
|
gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_upserts_admin_oauth_provider_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/linuxdo",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository.clone(),
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{gateway_url}/api/admin/oauth/providers/linuxdo"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"display_name": "Linux Do",
|
|
"client_id": "client-id",
|
|
"client_secret": "secret-value",
|
|
"authorization_url_override": "https://connect.linux.do/oauth2/authorize",
|
|
"token_url_override": "https://connect.linux.do/oauth2/token",
|
|
"userinfo_url_override": "https://connect.linux.do/api/user",
|
|
"scopes": ["openid", "profile"],
|
|
"redirect_uri": "https://backend.example.com/oauth/callback",
|
|
"frontend_callback_url": "https://frontend.example.com/auth/callback",
|
|
"attribute_mapping": {"email": "email"},
|
|
"extra_config": {"team": true},
|
|
"is_enabled": true,
|
|
"force": false
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "linuxdo");
|
|
assert_eq!(payload["has_secret"], true);
|
|
assert_eq!(payload["is_enabled"], true);
|
|
let stored = repository
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("lookup should succeed")
|
|
.expect("provider should exist");
|
|
assert!(stored.client_secret_encrypted.is_some());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_requires_oauth_admin_management_token_to_configure_frontend_callback() {
|
|
run_admin_oauth_test(
|
|
"gateway_requires_oauth_admin_management_token_to_configure_frontend_callback",
|
|
gateway_requires_oauth_admin_management_token_to_configure_frontend_callback_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_requires_oauth_admin_management_token_to_configure_frontend_callback_impl() {
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"oauth-callback-admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
|
|
let write_raw_token = "ae-oauth-callback-write";
|
|
let mut write_token = sample_management_token(
|
|
"token-oauth-callback-write",
|
|
&admin_user.id,
|
|
"oauth-callback-write",
|
|
true,
|
|
);
|
|
write_token.token.allowed_ips = None;
|
|
write_token.token.permissions = Some(json!(["admin:oauth:write"]));
|
|
|
|
let admin_raw_token = "ae-oauth-callback-admin";
|
|
let mut oauth_admin_token = sample_management_token(
|
|
"token-oauth-callback-admin",
|
|
&admin_user.id,
|
|
"oauth-callback-admin",
|
|
true,
|
|
);
|
|
oauth_admin_token.token.allowed_ips = None;
|
|
oauth_admin_token.token.permissions = Some(json!(["admin:oauth:admin"]));
|
|
|
|
let management_tokens = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
|
vec![write_token, oauth_admin_token],
|
|
vec![
|
|
(
|
|
hash_management_token(write_raw_token),
|
|
"token-oauth-callback-write".to_string(),
|
|
),
|
|
(
|
|
hash_management_token(admin_raw_token),
|
|
"token-oauth-callback-admin".to_string(),
|
|
),
|
|
],
|
|
));
|
|
let oauth_providers = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let data = GatewayDataState::with_management_token_repository_for_tests(management_tokens)
|
|
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_providers));
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(data));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
let provider_payload = |frontend_callback_url: &str| {
|
|
json!({
|
|
"display_name": "Linux Do",
|
|
"client_id": "client-id",
|
|
"authorization_url_override": "https://connect.linux.do/oauth2/authorize",
|
|
"token_url_override": "https://connect.linux.do/oauth2/token",
|
|
"userinfo_url_override": "https://connect.linux.do/api/user",
|
|
"scopes": ["openid", "profile"],
|
|
"redirect_uri": "https://backend.example.com/oauth/callback",
|
|
"frontend_callback_url": frontend_callback_url,
|
|
"attribute_mapping": {"email": "email"},
|
|
"extra_config": {"team": true},
|
|
"is_enabled": true,
|
|
"force": false
|
|
})
|
|
};
|
|
let provider_url = format!("{gateway_url}/api/admin/oauth/providers/linuxdo");
|
|
|
|
let denied_create = client
|
|
.put(&provider_url)
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(write_raw_token)
|
|
.json(&provider_payload("https://attacker.example/auth/callback"))
|
|
.send()
|
|
.await
|
|
.expect("write-token request should complete");
|
|
let denied_create_status = denied_create.status();
|
|
let denied_create_payload: Value = denied_create.json().await.expect("denial should be JSON");
|
|
assert_eq!(denied_create_status, StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
denied_create_payload["required_permission"],
|
|
"admin:oauth:admin"
|
|
);
|
|
assert!(oauth_providers
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("provider lookup should succeed")
|
|
.is_none());
|
|
|
|
let allowed_create = client
|
|
.put(&provider_url)
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(admin_raw_token)
|
|
.json(&provider_payload(
|
|
"https://configured.example/auth/callback",
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("oauth-admin request should complete");
|
|
let allowed_create_status = allowed_create.status();
|
|
let allowed_create_body = allowed_create.text().await.expect("body should read");
|
|
assert_eq!(
|
|
allowed_create_status,
|
|
StatusCode::OK,
|
|
"body={allowed_create_body}"
|
|
);
|
|
|
|
let denied_change = client
|
|
.put(&provider_url)
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(write_raw_token)
|
|
.json(&provider_payload(
|
|
"https://second-attacker.example/auth/callback",
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("write-token change request should complete");
|
|
assert_eq!(denied_change.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
oauth_providers
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("provider lookup should succeed")
|
|
.expect("provider should exist")
|
|
.frontend_callback_url,
|
|
"https://configured.example/auth/callback"
|
|
);
|
|
|
|
let allowed_session_change = client
|
|
.put(&provider_url)
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(
|
|
TRUSTED_ADMIN_SESSION_ID_HEADER,
|
|
"session-oauth-callback-admin",
|
|
)
|
|
.json(&provider_payload(
|
|
"https://frontend.example.com/auth/callback",
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("admin session request should complete");
|
|
let allowed_session_status = allowed_session_change.status();
|
|
let allowed_session_body = allowed_session_change
|
|
.text()
|
|
.await
|
|
.expect("body should read");
|
|
assert_eq!(
|
|
allowed_session_status,
|
|
StatusCode::OK,
|
|
"body={allowed_session_body}"
|
|
);
|
|
assert_eq!(
|
|
oauth_providers
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("provider lookup should succeed")
|
|
.expect("provider should exist")
|
|
.frontend_callback_url,
|
|
"https://frontend.example.com/auth/callback"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_custom_oidc_without_allowed_domains() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_custom_oidc_without_allowed_domains",
|
|
gateway_rejects_custom_oidc_without_allowed_domains_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_custom_oidc_without_allowed_domains_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/custom_oidc",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/oauth/providers/custom_oidc"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"display_name": "Custom OIDC",
|
|
"client_id": "custom-client",
|
|
"authorization_url_override": "https://idp.example.com/oauth/authorize",
|
|
"token_url_override": "https://idp.example.com/oauth/token",
|
|
"userinfo_url_override": "https://idp.example.com/oauth/userinfo",
|
|
"scopes": ["openid", "profile", "email"],
|
|
"redirect_uri": "https://backend.example.com/oauth/callback",
|
|
"frontend_callback_url": "https://frontend.example.com/auth/callback",
|
|
"attribute_mapping": {"sub": "sub", "email": "email"},
|
|
"extra_config": {},
|
|
"is_enabled": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(
|
|
payload["error"]["message"],
|
|
"custom_oidc 必须在 extra_config.allowed_domains 配置域名白名单"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_upserts_custom_oidc_with_allowed_domains() {
|
|
run_admin_oauth_test(
|
|
"gateway_upserts_custom_oidc_with_allowed_domains",
|
|
gateway_upserts_custom_oidc_with_allowed_domains_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_upserts_custom_oidc_with_allowed_domains_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/custom_oidc",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository.clone(),
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/oauth/providers/custom_oidc"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"display_name": "Custom OIDC",
|
|
"client_id": "custom-client",
|
|
"authorization_url_override": "https://idp.example.com/oauth/authorize",
|
|
"token_url_override": "https://idp.example.com/oauth/token",
|
|
"userinfo_url_override": "https://idp.example.com/oauth/userinfo",
|
|
"scopes": ["openid", "profile", "email"],
|
|
"redirect_uri": "https://backend.example.com/oauth/callback",
|
|
"frontend_callback_url": "https://frontend.example.com/auth/callback",
|
|
"attribute_mapping": {"sub": "id", "email": "profile.email"},
|
|
"extra_config": {"allowed_domains": ["idp.example.com"]},
|
|
"is_enabled": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], "custom_oidc");
|
|
assert_eq!(payload["is_enabled"], true);
|
|
|
|
let stored = repository
|
|
.get_oauth_provider_config("custom_oidc")
|
|
.await
|
|
.expect("lookup should succeed")
|
|
.expect("provider should exist");
|
|
assert_eq!(
|
|
stored.authorization_url_override.as_deref(),
|
|
Some("https://idp.example.com/oauth/authorize")
|
|
);
|
|
assert_eq!(
|
|
stored
|
|
.extra_config
|
|
.as_ref()
|
|
.and_then(|value| {
|
|
value
|
|
.get("allowed_domains")
|
|
.and_then(serde_json::Value::as_array)
|
|
})
|
|
.map(Vec::len),
|
|
Some(1)
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_upserts_multiple_custom_oidc_configs() {
|
|
run_admin_oauth_test(
|
|
"gateway_upserts_multiple_custom_oidc_configs",
|
|
gateway_upserts_multiple_custom_oidc_configs_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_upserts_multiple_custom_oidc_configs_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository.clone(),
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
for (provider_type, display_name, host) in [
|
|
("custom_oidc_work", "Work OIDC", "work-idp.example.com"),
|
|
(
|
|
"custom_oidc_personal",
|
|
"Personal OIDC",
|
|
"personal-idp.example.com",
|
|
),
|
|
] {
|
|
let response = reqwest::Client::new()
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/oauth/providers/{provider_type}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"display_name": display_name,
|
|
"client_id": format!("{provider_type}-client"),
|
|
"authorization_url_override": format!("https://{host}/oauth/authorize"),
|
|
"token_url_override": format!("https://{host}/oauth/token"),
|
|
"userinfo_url_override": format!("https://{host}/oauth/userinfo"),
|
|
"scopes": ["openid", "profile", "email"],
|
|
"redirect_uri": format!("https://backend.example.com/api/oauth/{provider_type}/callback"),
|
|
"frontend_callback_url": "https://frontend.example.com/auth/callback",
|
|
"attribute_mapping": {"sub": "id", "email": "profile.email"},
|
|
"extra_config": {"allowed_domains": [host]},
|
|
"is_enabled": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_type"], provider_type);
|
|
assert_eq!(payload["display_name"], display_name);
|
|
}
|
|
|
|
let stored = repository
|
|
.list_oauth_provider_configs()
|
|
.await
|
|
.expect("list should succeed");
|
|
assert_eq!(stored.len(), 2);
|
|
assert!(stored
|
|
.iter()
|
|
.any(|provider| provider.provider_type == "custom_oidc_work"));
|
|
assert!(stored
|
|
.iter()
|
|
.any(|provider| provider.provider_type == "custom_oidc_personal"));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_blocks_admin_oauth_test_ssrf_to_private_endpoints() {
|
|
run_admin_oauth_test(
|
|
"gateway_blocks_admin_oauth_test_ssrf_to_private_endpoints",
|
|
gateway_blocks_admin_oauth_test_ssrf_to_private_endpoints_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_blocks_admin_oauth_test_ssrf_to_private_endpoints_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/linuxdo/test",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let authorization_hits = Arc::new(Mutex::new(0usize));
|
|
let authorization_hits_clone = Arc::clone(&authorization_hits);
|
|
let token_hits = Arc::new(Mutex::new(0usize));
|
|
let token_hits_clone = Arc::clone(&token_hits);
|
|
let oauth_endpoints = Router::new()
|
|
.route(
|
|
"/oauth2/authorize",
|
|
any(move |_request: Request| {
|
|
let authorization_hits_inner = Arc::clone(&authorization_hits_clone);
|
|
async move {
|
|
*authorization_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::BAD_REQUEST, Body::from("missing query"))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/oauth2/token",
|
|
any(move |_request: Request| {
|
|
let token_hits_inner = Arc::clone(&token_hits_clone);
|
|
async move {
|
|
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::METHOD_NOT_ALLOWED,
|
|
Body::from("method not allowed"),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::seed(vec![
|
|
sample_oauth_provider_config("linuxdo"),
|
|
]));
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let (oauth_url, oauth_handle) = start_server(oauth_endpoints).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/oauth/providers/linuxdo/test"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"client_id": "client-id",
|
|
"authorization_url_override": format!("{oauth_url}/oauth2/authorize"),
|
|
"token_url_override": format!("{oauth_url}/oauth2/token"),
|
|
"redirect_uri": "http://localhost:8084/api/oauth/linuxdo/callback"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["authorization_url_reachable"], false);
|
|
assert_eq!(payload["token_url_reachable"], false);
|
|
assert_eq!(payload["secret_status"], "configured");
|
|
assert_eq!(*authorization_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
oauth_handle.abort();
|
|
let _ = upstream_url;
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_tests_admin_oauth_linuxdo_reports_invalid_endpoint_urls() {
|
|
run_admin_oauth_test(
|
|
"gateway_tests_admin_oauth_linuxdo_reports_invalid_endpoint_urls",
|
|
gateway_tests_admin_oauth_linuxdo_reports_invalid_endpoint_urls_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_tests_admin_oauth_linuxdo_reports_invalid_endpoint_urls_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/linuxdo/test",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::default());
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/oauth/providers/linuxdo/test"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"client_id": "client-id",
|
|
"authorization_url_override": "not-a-url",
|
|
"token_url_override": "not-a-url",
|
|
"redirect_uri": "http://localhost:8084/api/oauth/linuxdo/callback"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["authorization_url_reachable"], false);
|
|
assert_eq!(payload["token_url_reachable"], false);
|
|
assert_eq!(payload["secret_status"], "not_provided");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
let _ = upstream_url;
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal",
|
|
gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_deletes_admin_oauth_provider_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/linuxdo",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryOAuthProviderRepository::seed(vec![
|
|
sample_oauth_provider_config("linuxdo"),
|
|
]));
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_oauth_provider_repository_for_tests(
|
|
repository.clone(),
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{gateway_url}/api/admin/oauth/providers/linuxdo"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "删除成功");
|
|
assert!(repository
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("lookup should succeed")
|
|
.is_none());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_deleting_disabled_oauth_provider_with_user_bindings() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_deleting_disabled_oauth_provider_with_user_bindings",
|
|
gateway_rejects_deleting_disabled_oauth_provider_with_user_bindings_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_deleting_disabled_oauth_provider_with_user_bindings_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/oauth/providers/linuxdo",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_oauth_provider_config("linuxdo");
|
|
provider.is_enabled = false;
|
|
let provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed([provider]));
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::default());
|
|
let now = chrono::Utc::now();
|
|
let user = user_repository
|
|
.create_oauth_auth_user(
|
|
Some("[email protected]".to_string()),
|
|
false,
|
|
"bound-user".to_string(),
|
|
now,
|
|
)
|
|
.await
|
|
.expect("OAuth user should create")
|
|
.expect("OAuth user should exist");
|
|
user_repository
|
|
.bind_user_oauth_link(&user.id, "linuxdo", "bound-subject", None, None, None, now)
|
|
.await
|
|
.expect("OAuth link should bind");
|
|
|
|
let data = GatewayDataState::with_oauth_provider_repository_for_tests(Arc::clone(
|
|
&provider_repository,
|
|
))
|
|
.with_user_reader(user_repository);
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{gateway_url}/api/admin/oauth/providers/linuxdo"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error"]["type"], "provider_has_bindings");
|
|
assert!(provider_repository
|
|
.get_oauth_provider_config("linuxdo")
|
|
.await
|
|
.expect("provider lookup should succeed")
|
|
.is_some());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
let _ = upstream_url;
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_handles_admin_management_token_detail_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_handles_admin_management_token_detail_locally_with_trusted_admin_principal",
|
|
gateway_handles_admin_management_token_detail_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_handles_admin_management_token_detail_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/management-tokens/{token_id}",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
|
|
sample_management_token("mt-admin-1", "user-1", "alice", true),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/management-tokens/mt-admin-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["id"], "mt-admin-1");
|
|
assert_eq!(payload["user"]["email"], "[email protected]");
|
|
assert_eq!(payload["usage_count"], 7);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_creates_updates_and_regenerates_admin_management_token_locally_with_permissions() {
|
|
run_admin_oauth_test(
|
|
"gateway_creates_updates_and_regenerates_admin_management_token_locally_with_permissions",
|
|
gateway_creates_updates_and_regenerates_admin_management_token_locally_with_permissions_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_creates_updates_and_regenerates_admin_management_token_locally_with_permissions_impl(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let repository = Arc::new(InMemoryManagementTokenRepository::default());
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(repository.clone()),
|
|
));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let create_response = client
|
|
.post(format!("{gateway_url}/api/admin/management-tokens"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "admin-token",
|
|
"description": "admin token",
|
|
"allowed_ips": ["127.0.0.1"],
|
|
"permissions": ["admin:usage:read", "admin:pool:write"],
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let create_status = create_response.status();
|
|
let create_body = create_response.text().await.expect("body should read");
|
|
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
|
|
let create_payload: serde_json::Value =
|
|
serde_json::from_str(&create_body).expect("json body should parse");
|
|
let token_id = create_payload["data"]["id"]
|
|
.as_str()
|
|
.expect("token id should exist")
|
|
.to_string();
|
|
assert_eq!(create_payload["message"], "Management Token 创建成功");
|
|
assert_eq!(create_payload["data"]["user"]["id"], json!(admin_user.id));
|
|
assert_eq!(
|
|
create_payload["data"]["permissions"],
|
|
json!(["admin:pool:write", "admin:usage:read"])
|
|
);
|
|
let created_secret = create_payload["token"].as_str().unwrap_or_default();
|
|
assert!(created_secret.starts_with("ae-"));
|
|
|
|
let update_response = client
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/management-tokens/{token_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "admin-token-updated",
|
|
"description": null,
|
|
"allowed_ips": null,
|
|
"permissions": ["admin:usage:read"],
|
|
"expires_at": null,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(update_response.status(), StatusCode::OK);
|
|
let update_payload: serde_json::Value = update_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(update_payload["message"], "更新成功");
|
|
assert_eq!(update_payload["data"]["name"], "admin-token-updated");
|
|
assert_eq!(
|
|
update_payload["data"]["permissions"],
|
|
json!(["admin:usage:read"])
|
|
);
|
|
assert_eq!(
|
|
update_payload["data"]["allowed_ips"],
|
|
serde_json::Value::Null
|
|
);
|
|
|
|
let regenerate_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/management-tokens/{token_id}/regenerate"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
|
let regenerate_payload: serde_json::Value = regenerate_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(regenerate_payload["message"], "Token 已重新生成");
|
|
assert!(regenerate_payload["token"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.starts_with("ae-"));
|
|
assert_eq!(
|
|
repository
|
|
.get_management_token_with_user(&token_id)
|
|
.await
|
|
.expect("lookup should succeed")
|
|
.expect("token should remain")
|
|
.token
|
|
.permissions,
|
|
Some(json!(["admin:usage:read"]))
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
drop(upstream_url);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_allows_management_token_with_pool_write_for_provider_oauth_batch_import() {
|
|
run_admin_oauth_test(
|
|
"gateway_allows_management_token_with_pool_write_for_provider_oauth_batch_import",
|
|
gateway_allows_management_token_with_pool_write_for_provider_oauth_batch_import_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_allows_management_token_with_pool_write_for_provider_oauth_batch_import_impl() {
|
|
let raw_token = "ae-provider-oauth-batch-pool-write";
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
let mut management_token = sample_management_token(
|
|
"token-provider-oauth-batch-pool",
|
|
&admin_user.id,
|
|
"provider-oauth-pool",
|
|
true,
|
|
);
|
|
management_token.token.allowed_ips = None;
|
|
management_token.token.permissions = Some(json!([
|
|
"admin:pool:read",
|
|
"admin:pool:write",
|
|
"admin:provider_oauth:admin"
|
|
]));
|
|
let management_token_repository =
|
|
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
|
vec![management_token],
|
|
vec![(
|
|
hash_management_token(raw_token),
|
|
"token-provider-oauth-batch-pool".to_string(),
|
|
)],
|
|
));
|
|
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(management_token_repository),
|
|
));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-123/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(raw_token)
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{payload}");
|
|
assert_eq!(payload["detail"], "Admin provider OAuth data unavailable");
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_prevents_pool_write_token_from_importing_agent_identity_via_batch_routes() {
|
|
run_admin_oauth_test(
|
|
"gateway_prevents_pool_write_token_from_importing_agent_identity_via_batch_routes",
|
|
gateway_prevents_pool_write_token_from_importing_agent_identity_via_batch_routes_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_prevents_pool_write_token_from_importing_agent_identity_via_batch_routes_impl() {
|
|
let raw_token = "ae-provider-oauth-agent-identity-pool-write";
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
let mut management_token = sample_management_token(
|
|
"token-provider-oauth-agent-identity-pool",
|
|
&admin_user.id,
|
|
"provider-oauth-agent-identity-pool",
|
|
true,
|
|
);
|
|
management_token.token.allowed_ips = None;
|
|
management_token.token.permissions = Some(json!(["admin:pool:read", "admin:pool:write"]));
|
|
let management_token_repository =
|
|
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
|
vec![management_token],
|
|
vec![(
|
|
hash_management_token(raw_token),
|
|
"token-provider-oauth-agent-identity-pool".to_string(),
|
|
)],
|
|
));
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10);
|
|
provider.provider_type = "codex".to_string();
|
|
let endpoint = sample_endpoint(
|
|
"endpoint-codex-agent-identity",
|
|
"provider-codex",
|
|
"openai:chat",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![],
|
|
));
|
|
let data_state =
|
|
GatewayDataState::with_management_token_repository_for_tests(management_token_repository)
|
|
.attach_provider_catalog_repository_for_tests(provider_catalog_repository.clone())
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(data_state));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let credentials = json!({
|
|
"auth_mode": "agentIdentity",
|
|
"agent_runtime_id": "runtime-rbac-guard",
|
|
"agent_private_key": "MC4CAQAwBQYDK2VwBCIEIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
"task_id": "task-rbac-guard"
|
|
})
|
|
.to_string();
|
|
let client = reqwest::Client::new();
|
|
for path in [
|
|
"/api/admin/provider-oauth/providers/provider-codex/batch-import",
|
|
"/api/admin/provider-oauth/providers/provider-codex/batch-import/tasks",
|
|
"/api/admin/provider-oauth/providers/provider-codex/agent-identity-import/tasks",
|
|
] {
|
|
let response = client
|
|
.post(format!("{gateway_url}{path}"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(raw_token)
|
|
.json(&json!({ "credentials": credentials }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::FORBIDDEN,
|
|
"path={path} payload={payload}"
|
|
);
|
|
assert_eq!(payload["detail"], "management token permission denied");
|
|
assert_eq!(payload["required_permission"], "admin:provider_oauth:admin");
|
|
}
|
|
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should load");
|
|
assert!(keys.is_empty());
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_management_token_without_pool_write_for_provider_oauth_batch_import() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_management_token_without_pool_write_for_provider_oauth_batch_import",
|
|
gateway_rejects_management_token_without_pool_write_for_provider_oauth_batch_import_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_management_token_without_pool_write_for_provider_oauth_batch_import_impl()
|
|
{
|
|
let raw_token = "ae-provider-oauth-batch-pool-denied";
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
let mut management_token = sample_management_token(
|
|
"token-provider-oauth-batch-denied",
|
|
&admin_user.id,
|
|
"provider-oauth-denied",
|
|
true,
|
|
);
|
|
management_token.token.allowed_ips = None;
|
|
management_token.token.permissions = Some(json!(["admin:usage:read"]));
|
|
let management_token_repository =
|
|
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
|
vec![management_token],
|
|
vec![(
|
|
hash_management_token(raw_token),
|
|
"token-provider-oauth-batch-denied".to_string(),
|
|
)],
|
|
));
|
|
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(management_token_repository),
|
|
));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-oauth/providers/provider-123/batch-import"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(raw_token)
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], "management token permission denied");
|
|
assert_eq!(payload["required_permission"], "admin:provider_oauth:admin");
|
|
assert_eq!(payload["route_family"], "provider_oauth_manage");
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_deletes_admin_management_token_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_deletes_admin_management_token_locally_with_trusted_admin_principal",
|
|
gateway_deletes_admin_management_token_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_deletes_admin_management_token_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/management-tokens/{token_id}",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
|
|
sample_management_token("mt-admin-1", "user-1", "alice", true),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(repository.clone()),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!(
|
|
"{gateway_url}/api/admin/management-tokens/mt-admin-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "删除成功");
|
|
assert_eq!(
|
|
repository
|
|
.get_management_token_with_user("mt-admin-1")
|
|
.await
|
|
.expect("lookup should succeed"),
|
|
None
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_toggles_admin_management_token_locally_with_trusted_admin_principal() {
|
|
run_admin_oauth_test(
|
|
"gateway_toggles_admin_management_token_locally_with_trusted_admin_principal",
|
|
gateway_toggles_admin_management_token_locally_with_trusted_admin_principal_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_toggles_admin_management_token_locally_with_trusted_admin_principal_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/management-tokens/{token_id}/status",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
|
|
sample_management_token("mt-admin-1", "user-1", "alice", true),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(repository.clone()),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.patch(format!(
|
|
"{gateway_url}/api/admin/management-tokens/mt-admin-1/status"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "Token 已禁用");
|
|
assert_eq!(payload["data"]["is_active"], false);
|
|
assert_eq!(
|
|
repository
|
|
.get_management_token_with_user("mt-admin-1")
|
|
.await
|
|
.expect("lookup should succeed")
|
|
.expect("token should remain")
|
|
.token
|
|
.is_active,
|
|
false
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_rejects_partial_management_token_for_admin_management_token_routes() {
|
|
run_admin_oauth_test(
|
|
"gateway_rejects_partial_management_token_for_admin_management_token_routes",
|
|
gateway_rejects_partial_management_token_for_admin_management_token_routes_impl,
|
|
);
|
|
}
|
|
|
|
async fn gateway_rejects_partial_management_token_for_admin_management_token_routes_impl() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/management-tokens",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let raw_token = "ae-management-partial-access";
|
|
let state = AppState::new().expect("gateway should build");
|
|
let admin_user = state
|
|
.create_local_auth_user_with_settings(
|
|
Some("[email protected]".to_string()),
|
|
true,
|
|
"admin".to_string(),
|
|
"hash".to_string(),
|
|
"admin".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("admin user should be created")
|
|
.expect("admin user should exist");
|
|
let mut management_token = sample_management_token(
|
|
"mt-admin-partial",
|
|
&admin_user.id,
|
|
"management-partial",
|
|
true,
|
|
);
|
|
management_token.token.allowed_ips = None;
|
|
management_token.token.permissions = Some(json!(["admin:usage:read"]));
|
|
let repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
|
vec![management_token],
|
|
vec![(
|
|
hash_management_token(raw_token),
|
|
"mt-admin-partial".to_string(),
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
|
GatewayDataState::with_management_token_repository_for_tests(repository),
|
|
));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/management-tokens"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.bearer_auth(raw_token)
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], "management token permission denied");
|
|
assert_eq!(
|
|
payload["required_permission"],
|
|
"admin:management_tokens:read"
|
|
);
|
|
assert_eq!(payload["route_family"], "management_tokens_manage");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|