mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): cargo fmt + coverage for openai_oauth (#1953)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setCloudProviderKey } from '../../../../services/api/aiSettingsApi';
|
||||
import { callCoreRpc } from '../../../../services/coreRpcClient';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import { openUrl } from '../../../../utils/openUrl';
|
||||
import { isTauri } from '../../../../utils/tauriCommands/common';
|
||||
import ApiKeysStep from '../ApiKeysStep';
|
||||
|
||||
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
@@ -17,10 +21,12 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
describe('ApiKeysStep OpenAI OAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(openUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows connected badge when oauth status reports connected', async () => {
|
||||
const { callCoreRpc } = await import('../../../../services/coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } });
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
@@ -30,7 +36,6 @@ describe('ApiKeysStep OpenAI OAuth', () => {
|
||||
});
|
||||
|
||||
it('starts oauth and accepts pasted callback URL', async () => {
|
||||
const { callCoreRpc } = await import('../../../../services/coreRpcClient');
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ result: { connected: false } })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -42,8 +47,6 @@ describe('ApiKeysStep OpenAI OAuth', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ result: { connected: true } });
|
||||
|
||||
const { openUrl } = await import('../../../../utils/openUrl');
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect'));
|
||||
@@ -71,4 +74,94 @@ describe('ApiKeysStep OpenAI OAuth', () => {
|
||||
|
||||
expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a desktop-only error without calling core outside Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('onboarding-openai-oauth-connect'));
|
||||
|
||||
expect(
|
||||
await screen.findByText('ChatGPT sign-in is only available in the desktop app.')
|
||||
).toBeInTheDocument();
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an oauth start failure when core omits authUrl', async () => {
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ result: { connected: false } })
|
||||
.mockResolvedValueOnce({ result: { authUrl: ' ' } });
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect'));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Could not start ChatGPT sign-in. Try again or use an API key.')
|
||||
).toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a pasted callback before completing oauth', async () => {
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ result: { connected: false } })
|
||||
.mockResolvedValueOnce({
|
||||
result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' },
|
||||
});
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect'));
|
||||
await screen.findByTestId('onboarding-openai-oauth-callback-input');
|
||||
fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete'));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Paste the redirect URL from your browser after signing in.')
|
||||
).toBeInTheDocument();
|
||||
expect(callCoreRpc).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'openhuman.inference_openai_oauth_complete' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an oauth completion failure and keeps the callback form visible', async () => {
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ result: { connected: false } })
|
||||
.mockResolvedValueOnce({
|
||||
result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('state mismatch'));
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect'));
|
||||
const input = await screen.findByTestId('onboarding-openai-oauth-callback-input');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=wrong' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete'));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'ChatGPT sign-in did not complete. Check the redirect URL and try again.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('onboarding-openai-oauth-callback-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('continues without saving API keys when oauth is already connected', async () => {
|
||||
const onNext = vi.fn();
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } });
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={onNext} onSkip={vi.fn()} />);
|
||||
|
||||
await screen.findByTestId('onboarding-openai-oauth-connected');
|
||||
fireEvent.click(screen.getByTestId('onboarding-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(setCloudProviderKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ pub fn disconnect_openai_oauth(config: &Config) -> Result<serde_json::Value, Str
|
||||
Ok(serde_json::json!({ "disconnected": removed }))
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
pub(super) fn build_authorize_url(
|
||||
config: &motosan_ai_oauth::OAuthConfig,
|
||||
challenge: &str,
|
||||
state: &str,
|
||||
@@ -256,7 +256,7 @@ fn build_authorize_url(
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
async fn exchange_authorization_code(
|
||||
pub(super) async fn exchange_authorization_code(
|
||||
config: &motosan_ai_oauth::OAuthConfig,
|
||||
code: &str,
|
||||
state: &str,
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
use super::flow::parse_callback_input;
|
||||
use super::flow::{build_authorize_url, exchange_authorization_code, parse_callback_input};
|
||||
use super::store::persist_openai_oauth_token;
|
||||
use super::{
|
||||
complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth,
|
||||
};
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet};
|
||||
use crate::openhuman::credentials::profiles::{
|
||||
AuthProfile, AuthProfileKind, AuthProfilesStore, TokenSet,
|
||||
};
|
||||
use crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token;
|
||||
use crate::openhuman::inference::openai_oauth::store::{
|
||||
OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use motosan_ai_oauth::{OAuthConfig, StateStrategy, TokenBodyFormat};
|
||||
use tempfile::tempdir;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn test_config(tmp: &tempfile::TempDir) -> Config {
|
||||
let mut config = Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config
|
||||
Config {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Runtime::new().unwrap()
|
||||
}
|
||||
|
||||
fn unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||
format!("{header}.{payload}.")
|
||||
}
|
||||
|
||||
fn test_oauth_config(token_url: &'static str) -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
client_id: "client-id",
|
||||
client_secret: Some("client-secret"),
|
||||
auth_url: "https://auth.example.test/oauth/authorize",
|
||||
token_url,
|
||||
scopes: &["scope-a", "scope-b"],
|
||||
redirect_port: Some(1455),
|
||||
callback_path: "/auth/callback",
|
||||
redirect_uri_host: "127.0.0.1",
|
||||
token_body: TokenBodyFormat::Form,
|
||||
extra_auth_params: &[("prompt", "consent")],
|
||||
state_strategy: StateStrategy::Random,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,6 +65,41 @@ fn start_openai_oauth_returns_authorize_url() {
|
||||
assert!(!openai_oauth_status(&config).unwrap().connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorize_url_includes_codex_pkce_and_extra_params() {
|
||||
let url = build_authorize_url(
|
||||
&test_oauth_config("https://token.example.test/oauth/token"),
|
||||
"challenge-123",
|
||||
"state-123",
|
||||
"http://127.0.0.1:1455/auth/callback",
|
||||
);
|
||||
let parsed = reqwest::Url::parse(&url).unwrap();
|
||||
let pairs = parsed
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
pairs.get("client_id").map(String::as_str),
|
||||
Some("client-id")
|
||||
);
|
||||
assert_eq!(pairs.get("response_type").map(String::as_str), Some("code"));
|
||||
assert_eq!(
|
||||
pairs.get("scope").map(String::as_str),
|
||||
Some("scope-a scope-b")
|
||||
);
|
||||
assert_eq!(pairs.get("state").map(String::as_str), Some("state-123"));
|
||||
assert_eq!(
|
||||
pairs.get("code_challenge").map(String::as_str),
|
||||
Some("challenge-123")
|
||||
);
|
||||
assert_eq!(
|
||||
pairs.get("code_challenge_method").map(String::as_str),
|
||||
Some("S256")
|
||||
);
|
||||
assert_eq!(pairs.get("prompt").map(String::as_str), Some("consent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_accepts_full_redirect_url() {
|
||||
let url = "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz";
|
||||
@@ -38,12 +108,71 @@ fn parse_callback_input_accepts_full_redirect_url() {
|
||||
assert_eq!(state, "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_accepts_raw_query_string() {
|
||||
let (code, state) = parse_callback_input("code=abc%20123&state=xyz").unwrap();
|
||||
assert_eq!(code, "abc 123");
|
||||
assert_eq!(state, "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_rejects_missing_code() {
|
||||
let err = parse_callback_input("http://127.0.0.1:1455/auth/callback?state=xyz").unwrap_err();
|
||||
assert!(err.contains("code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_rejects_blank_invalid_and_missing_state() {
|
||||
let blank = parse_callback_input(" ").unwrap_err();
|
||||
assert!(blank.contains("required"));
|
||||
|
||||
let invalid = parse_callback_input("not-a-callback").unwrap_err();
|
||||
assert!(invalid.contains("invalid"));
|
||||
|
||||
let missing_state =
|
||||
parse_callback_input("http://127.0.0.1:1455/auth/callback?code=abc").unwrap_err();
|
||||
assert!(missing_state.contains("state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_openai_oauth_rejects_missing_pending_session() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let err = runtime()
|
||||
.block_on(complete_openai_oauth(
|
||||
&config,
|
||||
"http://127.0.0.1:1455/auth/callback?code=fake&state=state",
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no pending OAuth session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_openai_oauth_rejects_expired_pending_session() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
std::fs::write(
|
||||
tmp.path().join("openai-oauth-pending.json"),
|
||||
serde_json::json!({
|
||||
"state": "state",
|
||||
"verifier": "verifier",
|
||||
"redirect_uri": "http://127.0.0.1:1455/auth/callback",
|
||||
"created_at": 1_u64,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = runtime()
|
||||
.block_on(complete_openai_oauth(
|
||||
&config,
|
||||
"http://127.0.0.1:1455/auth/callback?code=fake&state=state",
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no pending OAuth session"));
|
||||
assert!(!tmp.path().join("openai-oauth-pending.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_openai_oauth_rejects_state_mismatch() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -53,13 +182,134 @@ fn complete_openai_oauth_rejects_state_mismatch() {
|
||||
"http://127.0.0.1:1455/auth/callback?code=fake&state=not-{}",
|
||||
start.state
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let err = rt
|
||||
let err = runtime()
|
||||
.block_on(complete_openai_oauth(&config, &callback))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("state mismatch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_authorization_code_parses_successful_token_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"id_token": "id-token",
|
||||
"expires_in": 3600,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str());
|
||||
|
||||
let token = exchange_authorization_code(
|
||||
&test_oauth_config(token_url),
|
||||
"code-123",
|
||||
"state-123",
|
||||
"verifier-123",
|
||||
"http://127.0.0.1:1455/auth/callback",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(token.access_token, "access-token");
|
||||
assert_eq!(token.refresh_token, "refresh-token");
|
||||
assert_eq!(token.id_token.as_deref(), Some("id-token"));
|
||||
assert_eq!(token.expires_in, 3600);
|
||||
assert!(token.issued_at > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_authorization_code_reports_http_errors() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("bad auth code"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str());
|
||||
|
||||
let err = exchange_authorization_code(
|
||||
&test_oauth_config(token_url),
|
||||
"code-123",
|
||||
"state-123",
|
||||
"verifier-123",
|
||||
"http://127.0.0.1:1455/auth/callback",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("HTTP 400"));
|
||||
assert!(err.contains("bad auth code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_openai_oauth_token_stores_oauth_profile_with_metadata() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let access_token = unsigned_jwt(serde_json::json!({ "sub": "acct_123" }));
|
||||
let token = motosan_ai_oauth::Token {
|
||||
access_token: access_token.clone(),
|
||||
refresh_token: "refresh-token".into(),
|
||||
id_token: Some("id-token".into()),
|
||||
expires_in: 3600,
|
||||
issued_at: 123,
|
||||
};
|
||||
|
||||
let profile = persist_openai_oauth_token(&config, &token).unwrap();
|
||||
assert_eq!(profile.kind, AuthProfileKind::OAuth);
|
||||
assert_eq!(
|
||||
profile.metadata.get("account_id").map(String::as_str),
|
||||
Some("acct_123")
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.token_set
|
||||
.as_ref()
|
||||
.map(|set| set.access_token.as_str()),
|
||||
Some(access_token.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.token_set
|
||||
.as_ref()
|
||||
.and_then(|set| set.refresh_token.as_deref()),
|
||||
Some("refresh-token")
|
||||
);
|
||||
assert!(profile
|
||||
.token_set
|
||||
.as_ref()
|
||||
.and_then(|set| set.expires_at)
|
||||
.is_some());
|
||||
|
||||
let data = AuthProfilesStore::new(tmp.path(), false).load().unwrap();
|
||||
let stored = data.profiles.get(&profile.id).unwrap();
|
||||
assert_eq!(stored.id, profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_status_reports_token_profile_as_disconnected() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
store
|
||||
.upsert_profile(
|
||||
AuthProfile::new_token(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
"sk-token-profile".to_string(),
|
||||
),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status = openai_oauth_status(&config).unwrap();
|
||||
assert!(!status.connected);
|
||||
assert_eq!(status.auth_method.as_deref(), Some("token"));
|
||||
assert!(status.profile_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_prefers_api_key_over_oauth() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -88,6 +338,105 @@ fn lookup_openai_bearer_token_prefers_api_key_over_oauth() {
|
||||
assert_eq!(token.as_deref(), Some("sk-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_uses_oauth_when_api_key_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
let oauth_profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "oauth-access".into(),
|
||||
refresh_token: Some("refresh".into()),
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(oauth_profile, true).unwrap();
|
||||
|
||||
let token = lookup_openai_bearer_token(&config).unwrap();
|
||||
assert_eq!(token.as_deref(), Some("oauth-access"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_uses_legacy_api_key_when_new_style_is_empty() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
let oauth_profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: " ".into(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(oauth_profile, true).unwrap();
|
||||
store
|
||||
.upsert_profile(
|
||||
AuthProfile::new_token("openai", "default", "sk-legacy-key".to_string()),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let token = lookup_openai_bearer_token(&config).unwrap();
|
||||
assert_eq!(token.as_deref(), Some("sk-legacy-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_keeps_expired_token_when_refresh_fails_without_runtime() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
let oauth_profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "expired-access".into(),
|
||||
refresh_token: Some("refresh".into()),
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() - Duration::minutes(5)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(oauth_profile, true).unwrap();
|
||||
|
||||
let token = lookup_openai_bearer_token(&config).unwrap();
|
||||
assert_eq!(token.as_deref(), Some("expired-access"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_returns_none_without_profiles_or_access_token() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None);
|
||||
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
let empty_oauth_profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: " ".into(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() - Duration::hours(1)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(empty_oauth_profile, true).unwrap();
|
||||
|
||||
assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_openai_oauth_clears_profile() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use super::*;
|
||||
use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet};
|
||||
use crate::openhuman::inference::openai_oauth::{OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY};
|
||||
use chrono::{Duration, Utc};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn disabled_config() -> (Config, tempfile::TempDir) {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = tmp.path().join("workspace");
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
let mut config = Config {
|
||||
workspace_dir: tmp.path().join("workspace"),
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.local_ai.runtime_enabled = false;
|
||||
config.local_ai.opt_in_confirmed = false;
|
||||
(config, tmp)
|
||||
@@ -109,3 +114,99 @@ async fn inference_presets_returns_recommended_tier() {
|
||||
assert!(outcome.value.get("recommended_tier").is_some());
|
||||
assert!(outcome.value.get("presets").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_openai_oauth_start_returns_authorize_payload() {
|
||||
let (config, _tmp) = disabled_config();
|
||||
|
||||
let outcome = inference_openai_oauth_start(&config)
|
||||
.await
|
||||
.expect("oauth start");
|
||||
|
||||
assert!(outcome.value["authUrl"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("auth.openai.com"));
|
||||
assert_eq!(
|
||||
outcome.value["redirectUri"].as_str(),
|
||||
Some("http://127.0.0.1:1455/auth/callback")
|
||||
);
|
||||
assert_eq!(outcome.logs, vec!["openai oauth authorize url ready"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_openai_oauth_complete_surfaces_state_errors() {
|
||||
let (config, _tmp) = disabled_config();
|
||||
let start = inference_openai_oauth_start(&config)
|
||||
.await
|
||||
.expect("oauth start");
|
||||
let state = start.value["state"].as_str().unwrap();
|
||||
let callback = format!("http://127.0.0.1:1455/auth/callback?code=fake&state=wrong-{state}");
|
||||
|
||||
let err = inference_openai_oauth_complete(&config, &callback)
|
||||
.await
|
||||
.expect_err("state mismatch should fail");
|
||||
|
||||
assert!(err.contains("state mismatch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_openai_oauth_status_returns_connected_payload() {
|
||||
let (config, tmp) = disabled_config();
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
store
|
||||
.upsert_profile(
|
||||
AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "oauth-access".into(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outcome = inference_openai_oauth_status(&config)
|
||||
.await
|
||||
.expect("oauth status");
|
||||
|
||||
assert_eq!(outcome.value["connected"], true);
|
||||
assert_eq!(outcome.value["authMethod"], "oauth");
|
||||
assert_eq!(outcome.logs, vec!["openai oauth status"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_openai_oauth_disconnect_returns_removed_flag() {
|
||||
let (config, tmp) = disabled_config();
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
store
|
||||
.upsert_profile(
|
||||
AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "oauth-access".into(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_at: None,
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outcome = inference_openai_oauth_disconnect(&config)
|
||||
.await
|
||||
.expect("oauth disconnect");
|
||||
|
||||
assert_eq!(outcome.value["disconnected"], true);
|
||||
assert_eq!(outcome.logs, vec!["openai oauth disconnected"]);
|
||||
}
|
||||
|
||||
@@ -454,3 +454,22 @@ fn verify_session_active_called_for_custom_provider_not_for_openhuman() {
|
||||
"verify_session_active must reject config without session",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_key_for_slug_routes_openai_oauth_lookup_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let config = config_in_tempdir(&tmp);
|
||||
let auth = AuthService::new(tmp.path(), config.secrets.encrypt);
|
||||
auth.store_provider_token(
|
||||
"provider:openai",
|
||||
"default",
|
||||
"sk-openai",
|
||||
Default::default(),
|
||||
true,
|
||||
)
|
||||
.expect("store openai token");
|
||||
|
||||
let token = lookup_key_for_slug("openai", &config).expect("lookup openai token");
|
||||
|
||||
assert_eq!(token, "sk-openai");
|
||||
}
|
||||
|
||||
@@ -701,9 +701,7 @@ fn handle_inference_openai_oauth_status(_params: Map<String, Value>) -> Controll
|
||||
fn handle_inference_openai_oauth_disconnect(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::inference::rpc::inference_openai_oauth_disconnect(&config).await?,
|
||||
)
|
||||
to_json(crate::openhuman::inference::rpc::inference_openai_oauth_disconnect(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ fn inference_schema_function_names_are_stable() {
|
||||
assert!(functions.contains(&"presets"));
|
||||
assert!(functions.contains(&"apply_preset"));
|
||||
assert!(functions.contains(&"diagnostics"));
|
||||
assert!(functions.contains(&"openai_oauth_start"));
|
||||
assert!(functions.contains(&"openai_oauth_complete"));
|
||||
assert!(functions.contains(&"openai_oauth_status"));
|
||||
assert!(functions.contains(&"openai_oauth_disconnect"));
|
||||
assert!(functions.contains(&"prompt"));
|
||||
assert!(functions.contains(&"vision_prompt"));
|
||||
assert!(functions.contains(&"embed"));
|
||||
@@ -64,6 +68,45 @@ fn inference_chat_schema_requires_messages() {
|
||||
.any(|field| field.name == "messages" && field.required));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() {
|
||||
let registered: Vec<&str> = all_registered_controllers()
|
||||
.into_iter()
|
||||
.map(|controller| controller.schema.function)
|
||||
.collect();
|
||||
for function in [
|
||||
"openai_oauth_start",
|
||||
"openai_oauth_complete",
|
||||
"openai_oauth_status",
|
||||
"openai_oauth_disconnect",
|
||||
] {
|
||||
assert!(registered.contains(&function), "missing {function}");
|
||||
let schema = schemas(function);
|
||||
assert_eq!(schema.namespace, "inference");
|
||||
assert_eq!(schema.function, function);
|
||||
assert!(!schema.description.is_empty());
|
||||
assert!(!schema.outputs.is_empty());
|
||||
}
|
||||
|
||||
let complete = schemas("openai_oauth_complete");
|
||||
assert_eq!(complete.inputs.len(), 1);
|
||||
assert_eq!(complete.inputs[0].name, "callback_url");
|
||||
assert!(complete.inputs[0].required);
|
||||
|
||||
assert!(schemas("openai_oauth_start").inputs.is_empty());
|
||||
assert!(schemas("openai_oauth_status").inputs.is_empty());
|
||||
assert!(schemas("openai_oauth_disconnect").inputs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_openai_oauth_complete_handler_rejects_invalid_params() {
|
||||
let params = Map::from_iter([("callback_url".to_string(), Value::Bool(true))]);
|
||||
let err = handle_inference_openai_oauth_complete(params)
|
||||
.await
|
||||
.expect_err("invalid params");
|
||||
assert!(err.contains("invalid params"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_unknown_schema_panics() {
|
||||
let panic = std::panic::catch_unwind(|| schemas("no_such_function"));
|
||||
|
||||
Reference in New Issue
Block a user