diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOps.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOps.java index 05c877a54e1..27a41024b9a 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOps.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOps.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2023 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -23,6 +23,8 @@ public class AzureDevOps { /** Name of this OAuth provider as found in OAuthAPI. */ public static final String PROVIDER_NAME = "azure-devops"; + /** Azure DevOps SAAS endpoint. */ + public static final String SAAS_ENDPOINT = "https://dev.azure.com"; /** Azure DevOps Service API version calls. */ public static final String API_VERSION = "7.0"; diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java index 3cafb79ba83..7b63fecb0db 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java @@ -51,7 +51,7 @@ public class AzureDevOpsPersonalAccessTokenFetcher implements PersonalAccessToke LoggerFactory.getLogger(AzureDevOpsPersonalAccessTokenFetcher.class); private static final String OAUTH_PROVIDER_NAME = "azure-devops"; private final String cheApiEndpoint; - private final String azureDevOpsScmApiEndpoint; + private final String azureDevOpsSAASApiEndpoint; private final OAuthAPI oAuthAPI; private final String[] scopes; @@ -60,12 +60,12 @@ public class AzureDevOpsPersonalAccessTokenFetcher implements PersonalAccessToke @Inject public AzureDevOpsPersonalAccessTokenFetcher( @Named("che.api") String cheApiEndpoint, - @Named("che.integration.azure.devops.scm.api_endpoint") String azureDevOpsScmApiEndpoint, + @Named("che.integration.azure.devops.scm.api_endpoint") String azureDevOpsSAASApiEndpoint, @Named("che.integration.azure.devops.application_scopes") String[] scopes, AzureDevOpsApiClient azureDevOpsApiClient, OAuthAPI oAuthAPI) { this.cheApiEndpoint = cheApiEndpoint; - this.azureDevOpsScmApiEndpoint = trimEnd(azureDevOpsScmApiEndpoint, '/'); + this.azureDevOpsSAASApiEndpoint = trimEnd(azureDevOpsSAASApiEndpoint, '/'); this.oAuthAPI = oAuthAPI; this.scopes = scopes; this.azureDevOpsApiClient = azureDevOpsApiClient; @@ -88,7 +88,7 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken( throws ScmUnauthorizedException, ScmCommunicationException, UnknownScmProviderException { OAuthToken oAuthToken; - if (!isValidScmServerUrl(scmServerUrl)) { + if (!isValidAzureDevOpsSAASUrl(scmServerUrl)) { LOG.debug("not a valid url {} for current fetcher ", scmServerUrl); return null; } @@ -147,7 +147,7 @@ private ScmUnauthorizedException buildScmUnauthorizedException(Subject cheSubjec @Override public Optional isValid(PersonalAccessToken personalAccessToken) { - if (!isValidScmServerUrl(personalAccessToken.getScmProviderUrl())) { + if (!isValidAzureDevOpsSAASUrl(personalAccessToken.getScmProviderUrl())) { LOG.debug("not a valid url {} for current fetcher ", personalAccessToken.getScmProviderUrl()); return Optional.empty(); } @@ -174,9 +174,20 @@ public Optional isValid(PersonalAccessToken personalAccessToken) { @Override public Optional> isValid(PersonalAccessTokenParams params) throws ScmCommunicationException { - if (!isValidScmServerUrl(params.getScmProviderUrl())) { - LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl()); - return Optional.empty(); + if (!isValidAzureDevOpsSAASUrl(params.getScmProviderUrl())) { + if (OAUTH_PROVIDER_NAME.equals(params.getScmProviderName())) { + AzureDevOpsServerApiClient azureDevOpsServerApiClient = + new AzureDevOpsServerApiClient(params.getScmProviderUrl(), params.getOrganization()); + try { + AzureDevOpsServerUserProfile user = azureDevOpsServerApiClient.getUser(params.getToken()); + return Optional.of(Pair.of(Boolean.TRUE, user.getIdentity().getAccountName())); + } catch (ScmItemNotFoundException | ScmBadRequestException e) { + return Optional.empty(); + } + } else { + LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl()); + return Optional.empty(); + } } try { @@ -196,7 +207,7 @@ private String getLocalAuthenticateUrl() { return cheApiEndpoint + getAuthenticateUrlPath(scopes); } - private Boolean isValidScmServerUrl(String scmServerUrl) { - return azureDevOpsScmApiEndpoint.equals(trimEnd(scmServerUrl, '/')); + private Boolean isValidAzureDevOpsSAASUrl(String url) { + return azureDevOpsSAASApiEndpoint.equals(trimEnd(url, '/')); } } diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerApiClient.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerApiClient.java new file mode 100644 index 00000000000..b6a8118d85a --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerApiClient.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2012-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.api.factory.server.azure.devops; + +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.Duration.ofSeconds; +import static org.eclipse.che.commons.lang.StringUtils.trimEnd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Charsets; +import com.google.common.io.CharStreams; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Base64; +import java.util.concurrent.Executors; +import java.util.function.Function; +import org.eclipse.che.api.factory.server.scm.exception.ScmBadRequestException; +import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException; +import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException; +import org.eclipse.che.commons.lang.concurrent.LoggingUncaughtExceptionHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Azure DevOps Server API operations helper. */ +public class AzureDevOpsServerApiClient { + + private static final Logger LOG = LoggerFactory.getLogger(AzureDevOpsServerApiClient.class); + + private final HttpClient httpClient; + private final String azureDevOpsServerApiEndpoint; + private final String azureDevOpsServerCollection; + private static final Duration DEFAULT_HTTP_TIMEOUT = ofSeconds(10); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public AzureDevOpsServerApiClient( + String azureDevOpsServerApiEndpoint, String azureDevOpsServerCollection) { + this.azureDevOpsServerApiEndpoint = trimEnd(azureDevOpsServerApiEndpoint, '/'); + this.azureDevOpsServerCollection = azureDevOpsServerCollection; + this.httpClient = + HttpClient.newBuilder() + .executor( + Executors.newCachedThreadPool( + new ThreadFactoryBuilder() + .setUncaughtExceptionHandler(LoggingUncaughtExceptionHandler.getInstance()) + .setNameFormat(AzureDevOpsServerApiClient.class.getName() + "-%d") + .setDaemon(true) + .build())) + .connectTimeout(DEFAULT_HTTP_TIMEOUT) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + /** + * Returns the user associated with the provided PAT. + * + * @param token personal access token. + */ + public AzureDevOpsServerUserProfile getUser(String token) + throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException { + final String url = + String.format( + "%s/%s/_api/_common/GetUserProfile", + azureDevOpsServerApiEndpoint, azureDevOpsServerCollection); + return getUser(url, encodeAuthorizationHeader(token)); + } + + private static String encodeAuthorizationHeader(String token) { + return "Basic " + Base64.getEncoder().encodeToString((":" + token).getBytes(UTF_8)); + } + + private AzureDevOpsServerUserProfile getUser(String url, String authorizationHeader) + throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException { + final HttpRequest userDataRequest = + HttpRequest.newBuilder(URI.create(url)) + .headers("Authorization", authorizationHeader) + .timeout(DEFAULT_HTTP_TIMEOUT) + .build(); + + LOG.trace("executeRequest={}", userDataRequest); + return executeRequest( + httpClient, + userDataRequest, + response -> { + try { + String result = + CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8)); + return OBJECT_MAPPER.readValue(result, AzureDevOpsServerUserProfile.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + private T executeRequest( + HttpClient httpClient, + HttpRequest request, + Function, T> responseConverter) + throws ScmBadRequestException, ScmItemNotFoundException, ScmCommunicationException { + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + LOG.trace("executeRequest={} response {}", request, response.statusCode()); + if (response.statusCode() == HTTP_OK) { + return responseConverter.apply(response); + } else if (response.statusCode() == HTTP_NO_CONTENT) { + return null; + } else { + String body = CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8)); + switch (response.statusCode()) { + case HTTP_BAD_REQUEST: + throw new ScmBadRequestException(body); + case HTTP_NOT_FOUND: + throw new ScmItemNotFoundException(body); + default: + throw new ScmCommunicationException( + "Unexpected status code " + response.statusCode() + " " + response, + response.statusCode(), + "azure-devops"); + } + } + } catch (IOException | InterruptedException | UncheckedIOException e) { + throw new ScmCommunicationException(e.getMessage(), e); + } + } +} diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserIdentity.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserIdentity.java new file mode 100644 index 00000000000..e0e5423a807 --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserIdentity.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.api.factory.server.azure.devops; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Azure DevOps Server user's identity. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AzureDevOpsServerUserIdentity { + private String accountName; + private String mailAddress; + + public String getAccountName() { + return accountName; + } + + @JsonProperty("AccountName") + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getMailAddress() { + return mailAddress; + } + + @JsonProperty("MailAddress") + public void setMailAddress(String mailAddress) { + this.mailAddress = mailAddress; + } + + @Override + public String toString() { + return "AzureDevOpsServerUserIdentity{" + + "accountName='" + + accountName + + '\'' + + ", mailAddress='" + + mailAddress + + '\'' + + '}'; + } +} diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserPreferences.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserPreferences.java new file mode 100644 index 00000000000..cbcd7570b3f --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserPreferences.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.api.factory.server.azure.devops; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Azure DevOps Server user's preferences. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AzureDevOpsServerUserPreferences { + private String preferredEmail; + + public String getPreferredEmail() { + return preferredEmail; + } + + @JsonProperty("PreferredEmail") + public void setPreferredEmail(String preferredEmail) { + this.preferredEmail = preferredEmail; + } + + @Override + public String toString() { + return "AzureDevOpsServerUserPreferences{" + "preferredEmail='" + preferredEmail + '\'' + '}'; + } +} diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserProfile.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserProfile.java new file mode 100644 index 00000000000..764c678be52 --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsServerUserProfile.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2012-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.api.factory.server.azure.devops; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Azure DevOps Server user's profile. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AzureDevOpsServerUserProfile { + private AzureDevOpsServerUserIdentity identity; + private AzureDevOpsServerUserPreferences userPreferences; + private String defaultMailAddress; + + public AzureDevOpsServerUserIdentity getIdentity() { + return identity; + } + + public void setIdentity(AzureDevOpsServerUserIdentity identity) { + this.identity = identity; + } + + public String getDefaultMailAddress() { + return defaultMailAddress; + } + + public void setDefaultMailAddress(String defaultMailAddress) { + this.defaultMailAddress = defaultMailAddress; + } + + public AzureDevOpsServerUserPreferences getUserPreferences() { + return userPreferences; + } + + public void setUserPreferences(AzureDevOpsServerUserPreferences userPreferences) { + this.userPreferences = userPreferences; + } + + @Override + public String toString() { + return "AzureDevOpsServerUserProfile{" + + "identity=" + + identity + + ", userPreferences=" + + userPreferences + + ", defaultMailAddress='" + + defaultMailAddress + + '\'' + + '}'; + } +} diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParser.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParser.java index b3e5a4e3774..7b2cc7125d9 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParser.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParser.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2023 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -17,12 +17,19 @@ import static org.eclipse.che.commons.lang.StringUtils.trimEnd; import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; +import org.eclipse.che.api.factory.server.scm.PersonalAccessToken; +import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager; +import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException; +import org.eclipse.che.api.factory.server.scm.exception.ScmConfigurationPersistenceException; import org.eclipse.che.api.factory.server.urlfactory.DevfileFilenamesProvider; +import org.eclipse.che.commons.env.EnvironmentContext; /** * Parser of String Azure DevOps URLs and provide {@link AzureDevOpsUrl} objects. @@ -33,6 +40,7 @@ public class AzureDevOpsURLParser { private final DevfileFilenamesProvider devfileFilenamesProvider; + private final PersonalAccessTokenManager tokenManager; private final String azureDevOpsScmApiEndpointHost; /** * Regexp to find repository details (repository name, organization name and branch or tag) @@ -41,45 +49,122 @@ public class AzureDevOpsURLParser { private final Pattern azureDevOpsPattern; private final Pattern azureSSHDevOpsPattern; + private final String azureSSHDevOpsPatternTemplate = + "^git@ssh\\.%s:v3/(?.*)/(?.*)/(?.*)$"; + private final String azureSSHDevOpsServerPatternTemplate = + "^ssh://%s:22/(?.*)/(?.*)/_git/(?.*)$"; + private final String azureDevOpsPatternTemplate = + "^https?://(?[^@]++)?@?%s/(?[^/]++)/((?[^/]++)/)?_git/" + + "(?[^?]++)" + + "([?&]path=(?[^&]++))?" + + "([?&]version=GT(?[^&]++))?" + + "([?&]version=GB(?[^&]++))?" + + "(.*)"; + private static final String PROVIDER_NAME = "azure-devops"; @Inject public AzureDevOpsURLParser( DevfileFilenamesProvider devfileFilenamesProvider, + PersonalAccessTokenManager tokenManager, @Named("che.integration.azure.devops.scm.api_endpoint") String azureDevOpsScmApiEndpoint) { this.devfileFilenamesProvider = devfileFilenamesProvider; + this.tokenManager = tokenManager; this.azureDevOpsScmApiEndpointHost = - trimEnd(azureDevOpsScmApiEndpoint, '/').substring("https://".length()); + trimEnd(azureDevOpsScmApiEndpoint, '/').replaceFirst("https?://", ""); this.azureDevOpsPattern = - compile( - format( - "^https://(?[^@]++)?@?%s/(?[^/]++)/((?[^/]++)/)?_git/" - + "(?[^?]++)" - + "([?&]path=(?[^&]++))?" - + "([?&]version=GT(?[^&]++))?" - + "([?&]version=GB(?[^&]++))?" - + "(.*)", - azureDevOpsScmApiEndpointHost)); + compile(format(azureDevOpsPatternTemplate, azureDevOpsScmApiEndpointHost)); this.azureSSHDevOpsPattern = - compile( - format( - "^git@ssh\\.%s:v3/(?.*)/(?.*)/(?.*)$", - azureDevOpsScmApiEndpointHost)); + compile(format(azureSSHDevOpsPatternTemplate, azureDevOpsScmApiEndpointHost)); } public boolean isValid(@NotNull String url) { - return azureDevOpsPattern.matcher(url).matches() - || azureSSHDevOpsPattern.matcher(url).matches(); + String trimmedUrl = trimEnd(url, '/'); + return azureDevOpsPattern.matcher(trimmedUrl).matches() + || azureSSHDevOpsPattern.matcher(trimmedUrl).matches() + // Check whether PAT is configured for the Azure Devops Server URL. It is sufficient to + // confirm + // that the URL is a valid Azure Devops Server URL. + || isUserTokenPresent(trimmedUrl); + } + + // Try to find the given url in a manually added user namespace token secret. + private boolean isUserTokenPresent(String repositoryUrl) { + Optional serverUrlOptional = getServerUrl(repositoryUrl); + if (serverUrlOptional.isPresent()) { + String serverUrl = serverUrlOptional.get(); + try { + Optional token = + tokenManager.get(EnvironmentContext.getCurrent().getSubject(), serverUrl); + if (token.isPresent()) { + PersonalAccessToken accessToken = token.get(); + return accessToken.getScmTokenName().equals(PROVIDER_NAME); + } + } catch (ScmConfigurationPersistenceException | ScmCommunicationException exception) { + return false; + } + } + return false; + } + + private Optional getServerUrl(String repositoryUrl) { + // If the given repository url is an SSH url, generate the base url from the pattern: + // https://. + String substring = null; + if (repositoryUrl.startsWith("git@ssh.")) { + substring = repositoryUrl.substring(8); + } else if (repositoryUrl.startsWith("ssh://")) { + substring = repositoryUrl.substring(6); + } + if (!isNullOrEmpty(substring)) { + return Optional.of("https://" + substring.substring(0, substring.indexOf(":"))); + } + // Otherwise, extract the base url from the given repository url by cutting the url after the + // first slash. + Matcher serverUrlMatcher = compile("[^/|:]/").matcher(repositoryUrl); + if (serverUrlMatcher.find()) { + return Optional.of( + repositoryUrl.substring(0, repositoryUrl.indexOf(serverUrlMatcher.group()) + 1)); + } + return Optional.empty(); + } + + private Optional getPatternMatcherByUrl(String url) { + String host = URI.create(url).getHost(); + Matcher matcher = compile(format(azureDevOpsPatternTemplate, host)).matcher(url); + if (matcher.matches()) { + return Optional.of(matcher); + } else { + matcher = compile(format(azureSSHDevOpsPatternTemplate, host)).matcher(url); + if (matcher.matches()) { + return Optional.of(matcher); + } else { + matcher = compile(format(azureSSHDevOpsServerPatternTemplate, host)).matcher(url); + } + return matcher.matches() ? Optional.of(matcher) : Optional.empty(); + } + } + + private IllegalArgumentException buildIllegalArgumentException(String url) { + return new IllegalArgumentException( + format("The given url %s is not a valid Azure DevOps URL. ", url)); } public AzureDevOpsUrl parse(String url) { + Matcher matcher; boolean isHTTPSUrl = azureDevOpsPattern.matcher(url).matches(); - Matcher matcher = - isHTTPSUrl ? azureDevOpsPattern.matcher(url) : azureSSHDevOpsPattern.matcher(url); + if (isHTTPSUrl) { + matcher = azureDevOpsPattern.matcher(url); + } else if (azureSSHDevOpsPattern.matcher(url).matches()) { + matcher = azureSSHDevOpsPattern.matcher(url); + } else { + matcher = getPatternMatcherByUrl(url).orElseThrow(() -> buildIllegalArgumentException(url)); + isHTTPSUrl = url.startsWith("http"); + } if (!matcher.matches()) { - throw new IllegalArgumentException(format("The given url %s is not a valid.", url)); + throw buildIllegalArgumentException(url); } - + String serverUrl = getServerUrl(url).orElseThrow(() -> buildIllegalArgumentException(url)); String repoName = matcher.group("repoName"); String project = matcher.group("project"); if (project == null) { @@ -93,6 +178,7 @@ public AzureDevOpsUrl parse(String url) { String tag = null; String organization = matcher.group("organization"); + String urlToReturn = url; if (isHTTPSUrl) { branch = matcher.group("branch"); tag = matcher.group("tag"); @@ -104,12 +190,14 @@ public AzureDevOpsUrl parse(String url) { // TODO: return empty credentials like the BitBucketUrl String organizationCanIgnore = matcher.group("organizationCanIgnore"); if (!isNullOrEmpty(organization) && organization.equals(organizationCanIgnore)) { - url = url.replace(organizationCanIgnore + "@", ""); + urlToReturn = urlToReturn.replace(organizationCanIgnore + "@", ""); + serverUrl = serverUrl.replace(organizationCanIgnore + "@", ""); } } return new AzureDevOpsUrl() - .withHostName(azureDevOpsScmApiEndpointHost) + .withHostName( + url.startsWith("git@ssh.") ? azureDevOpsScmApiEndpointHost : URI.create(url).getHost()) .setIsHTTPSUrl(isHTTPSUrl) .withProject(project) .withRepository(repoName) @@ -117,6 +205,7 @@ public AzureDevOpsUrl parse(String url) { .withBranch(branch) .withTag(tag) .withDevfileFilenames(devfileFilenamesProvider.getConfiguredDevfileFilenames()) - .withUrl(url); + .withServerUrl(serverUrl) + .withUrl(urlToReturn); } } diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUrl.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUrl.java index 6ae07240a65..8cd29bbf919 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUrl.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUrl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2023 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -43,6 +43,8 @@ public class AzureDevOpsUrl extends DefaultFactoryUrl { private String tag; + private String serverUrl; + private final List devfileFilenames = new ArrayList<>(); protected AzureDevOpsUrl() {} @@ -100,7 +102,7 @@ public AzureDevOpsUrl withBranch(String branch) { @Override public String getProviderUrl() { - return "https://" + hostName; + return isNullOrEmpty(serverUrl) ? "https://" + hostName : serverUrl; } protected AzureDevOpsUrl withDevfileFilenames(List devfileFilenames) { @@ -108,6 +110,11 @@ protected AzureDevOpsUrl withDevfileFilenames(List devfileFilenames) { return this; } + public AzureDevOpsUrl withServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + return this; + } + @Override public void setDevfileFilename(String devfileName) { this.devfileFilenames.clear(); @@ -154,7 +161,11 @@ public String getRepositoryLocation() { if (isHTTPSUrl) { return getRepoPathJoiner().add("_git").add(repository).toString(); } - return "git@ssh." + hostName + ":v3/" + organization + "/" + project + "/" + repository; + if ("dev.azure.com".equals(hostName)) { + return "git@ssh." + hostName + ":v3/" + organization + "/" + project + "/" + repository; + } else { + return "ssh://" + hostName + ":22/" + organization + "/" + project + "/_git/" + repository; + } } private StringJoiner getRepoPathJoiner() { diff --git a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUserDataFetcher.java b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUserDataFetcher.java index cb50ca53445..ccd9adf65ca 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUserDataFetcher.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsUserDataFetcher.java @@ -11,6 +11,8 @@ */ package org.eclipse.che.api.factory.server.azure.devops; +import static com.google.common.base.Strings.isNullOrEmpty; +import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.SAAS_ENDPOINT; import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.getAuthenticateUrlPath; import javax.inject.Inject; @@ -33,6 +35,8 @@ public class AzureDevOpsUserDataFetcher extends AbstractGitUserDataFetcher { private final String cheApiEndpoint; private final String[] scopes; private final AzureDevOpsApiClient azureDevOpsApiClient; + private static final String NO_USERNAME_AND_EMAIL_ERROR_MESSAGE = + "User name and/or email is not found in the azure devops profile."; @Inject public AzureDevOpsUserDataFetcher( @@ -60,10 +64,34 @@ protected GitUserData fetchGitUserDataWithPersonalAccessToken( PersonalAccessToken personalAccessToken) throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException, ScmUnauthorizedException { - AzureDevOpsUser user = - azureDevOpsApiClient.getUserWithPAT( - personalAccessToken.getToken(), personalAccessToken.getScmOrganization()); - return new GitUserData(user.getDisplayName(), user.getEmailAddress()); + if (SAAS_ENDPOINT.equals(personalAccessToken.getScmProviderUrl())) { + AzureDevOpsUser user = + azureDevOpsApiClient.getUserWithPAT( + personalAccessToken.getToken(), personalAccessToken.getScmOrganization()); + if (isNullOrEmpty(user.getDisplayName()) || isNullOrEmpty(user.getEmailAddress())) { + throw new ScmItemNotFoundException(NO_USERNAME_AND_EMAIL_ERROR_MESSAGE); + } else { + return new GitUserData(user.getDisplayName(), user.getEmailAddress()); + } + } else { + AzureDevOpsServerApiClient apiClient = + new AzureDevOpsServerApiClient( + personalAccessToken.getScmProviderUrl(), personalAccessToken.getScmOrganization()); + AzureDevOpsServerUserProfile user = apiClient.getUser(personalAccessToken.getToken()); + String defaultMailAddress = user.getDefaultMailAddress(); + String identityMailAddress = user.getIdentity().getMailAddress(); + String preferredEmail = user.getUserPreferences().getPreferredEmail(); + String email = + isNullOrEmpty(defaultMailAddress) + ? (isNullOrEmpty(identityMailAddress) ? preferredEmail : identityMailAddress) + : defaultMailAddress; + String name = user.getIdentity().getAccountName(); + if (isNullOrEmpty(name) || isNullOrEmpty(email)) { + throw new ScmItemNotFoundException(NO_USERNAME_AND_EMAIL_ERROR_MESSAGE); + } else { + return new GitUserData(name, email); + } + } } protected String getLocalAuthenticateUrl() { diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcherTest.java b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcherTest.java index 996862d9cea..6cc3822f184 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcherTest.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcherTest.java @@ -19,6 +19,7 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static org.eclipse.che.api.factory.server.scm.PersonalAccessTokenFetcher.OAUTH_2_PREFIX; import static org.eclipse.che.dto.server.DtoFactory.newDto; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -30,9 +31,13 @@ import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.common.Slf4jNotifier; import com.google.common.net.HttpHeaders; +import java.util.Base64; +import java.util.Optional; import org.eclipse.che.api.auth.shared.dto.OAuthToken; import org.eclipse.che.api.factory.server.scm.PersonalAccessToken; +import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenParams; import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException; +import org.eclipse.che.commons.lang.Pair; import org.eclipse.che.commons.subject.Subject; import org.eclipse.che.commons.subject.SubjectImpl; import org.eclipse.che.security.oauth.OAuthAPI; @@ -52,6 +57,8 @@ public class AzureDevOpsPersonalAccessTokenFetcherTest { @Mock private OAuthToken oAuthToken; @Mock private AzureDevOpsUser azureDevOpsUser; + private final String azureDevOpsToken = "token"; + final int httpPort = 3301; WireMockServer wireMockServer; WireMock wireMock; @@ -134,4 +141,91 @@ public void shouldThrowUnauthorizedExceptionOnRedirectResponse() throws Exceptio personalAccessTokenFetcher.fetchPersonalAccessToken(subject, wireMockServer.url("/")); } + + @Test + public void shouldValidateSAASPersonalAccessToken() throws Exception { + stubFor( + get(urlEqualTo("/organization/_apis/profile/profiles/me?api-version=7.0")) + .withHeader( + HttpHeaders.AUTHORIZATION, + equalTo( + "Basic " + + Base64.getEncoder().encodeToString((":" + azureDevOpsToken).getBytes()))) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBodyFile("azure-devops/rest/user/response.json"))); + + PersonalAccessTokenParams params = + new PersonalAccessTokenParams( + wireMockServer.url("/"), + "azure-devops", + "token-name", + "tid-23434", + azureDevOpsToken, + "organization"); + + Optional> valid = personalAccessTokenFetcher.isValid(params); + assertTrue(valid.isPresent()); + assertTrue(valid.get().first); + } + + @Test + public void shouldValidateServerPersonalAccessToken() throws Exception { + personalAccessTokenFetcher = + new AzureDevOpsPersonalAccessTokenFetcher( + "localhost", + "https://dev.azure-server.com", + new String[] {}, + new AzureDevOpsApiClient(wireMockServer.url("/")), + oAuthAPI); + stubFor( + get(urlEqualTo("/organization/_api/_common/GetUserProfile")) + .withHeader( + HttpHeaders.AUTHORIZATION, + equalTo( + "Basic " + + Base64.getEncoder().encodeToString((":" + azureDevOpsToken).getBytes()))) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBodyFile("azure-devops-server/rest/user/response.json"))); + + PersonalAccessTokenParams params = + new PersonalAccessTokenParams( + wireMockServer.url("/"), + "azure-devops", + "token-name", + "tid-23434", + azureDevOpsToken, + "organization"); + + Optional> valid = personalAccessTokenFetcher.isValid(params); + assertTrue(valid.isPresent()); + assertTrue(valid.get().first); + } + + @Test + public void shouldValidateOauthToken() throws Exception { + stubFor( + get(urlEqualTo("/_apis/profile/profiles/me?api-version=7.0")) + .withHeader(HttpHeaders.AUTHORIZATION, equalTo("Bearer " + azureDevOpsToken)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBodyFile("azure-devops/rest/user/response.json"))); + + PersonalAccessTokenParams params = + new PersonalAccessTokenParams( + wireMockServer.url("/"), + "dev-azure", + OAUTH_2_PREFIX + "-token-name", + "tid-23434", + azureDevOpsToken, + "organization"); + + Optional> valid = personalAccessTokenFetcher.isValid(params); + assertTrue(valid.isPresent()); + assertTrue(valid.get().first); + } } diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParserTest.java b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParserTest.java index 7265d3baa66..d78dfe3cbc1 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParserTest.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLParserTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2023 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -15,6 +15,7 @@ import static org.testng.Assert.assertEquals; import java.util.Optional; +import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager; import org.eclipse.che.api.factory.server.urlfactory.DevfileFilenamesProvider; import org.mockito.testng.MockitoTestNGListener; import org.testng.annotations.BeforeMethod; @@ -31,7 +32,10 @@ public class AzureDevOpsURLParserTest { @BeforeMethod protected void start() { azureDevOpsURLParser = - new AzureDevOpsURLParser(mock(DevfileFilenamesProvider.class), "https://dev.azure.com/"); + new AzureDevOpsURLParser( + mock(DevfileFilenamesProvider.class), + mock(PersonalAccessTokenManager.class), + "https://dev.azure.com/"); } @Test(expectedExceptions = IllegalArgumentException.class) @@ -56,6 +60,23 @@ public void testParse( assertEquals(azureDevOpsUrl.getTag(), tag); } + @Test(dataProvider = "parsingServer") + public void testParseServer( + String url, + String organization, + String project, + String repository, + String branch, + String tag) { + AzureDevOpsUrl azureDevOpsUrl = azureDevOpsURLParser.parse(url); + + assertEquals(azureDevOpsUrl.getOrganization(), organization); + assertEquals(azureDevOpsUrl.getProject(), project); + assertEquals(azureDevOpsUrl.getRepository(), repository); + assertEquals(azureDevOpsUrl.getBranch(), branch); + assertEquals(azureDevOpsUrl.getTag(), tag); + } + @DataProvider(name = "parsing") public Object[][] expectedParsing() { return new Object[][] { @@ -193,6 +214,144 @@ public Object[][] expectedParsing() { }; } + @DataProvider(name = "parsingServer") + public Object[][] expectedServerParsing() { + return new Object[][] { + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo", + "MyOrg", + "MyProject", + "MyRepo", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.git", + "MyOrg", + "MyProject", + "MyRepo.git", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.dot.git", + "MyOrg", + "MyProject", + "MyRepo.dot.git", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo-with-hypen", + "MyOrg", + "MyProject", + "MyRepo-with-hypen", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-", "MyOrg", "MyProject", "-", null, null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-j.git", + "MyOrg", + "MyProject", + "-j.git", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain&_a=contents", + "MyOrg", + "MyProject", + "MyRepo", + "main", + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo", + "MyOrg", + "MyProject", + "MyRepo", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.git", + "MyOrg", + "MyProject", + "MyRepo.git", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.dot.git", + "MyOrg", + "MyProject", + "MyRepo.dot.git", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo", + "MyOrg", + "MyProject", + "MyRepo", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo-with-hypen", + "MyOrg", + "MyProject", + "MyRepo-with-hypen", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-", + "MyOrg", + "MyProject", + "-", + null, + null + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-j.git", + "MyOrg", + "MyProject", + "-j.git", + null, + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain", + "MyOrg", + "MyProject", + "MyRepo", + "main", + null + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag&_a=contents", + "MyOrg", + "MyProject", + "MyRepo", + null, + "MyTag" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag", + "MyOrg", + "MyProject", + "MyRepo", + null, + "MyTag" + }, + {"https://dev.azure-server.com/MyOrg/_git/MyRepo", "MyOrg", "MyRepo", "MyRepo", null, null}, + }; + } + @Test(dataProvider = "url") public void testCredentials(String url, String organization, Optional credentials) { AzureDevOpsUrl azureDevOpsUrl = azureDevOpsURLParser.parse(url); diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLTest.java b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLTest.java index 240c590df72..9f3b129f0df 100644 --- a/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLTest.java +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsURLTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2023 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -17,6 +17,7 @@ import java.util.Arrays; import java.util.Iterator; +import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager; import org.eclipse.che.api.factory.server.urlfactory.DevfileFilenamesProvider; import org.eclipse.che.api.factory.server.urlfactory.RemoteFactoryUrl; import org.mockito.testng.MockitoTestNGListener; @@ -32,7 +33,10 @@ public class AzureDevOpsURLTest { @BeforeMethod protected void init() { azureDevOpsURLParser = - new AzureDevOpsURLParser(mock(DevfileFilenamesProvider.class), "https://dev.azure.com/"); + new AzureDevOpsURLParser( + mock(DevfileFilenamesProvider.class), + mock(PersonalAccessTokenManager.class), + "https://dev.azure.com/"); } @Test(dataProvider = "urlsProvider") @@ -50,6 +54,21 @@ public void checkDevfileLocation(String repoUrl, String fileUrl) { assertEquals(location, format(fileUrl, "foo.bar")); } + @Test(dataProvider = "urlsProviderServer") + public void checkDevfileLocationServer(String repoUrl, String fileUrl) { + + AzureDevOpsUrl azureDevOpsUrl = + azureDevOpsURLParser + .parse(repoUrl) + .withDevfileFilenames(Arrays.asList("devfile.yaml", "foo.bar")); + assertEquals(azureDevOpsUrl.devfileFileLocations().size(), 2); + Iterator iterator = + azureDevOpsUrl.devfileFileLocations().iterator(); + String location = iterator.next().location(); + assertEquals(location, format(fileUrl, "devfile.yaml")); + assertEquals(location, format(fileUrl, "foo.bar")); + } + @DataProvider public static Object[][] urlsProvider() { return new Object[][] { @@ -132,12 +151,88 @@ public static Object[][] urlsProvider() { }; } + @DataProvider + public static Object[][] urlsProviderServer() { + return new Object[][] { + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.dot.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo.dot.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo-with-hypen", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo-with-hypen/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/-/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-j.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/-j.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.dot.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo.dot.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo-with-hypen", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo-with-hypen/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/-/items?path=/devfile.yaml&api-version=7.0" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-j.git", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/-j.git/items?path=/devfile.yaml&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain&_a=contents", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&versionType=branch&version=main&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&versionType=branch&version=main&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag&_a=contents", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&versionType=tag&version=MyTag&api-version=7.0" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag", + "https://dev.azure-server.com/MyOrg/MyProject/_apis/git/repositories/MyRepo/items?path=/devfile.yaml&versionType=tag&version=MyTag&api-version=7.0" + }, + }; + } + @Test(dataProvider = "repoProvider") public void checkRepositoryLocation(String rawUrl, String repoUrl) { AzureDevOpsUrl azureDevOpsUrl = azureDevOpsURLParser.parse(rawUrl); assertEquals(azureDevOpsUrl.getRepositoryLocation(), repoUrl); } + @Test(dataProvider = "repoProviderServer") + public void checkRepositoryLocationServer(String rawUrl, String repoUrl) { + AzureDevOpsUrl azureDevOpsUrl = azureDevOpsURLParser.parse(rawUrl); + assertEquals(azureDevOpsUrl.getRepositoryLocation(), repoUrl); + } + @DataProvider public static Object[][] repoProvider() { return new Object[][] { @@ -216,4 +311,74 @@ public static Object[][] repoProvider() { } }; } + + @DataProvider + public static Object[][] repoProviderServer() { + return new Object[][] { + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.git", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.git" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.dot.git", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo.dot.git" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo-with-hypen", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo-with-hypen" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-", + "https://dev.azure-server.com/MyOrg/MyProject/_git/-" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/-j.git", + "https://dev.azure-server.com/MyOrg/MyProject/_git/-j.git" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.git", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.git" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.dot.git", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo.dot.git" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo-with-hypen", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/MyRepo-with-hypen" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-" + }, + { + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-j.git", + "ssh://dev.azure-server.com:22/MyOrg/MyProject/_git/-j.git" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain&_a=contents", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GBmain", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag&_a=contents", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo" + }, + { + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo?path=MyFile&version=GTMyTag", + "https://dev.azure-server.com/MyOrg/MyProject/_git/MyRepo" + } + }; + } } diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops-server/rest/user/response.json b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops-server/rest/user/response.json new file mode 100644 index 00000000000..2b754f3c0f3 --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops-server/rest/user/response.json @@ -0,0 +1,5 @@ +{ + "identity": { + "AccountName": "Azure DevOps" + } +} \ No newline at end of file diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/email/response.json b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/email/response.json new file mode 100644 index 00000000000..984754b80e9 --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/email/response.json @@ -0,0 +1,3 @@ +{ + "values": [{"email" : "azure-user@mail.com"}] +} \ No newline at end of file diff --git a/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/response.json b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/response.json new file mode 100644 index 00000000000..39b0898fecb --- /dev/null +++ b/wsmaster/che-core-api-factory-azure-devops/src/test/resources/__files/azure-devops/rest/user/response.json @@ -0,0 +1,4 @@ +{ + "displayName": "Display Name", + "emailAddress": "user@mail.com" +} \ No newline at end of file diff --git a/wsmaster/che-core-api-factory-bitbucket/src/test/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcherTest.java b/wsmaster/che-core-api-factory-bitbucket/src/test/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcherTest.java index 6bd77b2d3b9..23ddb8ab976 100644 --- a/wsmaster/che-core-api-factory-bitbucket/src/test/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcherTest.java +++ b/wsmaster/che-core-api-factory-bitbucket/src/test/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketPersonalAccessTokenFetcherTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ diff --git a/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabUrlParser.java b/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabUrlParser.java index 76aa1e13270..c4fc2d3d218 100644 --- a/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabUrlParser.java +++ b/wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabUrlParser.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -112,7 +112,8 @@ private boolean isApiRequestRelevant(String repositoryUrl) { // belongs to Gitlab. gitlabApiClient.getOAuthTokenInfo(""); } catch (ScmUnauthorizedException e) { - return true; + // the error message is a JSON if it is a response from Gitlab. + return e.getMessage().startsWith("{"); } catch (ScmItemNotFoundException | IllegalArgumentException | ScmCommunicationException e) { return false; } diff --git a/wsmaster/che-core-api-factory-gitlab/src/test/java/org/eclipse/che/api/factory/server/gitlab/GitlabUrlParserTest.java b/wsmaster/che-core-api-factory-gitlab/src/test/java/org/eclipse/che/api/factory/server/gitlab/GitlabUrlParserTest.java index 28e7159a05f..711bf0742b1 100644 --- a/wsmaster/che-core-api-factory-gitlab/src/test/java/org/eclipse/che/api/factory/server/gitlab/GitlabUrlParserTest.java +++ b/wsmaster/che-core-api-factory-gitlab/src/test/java/org/eclipse/che/api/factory/server/gitlab/GitlabUrlParserTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 Red Hat, Inc. + * Copyright (c) 2012-2025 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -99,7 +99,9 @@ public void shouldParseWithoutPredefinedEndpoint( public void shouldValidateUrlByApiRequest() { // given String url = wireMockServer.url("/user/repo"); - stubFor(get(urlEqualTo("/oauth/token/info")).willReturn(aResponse().withStatus(401))); + stubFor( + get(urlEqualTo("/oauth/token/info")) + .willReturn(aResponse().withStatus(401).withBody("{error}"))); // when boolean result = gitlabUrlParser.isValid(url);