diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index efc680596a..0297a2d3c9 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -138,8 +138,8 @@ describe("Content", () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, provider_tokens_set: { - github: "some-token", - gitlab: "some-token", + github: null, + gitlab: null, }, }); queryClient.invalidateQueries(); @@ -163,7 +163,7 @@ describe("Content", () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, provider_tokens_set: { - gitlab: "some-token", + gitlab: null, }, }); queryClient.invalidateQueries(); @@ -241,8 +241,8 @@ describe("Form submission", () => { await userEvent.click(submit); expect(saveProvidersSpy).toHaveBeenCalledWith({ - github: { token: "test-token" }, - gitlab: { token: "" }, + github: { token: "test-token", host: "" }, + gitlab: { token: "", host: "" }, }); const gitlabInput = await screen.findByTestId("gitlab-token-input"); @@ -250,8 +250,8 @@ describe("Form submission", () => { await userEvent.click(submit); expect(saveProvidersSpy).toHaveBeenCalledWith({ - github: { token: "test-token" }, - gitlab: { token: "" }, + github: { token: "test-token", host: "" }, + gitlab: { token: "", host: "" }, }); }); @@ -290,7 +290,7 @@ describe("Form submission", () => { ...MOCK_DEFAULT_USER_SETTINGS, provider_tokens_set: { github: null, - gitlab: "some-token", + gitlab: null, }, }); @@ -321,7 +321,7 @@ describe("Form submission", () => { ...MOCK_DEFAULT_USER_SETTINGS, provider_tokens_set: { github: null, - gitlab: "some-token", + gitlab: null, }, }); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index 5996c2a657..0789dda955 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -64,7 +64,7 @@ describe("HomeScreen", () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, provider_tokens_set: { - github: "some-token", + github: null, gitlab: null, }, }); diff --git a/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx b/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx index aed88534e4..4b9294878c 100644 --- a/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx +++ b/frontend/src/components/features/settings/git-settings/configure-github-repositories-anchor.tsx @@ -17,7 +17,7 @@ export function ConfigureGitHubRepositoriesAnchor({ href={`https://github.com/apps/${slug}/installations/new`} target="_blank" rel="noreferrer noopener" - className="px-11 py-9" + className="py-9" > {t(I18nKey.GITHUB$CONFIGURE_REPOS)} diff --git a/frontend/src/components/features/settings/git-settings/github-token-input.tsx b/frontend/src/components/features/settings/git-settings/github-token-input.tsx index d91ea20e63..58a5f3e01f 100644 --- a/frontend/src/components/features/settings/git-settings/github-token-input.tsx +++ b/frontend/src/components/features/settings/git-settings/github-token-input.tsx @@ -6,14 +6,18 @@ import { KeyStatusIcon } from "../key-status-icon"; interface GitHubTokenInputProps { onChange: (value: string) => void; + onGitHubHostChange: (value: string) => void; isGitHubTokenSet: boolean; name: string; + githubHostSet: string | null | undefined; } export function GitHubTokenInput({ onChange, + onGitHubHostChange, isGitHubTokenSet, name, + githubHostSet, }: GitHubTokenInputProps) { const { t } = useTranslation(); @@ -37,6 +41,24 @@ export function GitHubTokenInput({ } /> + {})} + name="github-host-input" + testId="github-host-input" + label={t(I18nKey.GITHUB$HOST_LABEL)} + type="text" + className="w-[680px]" + placeholder="github.com" + defaultValue={githubHostSet || undefined} + startContent={ + githubHostSet && githubHostSet.trim() !== "" ? ( + + ) : ( + + ) + } + /> + ); diff --git a/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx b/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx index f377226a1e..c95db9ff7e 100644 --- a/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx +++ b/frontend/src/components/features/settings/git-settings/gitlab-token-input.tsx @@ -6,14 +6,18 @@ import { KeyStatusIcon } from "../key-status-icon"; interface GitLabTokenInputProps { onChange: (value: string) => void; + onGitLabHostChange: (value: string) => void; isGitLabTokenSet: boolean; name: string; + gitlabHostSet: string | null | undefined; } export function GitLabTokenInput({ onChange, + onGitLabHostChange, isGitLabTokenSet, name, + gitlabHostSet, }: GitLabTokenInputProps) { const { t } = useTranslation(); @@ -37,6 +41,24 @@ export function GitLabTokenInput({ } /> + {})} + name="gitlab-host-input" + testId="gitlab-host-input" + label={t(I18nKey.GITLAB$HOST_LABEL)} + type="text" + className="w-[680px]" + placeholder="gitlab.com" + defaultValue={gitlabHostSet || undefined} + startContent={ + gitlabHostSet && gitlabHostSet.trim() !== "" ? ( + + ) : ( + + ) + } + /> + ); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index bd636625ce..64d40c1ace 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -134,6 +134,7 @@ export enum I18nKey { EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE", LANGUAGE$LABEL = "LANGUAGE$LABEL", GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL", + GITHUB$HOST_LABEL = "GITHUB$HOST_LABEL", GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL", GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN", GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT", @@ -483,6 +484,7 @@ export enum I18nKey { MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED", MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS", GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL", + GITLAB$HOST_LABEL = "GITLAB$HOST_LABEL", GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN", GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT", GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 1a08e027f0..0dd790aecc 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -2145,6 +2145,21 @@ "de": "GitHub-Token", "uk": "GitHub Токен" }, + "GITHUB$HOST_LABEL": { + "en": "GitHub Host (optional)", + "ja": "GitHubホスト (オプション)", + "zh-CN": "GitHub主机 (可选)", + "zh-TW": "GitHub主機 (選填)", + "ko-KR": "GitHub 호스트 (선택사항)", + "no": "GitHub-vert (valgfritt)", + "it": "Host GitHub (opzionale)", + "pt": "Host do GitHub (opcional)", + "es": "Host de GitHub (opcional)", + "ar": "مضيف GitHub (اختياري)", + "fr": "Hôte GitHub (optionnel)", + "tr": "GitHub Sunucusu (isteğe bağlı)", + "de": "GitHub-Host (optional)" + }, "GITHUB$TOKEN_OPTIONAL": { "en": "GitHub Token (Optional)", "ja": "GitHubトークン(任意)", @@ -7432,6 +7447,21 @@ "de": "GitLab-Token", "uk": "GitLab токен" }, + "GITLAB$HOST_LABEL": { + "en": "GitLab Host (optional)", + "ja": "GitLabホスト (オプション)", + "zh-CN": "GitLab主机 (可选)", + "zh-TW": "GitLab主機 (選填)", + "ko-KR": "GitLab 호스트 (선택사항)", + "no": "GitLab-vert (valgfritt)", + "it": "Host GitLab (opzionale)", + "pt": "Host do GitLab (opcional)", + "es": "Host de GitLab (opcional)", + "ar": "مضيف GitLab (اختياري)", + "fr": "Hôte GitLab (optionnel)", + "tr": "GitLab Sunucusu (isteğe bağlı)", + "de": "GitLab-Host (optional)" + }, "GITLAB$GET_TOKEN": { "en": "Generate a token on", "ja": "トークンを生成する", diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index 2684ffeb9e..89f01758b8 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -23,8 +23,9 @@ function GitSettingsScreen() { const { mutate: saveGitProviders, isPending } = useAddGitProviders(); const { mutate: disconnectGitTokens } = useLogout(); + const { data: settings, isLoading } = useSettings(); const { providers } = useUserProviders(); - const { isLoading } = useSettings(); + const { data: config } = useConfig(); const [githubTokenInputHasValue, setGithubTokenInputHasValue] = @@ -32,6 +33,14 @@ function GitSettingsScreen() { const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] = React.useState(false); + const [githubHostInputHasValue, setGithubHostInputHasValue] = + React.useState(false); + const [gitlabHostInputHasValue, setGitlabHostInputHasValue] = + React.useState(false); + + const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github; + const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab; + const isSaas = config?.APP_MODE === "saas"; const isGitHubTokenSet = providers.includes("github"); const isGitLabTokenSet = providers.includes("gitlab"); @@ -47,12 +56,14 @@ function GitSettingsScreen() { const githubToken = formData.get("github-token-input")?.toString() || ""; const gitlabToken = formData.get("gitlab-token-input")?.toString() || ""; + const githubHost = formData.get("github-host-input")?.toString() || ""; + const gitlabHost = formData.get("gitlab-host-input")?.toString() || ""; saveGitProviders( { providers: { - github: { token: githubToken }, - gitlab: { token: gitlabToken }, + github: { token: githubToken, host: githubHost }, + gitlab: { token: gitlabToken, host: gitlabHost }, }, }, { @@ -66,12 +77,18 @@ function GitSettingsScreen() { onSettled: () => { setGithubTokenInputHasValue(false); setGitlabTokenInputHasValue(false); + setGithubHostInputHasValue(false); + setGitlabHostInputHasValue(false); }, }, ); }; - const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue; + const formIsClean = + !githubTokenInputHasValue && + !gitlabTokenInputHasValue && + !githubHostInputHasValue && + !gitlabHostInputHasValue; const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG; return ( @@ -80,55 +97,68 @@ function GitSettingsScreen() { action={formAction} className="flex flex-col h-full justify-between" > + {!isLoading && ( +
+ {shouldRenderExternalConfigureButtons && !isLoading && ( + + )} + + {!isSaas && ( + { + setGithubTokenInputHasValue(!!value); + }} + onGitHubHostChange={(value) => { + setGitlabHostInputHasValue(!!value); + }} + githubHostSet={existingGithubHost} + /> + )} + + {!isSaas && ( + { + setGitlabTokenInputHasValue(!!value); + }} + onGitLabHostChange={(value) => { + setGitlabHostInputHasValue(!!value); + }} + gitlabHostSet={existingGitlabHost} + /> + )} +
+ )} + {isLoading && } - {shouldRenderExternalConfigureButtons && !isLoading && ( - - )} - - {!isSaas && !isLoading && ( -
- { - setGithubTokenInputHasValue(!!value); - }} - /> - - { - setGitlabTokenInputHasValue(!!value); - }} - /> -
- )} - - {!shouldRenderExternalConfigureButtons && ( -
- - Disconnect Tokens - - - - {!isPending && t("SETTINGS$SAVE_CHANGES")} - {isPending && t("SETTINGS$SAVING")} - -
- )} +
+ {!shouldRenderExternalConfigureButtons && ( + <> + + Disconnect Tokens + + + {!isPending && t("SETTINGS$SAVE_CHANGES")} + {isPending && t("SETTINGS$SAVING")} + + + )} +
); } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index cdc934862b..1c99287ad5 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -7,6 +7,7 @@ export type Provider = keyof typeof ProviderOptions; export type ProviderToken = { token: string; + host: string | null; }; export type MCPSSEServer = { diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index 36dcd122c1..ab270f72ae 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -47,7 +47,7 @@ class GitHubService(BaseGitService, GitService): if token: self.token = token - if base_domain: + if base_domain and base_domain != 'github.com': self.BASE_URL = f'https://{base_domain}/api/v3' self.external_auth_id = external_auth_id diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index efcecdc9ff..92fb8a2132 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -31,6 +31,7 @@ from openhands.server.types import AppMode class ProviderToken(BaseModel): token: SecretStr | None = Field(default=None) user_id: str | None = Field(default=None) + host: str | None = Field(default=None) model_config = { 'frozen': True, # Makes the entire model immutable @@ -40,7 +41,7 @@ class ProviderToken(BaseModel): @classmethod def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken: """Factory method to create a ProviderToken from various input types""" - if isinstance(token_value, ProviderToken): + if isinstance(token_value, cls): return token_value elif isinstance(token_value, dict): token_str = token_value.get('token', '') @@ -49,10 +50,11 @@ class ProviderToken(BaseModel): if token_str is None: token_str = '' user_id = token_value.get('user_id') - return cls(token=SecretStr(token_str), user_id=user_id) + host = token_value.get('host') + return cls(token=SecretStr(token_str), user_id=user_id, host=host) else: - raise ValueError('Unsupport Provider token type') + raise ValueError('Unsupported Provider token type') PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken] diff --git a/openhands/server/routes/secrets.py b/openhands/server/routes/secrets.py index 76c1ebfdf1..e3e56b6dfb 100644 --- a/openhands/server/routes/secrets.py +++ b/openhands/server/routes/secrets.py @@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse from openhands.core.logger import openhands_logger as logger +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken +from openhands.integrations.service_types import ProviderType from openhands.integrations.utils import validate_provider_token from openhands.server.settings import ( GETCustomSecrets, @@ -9,6 +11,7 @@ from openhands.server.settings import ( POSTProviderModel, ) from openhands.server.user_auth import ( + get_provider_tokens, get_secrets_store, get_user_secrets, ) @@ -51,24 +54,44 @@ async def invalidate_legacy_secrets_store( return None -async def check_provider_tokens(provider_info: POSTProviderModel) -> str: - if provider_info.provider_tokens: - # Determine whether tokens are valid - for token_type, token_value in provider_info.provider_tokens.items(): - if token_value.token: - confirmed_token_type = await validate_provider_token(token_value.token) - if not confirmed_token_type or confirmed_token_type != token_type: - return f'Invalid token. Please make sure it is a valid {token_type.value} token.' - +def process_token_validation_result( + confirmed_token_type: ProviderType | None, + token_type: ProviderType): + + if not confirmed_token_type or confirmed_token_type != token_type: + return f'Invalid token. Please make sure it is a valid {token_type.value} token.' + return '' +async def check_provider_tokens( + incoming_provider_tokens: POSTProviderModel, + existing_provider_tokens: PROVIDER_TOKEN_TYPE) -> str: + + msg = '' + if incoming_provider_tokens.provider_tokens: + # Determine whether tokens are valid + for token_type, token_value in incoming_provider_tokens.provider_tokens.items(): + if token_value.token: + confirmed_token_type = await validate_provider_token(token_value.token, token_value.host) # FE always sends latest host + msg = process_token_validation_result(confirmed_token_type, token_type) + + existing_token = existing_provider_tokens.get(token_type, None) + if existing_token and (existing_token.host != token_value.host) and existing_token.token: + confirmed_token_type = await validate_provider_token(existing_token.token, token_value.host) # Host has changed, check it against existing token + if not confirmed_token_type or confirmed_token_type != token_type: + msg = process_token_validation_result(confirmed_token_type, token_type) + + return msg + @app.post('/add-git-providers') async def store_provider_tokens( provider_info: POSTProviderModel, secrets_store: SecretsStore = Depends(get_secrets_store), + provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens) ) -> JSONResponse: - provider_err_msg = await check_provider_tokens(provider_info) + + provider_err_msg = await check_provider_tokens(provider_info, provider_tokens) if provider_err_msg: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, @@ -90,8 +113,7 @@ async def store_provider_tokens( if existing_token and existing_token.token: provider_info.provider_tokens[provider] = existing_token - else: # nothing passed in means keep current settings - provider_info.provider_tokens = dict(user_secrets.provider_tokens) + provider_info.provider_tokens[provider] = provider_info.provider_tokens[provider].model_copy(update={'host': token_value.host}) updated_secrets = user_secrets.model_copy( update={'provider_tokens': provider_info.provider_tokens} diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 0418be060c..e9a5e270fc 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -11,6 +11,7 @@ from openhands.server.settings import ( GETSettingsModel, ) from openhands.server.shared import config +from openhands.server.types import AppMode from openhands.server.user_auth import ( get_provider_tokens, get_secrets_store, @@ -19,6 +20,7 @@ from openhands.server.user_auth import ( from openhands.storage.data_models.settings import Settings from openhands.storage.secrets.secrets_store import SecretsStore from openhands.storage.settings.settings_store import SettingsStore +from openhands.server.shared import server_config app = APIRouter(prefix='/api') @@ -38,10 +40,13 @@ async def load_settings( content={'error': 'Settings not found'}, ) + # On initial load, user secrets may not be populated with values migrated from settings store user_secrets = await invalidate_legacy_secrets_store( settings, settings_store, secrets_store ) + + # If invalidation is successful, then the returned user secrets holds the most recent values git_providers = ( user_secrets.provider_tokens if user_secrets else provider_tokens @@ -51,7 +56,8 @@ async def load_settings( if git_providers: for provider_type, provider_token in git_providers.items(): if provider_token.token or provider_token.user_id: - provider_tokens_set[provider_type] = None + provider_tokens_set[provider_type] = provider_token.host + settings_with_token_data = GETSettingsModel( **settings.model_dump(exclude='secrets_store'), diff --git a/openhands/server/settings.py b/openhands/server/settings.py index 2113453356..16629be3d5 100644 --- a/openhands/server/settings.py +++ b/openhands/server/settings.py @@ -38,6 +38,8 @@ class GETSettingsModel(Settings): ) llm_api_key_set: bool + model_config = {'use_enum_values': True} + class GETCustomSecrets(BaseModel): """ diff --git a/openhands/storage/data_models/user_secrets.py b/openhands/storage/data_models/user_secrets.py index a67528a5ff..557b72497b 100644 --- a/openhands/storage/data_models/user_secrets.py +++ b/openhands/storage/data_models/user_secrets.py @@ -45,7 +45,7 @@ class UserSecrets(BaseModel): expose_secrets = info.context and info.context.get('expose_secrets', False) for token_type, provider_token in provider_tokens.items(): - if not provider_token or not provider_token.token: + if not provider_token: continue token_type_str = ( @@ -53,11 +53,16 @@ class UserSecrets(BaseModel): if isinstance(token_type, ProviderType) else str(token_type) ) + + token = None + if provider_token.token: + token = provider_token.token.get_secret_value() if expose_secrets else pydantic_encoder(provider_token.token) + tokens[token_type_str] = { - 'token': provider_token.token.get_secret_value() - if expose_secrets - else pydantic_encoder(provider_token.token), + 'token': token, + 'host': provider_token.host, 'user_id': provider_token.user_id, + } return tokens diff --git a/tests/unit/test_secrets_api.py b/tests/unit/test_secrets_api.py index ded24d3209..2d8efee37c 100644 --- a/tests/unit/test_secrets_api.py +++ b/tests/unit/test_secrets_api.py @@ -283,3 +283,152 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store) # Check that other settings were preserved assert ProviderType.GITHUB in stored_settings.provider_tokens + + +@pytest.mark.asyncio +async def test_add_git_providers_with_host(test_client, file_secrets_store): + """Test adding git providers with host parameter.""" + # Create initial user secrets + provider_tokens = { + ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token')) + } + user_secrets = UserSecrets(provider_tokens=provider_tokens) + await file_secrets_store.store(user_secrets) + + # Mock check_provider_tokens to return empty string (no error) + with patch( + 'openhands.server.routes.secrets.check_provider_tokens', + AsyncMock(return_value=''), + ): + # Add a GitHub provider with a host + add_provider_data = { + 'provider_tokens': { + 'github': {'token': 'new-github-token', 'host': 'github.enterprise.com'} + } + } + response = test_client.post('/api/add-git-providers', json=add_provider_data) + assert response.status_code == 200 + + # Verify that the settings were stored with the new provider token and host + stored_secrets = await file_secrets_store.load() + assert ProviderType.GITHUB in stored_secrets.provider_tokens + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value() + == 'new-github-token' + ) + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].host + == 'github.enterprise.com' + ) + + +@pytest.mark.asyncio +async def test_add_git_providers_update_host_only(test_client, file_secrets_store): + """Test updating only the host for an existing provider token.""" + # Create initial user secrets with a token + provider_tokens = { + ProviderType.GITHUB: ProviderToken( + token=SecretStr('github-token'), host='github.com' + ) + } + user_secrets = UserSecrets(provider_tokens=provider_tokens) + await file_secrets_store.store(user_secrets) + + # Mock check_provider_tokens to return empty string (no error) + with patch( + 'openhands.server.routes.secrets.check_provider_tokens', + AsyncMock(return_value=''), + ): + # Update only the host + update_host_data = { + 'provider_tokens': { + 'github': { + 'token': '', # Empty token means keep existing token + 'host': 'github.enterprise.com', + } + } + } + response = test_client.post('/api/add-git-providers', json=update_host_data) + assert response.status_code == 200 + + # Verify that the host was updated but the token remains the same + stored_secrets = await file_secrets_store.load() + assert ProviderType.GITHUB in stored_secrets.provider_tokens + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value() + == 'github-token' + ) + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].host + == 'github.enterprise.com' + ) + + +@pytest.mark.asyncio +async def test_add_git_providers_invalid_token_with_host( + test_client, file_secrets_store +): + """Test adding an invalid token with a host.""" + # Create initial user secrets + user_secrets = UserSecrets() + await file_secrets_store.store(user_secrets) + + # Mock validate_provider_token to return None (invalid token) + with patch( + 'openhands.integrations.utils.validate_provider_token', + AsyncMock(return_value=None), + ): + # Try to add an invalid GitHub provider with a host + add_provider_data = { + 'provider_tokens': { + 'github': {'token': 'invalid-token', 'host': 'github.enterprise.com'} + } + } + response = test_client.post('/api/add-git-providers', json=add_provider_data) + assert response.status_code == 401 + assert 'Invalid token' in response.json()['error'] + + +@pytest.mark.asyncio +async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_store): + """Test adding multiple git providers with different hosts.""" + # Create initial user secrets + user_secrets = UserSecrets() + await file_secrets_store.store(user_secrets) + + # Mock check_provider_tokens to return empty string (no error) + with patch( + 'openhands.server.routes.secrets.check_provider_tokens', + AsyncMock(return_value=''), + ): + # Add multiple providers with hosts + add_providers_data = { + 'provider_tokens': { + 'github': {'token': 'github-token', 'host': 'github.enterprise.com'}, + 'gitlab': {'token': 'gitlab-token', 'host': 'gitlab.enterprise.com'}, + } + } + response = test_client.post('/api/add-git-providers', json=add_providers_data) + assert response.status_code == 200 + + # Verify that both providers were stored with their respective hosts + stored_secrets = await file_secrets_store.load() + assert ProviderType.GITHUB in stored_secrets.provider_tokens + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value() + == 'github-token' + ) + assert ( + stored_secrets.provider_tokens[ProviderType.GITHUB].host + == 'github.enterprise.com' + ) + + assert ProviderType.GITLAB in stored_secrets.provider_tokens + assert ( + stored_secrets.provider_tokens[ProviderType.GITLAB].token.get_secret_value() + == 'gitlab-token' + ) + assert ( + stored_secrets.provider_tokens[ProviderType.GITLAB].host + == 'gitlab.enterprise.com' + ) diff --git a/tests/unit/test_settings_store_functions.py b/tests/unit/test_settings_store_functions.py index 959466e30e..4a0bf0f4ff 100644 --- a/tests/unit/test_settings_store_functions.py +++ b/tests/unit/test_settings_store_functions.py @@ -60,13 +60,16 @@ async def test_check_provider_tokens_valid(): provider_token = ProviderToken(token=SecretStr('valid-token')) providers = POSTProviderModel(provider_tokens={ProviderType.GITHUB: provider_token}) + # Empty existing provider tokens + existing_provider_tokens = {} + # Mock the validate_provider_token function to return GITHUB for valid tokens with patch( 'openhands.server.routes.secrets.validate_provider_token' ) as mock_validate: mock_validate.return_value = ProviderType.GITHUB - result = await check_provider_tokens(providers) + result = await check_provider_tokens(providers, existing_provider_tokens) # Should return empty string for valid token assert result == '' @@ -79,13 +82,16 @@ async def test_check_provider_tokens_invalid(): provider_token = ProviderToken(token=SecretStr('invalid-token')) providers = POSTProviderModel(provider_tokens={ProviderType.GITHUB: provider_token}) + # Empty existing provider tokens + existing_provider_tokens = {} + # Mock the validate_provider_token function to return None for invalid tokens with patch( 'openhands.server.routes.secrets.validate_provider_token' ) as mock_validate: mock_validate.return_value = None - result = await check_provider_tokens(providers) + result = await check_provider_tokens(providers, existing_provider_tokens) # Should return error message for invalid token assert 'Invalid token' in result @@ -98,7 +104,11 @@ async def test_check_provider_tokens_wrong_type(): # We can't test with an unsupported provider type directly since the model enforces valid types # Instead, we'll test with an empty provider_tokens dictionary providers = POSTProviderModel(provider_tokens={}) - result = await check_provider_tokens(providers) + + # Empty existing provider tokens + existing_provider_tokens = {} + + result = await check_provider_tokens(providers, existing_provider_tokens) # Should return empty string for no providers assert result == '' @@ -109,7 +119,10 @@ async def test_check_provider_tokens_no_tokens(): """Test check_provider_tokens with no tokens.""" providers = POSTProviderModel(provider_tokens={}) - result = await check_provider_tokens(providers) + # Empty existing provider tokens + existing_provider_tokens = {} + + result = await check_provider_tokens(providers, existing_provider_tokens) # Should return empty string when no tokens provided assert result == ''