[Feat]: Support self hosted gitlab + enterprise github (#8274)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Rohit Malhotra
2025-05-08 17:54:32 -04:00
committed by GitHub
parent c982bc6692
commit 6d1e1f75ae
17 changed files with 393 additions and 87 deletions

View File

@@ -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,
},
});

View File

@@ -64,7 +64,7 @@ describe("HomeScreen", () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
github: null,
gitlab: null,
},
});

View File

@@ -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"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}

View File

@@ -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({
}
/>
<SettingsInput
onChange={onGitHubHostChange || (() => {})}
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() !== "" ? (
<KeyStatusIcon testId="gh-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gh-set-host-indicator" isSet={false} />
)
}
/>
<GitHubTokenHelpAnchor />
</div>
);

View File

@@ -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({
}
/>
<SettingsInput
onChange={onGitLabHostChange || (() => {})}
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() !== "" ? (
<KeyStatusIcon testId="gl-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gl-set-host-indicator" isSet={false} />
)
}
/>
<GitLabTokenHelpAnchor />
</div>
);

View File

@@ -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",

View File

@@ -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": "トークンを生成する",

View File

@@ -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 && (
<div className="p-9 flex flex-col gap-12">
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
)}
{!isSaas && (
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
onGitHubHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
}}
githubHostSet={existingGithubHost}
/>
)}
{!isSaas && (
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
onGitLabHostChange={(value) => {
setGitlabHostInputHasValue(!!value);
}}
gitlabHostSet={existingGitlabHost}
/>
)}
</div>
)}
{isLoading && <GitSettingInputsSkeleton />}
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
)}
{!isSaas && !isLoading && (
<div className="p-9 flex flex-col gap-12">
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
/>
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
/>
</div>
)}
{!shouldRenderExternalConfigureButtons && (
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
>
Disconnect Tokens
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
)}
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
{!shouldRenderExternalConfigureButtons && (
<>
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
>
Disconnect Tokens
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</>
)}
</div>
</form>
);
}

View File

@@ -7,6 +7,7 @@ export type Provider = keyof typeof ProviderOptions;
export type ProviderToken = {
token: string;
host: string | null;
};
export type MCPSSEServer = {

View File

@@ -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

View File

@@ -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]

View File

@@ -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}

View File

@@ -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'),

View File

@@ -38,6 +38,8 @@ class GETSettingsModel(Settings):
)
llm_api_key_set: bool
model_config = {'use_enum_values': True}
class GETCustomSecrets(BaseModel):
"""

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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 == ''