diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e04c0d..c6c9fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Support for Bitbucket Cloud and Server ([#23](https://github.com/diffplug/blowdryer/pull/23)). + ## [1.2.1] - 2021-06-01 ### Fixed - `repoSubfolder` doesn't do anything in `localJar` mode, so setting `repoSubfolder` ought to be an error, and now it is ([#22](https://github.com/diffplug/blowdryer/pull/22)). diff --git a/README.md b/README.md index d98c9a1..30fde4f 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,15 @@ blowdryerSetup { github('acme/blowdryer-acme', 'tag', 'v1.4.5') // or 'commit', '07f588e52eb0f31e596eab0228a5df7233a98a14' // or 'tree', 'a5df7233a98a1407f588e52eb0f31e596eab0228' - // or gitlab('acme/blowdryer-acme', 'tag', 'v1.4.5').customDomainHttp('acme.org').authToken('abc123') + + // or gitlab('acme/blowdryer-acme', 'tag', 'v1.4.5').authToken('abc123').customDomainHttp('acme.org') + // or bitbucket('acme/blowdryer-acme', 'tag', 'v1.4.5').authToken('abc123').customDomainHttps('acme.org') } ``` +* Reference on how to create [application password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) +for Bitbucket Cloud private repo access.
+* Reference on how to create [personal access token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html) +for Bitbucket Server private repo access. Now, in *only* your root `build.gradle`, do this: `apply plugin: 'com.diffplug.blowdryer'`. Now, in any project throughout your gradle build (including subprojects), you can do this: diff --git a/build.gradle b/build.gradle index bfd1863..0455b69 100644 --- a/build.gradle +++ b/build.gradle @@ -18,8 +18,10 @@ apply from: 干.file('spotless/java.gradle') dependencies { implementation 'com.squareup.okhttp3:okhttp:4.2.2' implementation 'com.squareup.okio:okio:2.4.1' + implementation 'com.google.code.gson:gson:2.8.7' implementation 'com.diffplug.durian:durian-core:1.2.0' implementation 'com.diffplug.durian:durian-io:1.2.0' testImplementation 'junit:junit:4.12' testImplementation 'org.assertj:assertj-core:3.14.0' + testImplementation 'org.mockito:mockito-core:3.11.2' } diff --git a/src/main/java/com/diffplug/blowdryer/Blowdryer.java b/src/main/java/com/diffplug/blowdryer/Blowdryer.java index 1bdc949..ee7c615 100644 --- a/src/main/java/com/diffplug/blowdryer/Blowdryer.java +++ b/src/main/java/com/diffplug/blowdryer/Blowdryer.java @@ -101,11 +101,14 @@ public static File immutableUrl(String url) { if (metaFile.exists() && dataFile.exists()) { Map props = loadPropertyFile(metaFile); String propUrl = props.get(PROP_URL); + if (propUrl == null) { + throw new IllegalArgumentException("Unexpected content, recommend deleting file at " + metaFile); + } if (propUrl.equals(url)) { urlToContent.put(url, dataFile); return dataFile; } else { - throw new IllegalStateException("Expected url " + url + " but was " + propUrl + " for " + metaFile.getAbsolutePath()); + throw new IllegalStateException("Expected url " + url + " but was " + propUrl + ", recommend deleting file at " + metaFile.getAbsolutePath()); } } else { Files.createParentDirs(dataFile); @@ -191,6 +194,7 @@ private static void downloadRemote(String url, File dst) throws IOException { /** Returns either the filename safe URL, or (first40)--(Base64 filenamesafe)(last40). */ static String filenameSafe(String url) { + url = preserveFileExtensionBitbucket(url); String allSafeCharacters = url.replaceAll("[^a-zA-Z0-9-+_.]", "-"); String noDuplicateDash = allSafeCharacters.replaceAll("-+", "-"); if (noDuplicateDash.length() <= MAX_FILE_LENGTH) { @@ -209,6 +213,19 @@ static String filenameSafe(String url) { } } + // preserve the filename and extension if query parameters are present in original url. + // required to retrieve XML files. + // From: https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=07f588e52eb0f31e596eab0228a5df7233a98a14 + // To: https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=07f588e52eb0f31e596eab0228a5df7233a98a14-spotless.gradle + private static String preserveFileExtensionBitbucket(String url) { + int atIdx = url.indexOf("?at="); + if (atIdx != -1) { + String fileNameWithoutQuery = url.substring(0, atIdx); + url = String.format("%s-%s", url, fileNameWithoutQuery.substring(fileNameWithoutQuery.lastIndexOf("/") + 1)); + } + return url; + } + ////////////////////// // plugin interface // ////////////////////// @@ -253,7 +270,8 @@ private static void assertInitialized() { } } - static interface AuthPlugin { + @FunctionalInterface + interface AuthPlugin { void addAuthToken(String url, Request.Builder builder) throws MalformedURLException; } diff --git a/src/main/java/com/diffplug/blowdryer/BlowdryerSetup.java b/src/main/java/com/diffplug/blowdryer/BlowdryerSetup.java index 373cb10..2a6998b 100644 --- a/src/main/java/com/diffplug/blowdryer/BlowdryerSetup.java +++ b/src/main/java/com/diffplug/blowdryer/BlowdryerSetup.java @@ -16,14 +16,26 @@ package com.diffplug.blowdryer; +import com.diffplug.common.annotations.VisibleForTesting; import com.diffplug.common.base.Errors; +import com.diffplug.common.base.Unhandled; +import com.google.gson.Gson; import groovy.lang.Closure; import java.io.File; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; import java.util.Objects; import java.util.function.Function; +import java.util.stream.Collectors; import javax.annotation.Nullable; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.Response; +import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; /** Configures where {@link Blowdryer#file(String)} downloads files from. */ @@ -32,6 +44,10 @@ public class BlowdryerSetup { private static final String GITHUB_HOST = "raw.githubusercontent.com"; private static final String GITLAB_HOST = "gitlab.com"; + private static final String BITBUCKET_HOST = "api.bitbucket.org/2.0/repositories"; + + private static final String HTTP_PROTOCOL = "http://"; + private static final String HTTPS_PROTOCOL = "https://"; private final File referenceDirectory; @@ -46,7 +62,7 @@ public BlowdryerSetup(File referenceDirectory) { /** * Default value is `src/main/resources`. If you change, you must change as the *first* call. - * + * * The nice thing about the default `src/main/resources` is that if you ever want to, you could * copy the blowdryer code into your blowdryer repo, and deploy your own plugin that pulls resources * from the local jar rather than from github. Keeping the default lets you switch to that approach @@ -86,7 +102,7 @@ public GitHub authToken(String authToken) { private GitHub setGlobals() { Blowdryer.setResourcePluginNull(); - String root = "https://" + GITHUB_HOST + "/" + repoOrg + "/" + anchor + "/"; + String root = HTTPS_PROTOCOL + GITHUB_HOST + "/" + repoOrg + "/" + anchor + "/"; Blowdryer.setResourcePlugin(resource -> root + getFullResourcePath(resource), authToken == null ? null : (url, builder) -> { if (url.startsWith(root)) { builder.addHeader("Authorization", "Bearer " + authToken); @@ -121,11 +137,11 @@ public GitLab authToken(String authToken) { } public GitLab customDomainHttp(String domain) { - return customProtocolAndDomain("http://", domain); + return customProtocolAndDomain(HTTP_PROTOCOL, domain); } public GitLab customDomainHttps(String domain) { - return customProtocolAndDomain("https://", domain); + return customProtocolAndDomain(HTTPS_PROTOCOL, domain); } private GitLab customProtocolAndDomain(String protocol, String domain) { @@ -147,6 +163,188 @@ private GitLab setGlobals() { } } + public enum BitbucketType { + CLOUD, SERVER + } + + /** Sets the source where we will grab these scripts. */ + public Bitbucket bitbucket(String repoOrg, GitAnchorType anchorType, String anchor) { + return new Bitbucket(repoOrg, anchorType, anchor, BitbucketType.CLOUD); + } + + public class Bitbucket { + + private String repoOrg; + private String repoName; + private String anchor; + private GitAnchorType anchorType; + private BitbucketType bitbucketType; + private @Nullable String auth; + private @Nullable String authToken; + private String protocol, host; + + private Bitbucket(String repoOrg, GitAnchorType anchorType, String anchor, BitbucketType bitbucketType) { + Blowdryer.assertPluginNotSet(); + final String[] repoOrgAndName = assertNoLeadingOrTrailingSlash(repoOrg).split("/"); + if (repoOrgAndName.length != 2) { + throw new IllegalArgumentException("repoOrg must be in format 'repoOrg/repoName'"); + } + this.repoOrg = repoOrgAndName[0]; + this.repoName = repoOrgAndName[1]; + this.anchorType = anchorType; + this.bitbucketType = bitbucketType; + this.anchor = assertNoLeadingOrTrailingSlash(anchor); + customProtocolAndDomain(BitbucketType.CLOUD, HTTPS_PROTOCOL, BITBUCKET_HOST); + } + + public Bitbucket authToken(String auth) { + this.auth = auth; + return setGlobals(); + } + + public Bitbucket customDomainHttp(String domain) { + return customProtocolAndDomain(BitbucketType.SERVER, HTTP_PROTOCOL, domain); + } + + public Bitbucket customDomainHttps(String domain) { + return customProtocolAndDomain(BitbucketType.SERVER, HTTPS_PROTOCOL, domain); + } + + private Bitbucket customProtocolAndDomain(BitbucketType type, String protocol, String domain) { + this.bitbucketType = type; + this.protocol = protocol; + this.host = domain; + return setGlobals(); + } + + private Bitbucket setGlobals() { + if (auth == null) { + authToken = null; + } else { + switch (bitbucketType) { + case SERVER: + authToken = String.format("Bearer %s", auth); + break; + case CLOUD: + String base64 = Base64.getEncoder().encodeToString((auth).getBytes(StandardCharsets.UTF_8)); + authToken = String.format("Basic %s", base64); + break; + default: + throw Unhandled.enumException(bitbucketType); + } + } + Blowdryer.setResourcePluginNull(); + String urlStart = getUrlStart(); + Blowdryer.setResourcePlugin(resource -> getFullUrl(urlStart, encodeUrlParts(getFullResourcePath(resource))), (url, builder) -> { + if (authToken != null) { + builder.addHeader("Authorization", authToken); + } + }); + return this; + } + + private String getUrlStart() { + // Bitbucket Cloud and Bitbucket Server (premium, company hosted) has different url structures. + // Bitbucket Cloud uses "org/repo" in URLs, where org is your (or someone else's) account name. + // Bitbucket Server uses "projects/PROJECT_KEY/repos/REPO_NAME" in urls. + if (isServer()) { + return String.format("%s%s/projects/%s/repos/%s", protocol, host, repoOrg, repoName); + } else { + return String.format("%s%s/%s/%s", protocol, host, repoOrg, repoName); + } + } + + private String getFullUrl(String urlStart, String filePath) { + if (isServer()) { + return String.format("%s/raw/%s?at=%s", urlStart, filePath, encodeUrlPart(getAnchorForServer())); + } else { + return String.format("%s/src/%s/%s", urlStart, encodeUrlParts(getAnchorForCloud()), filePath); + } + } + + private boolean isServer() { + return BitbucketType.SERVER.equals(this.bitbucketType); + } + + private String getAnchorForServer() { + switch (anchorType) { + case COMMIT: + return anchor; + case TAG: + return "refs/tags/" + anchor; + default: + throw new UnsupportedOperationException(anchorType + " not supported for Bitbucket"); + } + } + + private String getAnchorForCloud() { + switch (anchorType) { + case COMMIT: + return anchor; + case TAG: + // rewrite the tag into the commit it points to + anchor = getCommitHash("refs/tags/"); + anchorType = GitAnchorType.COMMIT; + return anchor; + default: + throw new UnsupportedOperationException(anchorType + " not supported for Bitbucket"); + } + } + + // Bitbucket API: https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/src/%7Bcommit%7D/%7Bpath%7D + private String getCommitHash(String baseRefs) { + String requestUrl = String.format("%s/%s%s", getUrlStart(), baseRefs, encodeUrlParts(anchor)); + + return getCommitHashFromBitbucket(requestUrl); + } + + @VisibleForTesting + String getCommitHashFromBitbucket(String requestUrl) { + OkHttpClient client = new OkHttpClient.Builder().build(); + Builder requestBuilder = new Builder().url(requestUrl); + if (authToken != null) { + requestBuilder.addHeader("Authorization", authToken); + } + Request request = requestBuilder.build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IllegalArgumentException(String.format("%s\nreceived http code %s \n %s", request.url(), response.code(), + Objects.requireNonNull(response.body()).string())); + } + try (ResponseBody body = response.body()) { + RefsTarget refsTarget = new Gson().fromJson(Objects.requireNonNull(body).string(), RefsTarget.class); + return refsTarget.target.hash; + } + } catch (Exception e) { + throw new IllegalArgumentException("Body was expected to be non-null", e); + } + } + + // Do not encode '/'. + private String encodeUrlParts(String part) { + return Arrays.stream(part.split("/")) + .map(BlowdryerSetup::encodeUrlPart) + .collect(Collectors.joining("/")); + } + + private class RefsTarget { + private final Target target; + + private RefsTarget(Target target) { + this.target = target; + } + + private class Target { + private final String hash; + + private Target(String hash) { + this.hash = hash; + } + } + } + } + /** * Uses the provided {@code jarFile} to extract a file resource. * @param jarFile Absolute path to JAR on the file system. @@ -214,4 +412,5 @@ private static String encodeUrlPart(String part) { throw new IllegalArgumentException("error encoding part", e); } } + } diff --git a/src/test/java/com/diffplug/blowdryer/BlowdryerPluginAuthTest.java b/src/test/java/com/diffplug/blowdryer/BlowdryerPluginAuthTest.java index 1829d8a..4995356 100644 --- a/src/test/java/com/diffplug/blowdryer/BlowdryerPluginAuthTest.java +++ b/src/test/java/com/diffplug/blowdryer/BlowdryerPluginAuthTest.java @@ -25,9 +25,17 @@ import org.junit.Ignore; import org.junit.Test; -@Ignore("has to be filled with prvate tokens and repos") +@Ignore("has to be filled with private tokens and repos") public class BlowdryerPluginAuthTest extends GradleHarness { + private static final String BITBUCKET_REPO_ORG = "bHack/blowdryer-private"; + private static final String BITBUCKET_REPO_USER = "bHack"; + private static final String BITBUCKET_REPO_APP_PW = "replace-with-app-pw"; + + private static final String BITBUCKET_REPO_PAT_REPO_ORG = "MNT/mnt-centralised-cfg"; + private static final String BITBUCKET_REPO_PAT = "replace-with-pat"; + private static final String BITBUCKET_PRIVATE_SERVER_HOST = "replace.with.private.host"; + private void settingsGitlabAuth(String tag, String... extra) throws IOException { write("settings.gradle", "plugins { id 'com.diffplug.blowdryerSetup' }", @@ -45,6 +53,24 @@ private void settingsGithubAuth(String tag, String... extra) throws IOException Arrays.stream(extra).collect(Collectors.joining("\n"))); } + private void settingsBitbucketBasicAuth(String tag, String... extra) throws IOException { + write("settings.gradle", + "plugins { id 'com.diffplug.blowdryerSetup' }", + String.format("blowdryerSetup { bitbucket('%s', 'commit', '%s').cloudAuth('%s:%s');", + BITBUCKET_REPO_ORG, tag, BITBUCKET_REPO_USER, BITBUCKET_REPO_APP_PW) + + " }", + Arrays.stream(extra).collect(Collectors.joining("\n"))); + } + + private void settingsBitbucketPersonalAccessTokenAuth(String tag, String... extra) throws IOException { + write("settings.gradle", + "plugins { id 'com.diffplug.blowdryerSetup' }", + String.format("blowdryerSetup { bitbucket('%s', 'commit', '%s').customDomainHttps('%s')" + + ".serverAuth('%s');", BITBUCKET_REPO_PAT_REPO_ORG, tag, BITBUCKET_PRIVATE_SERVER_HOST, BITBUCKET_REPO_PAT) + + " }", + Arrays.stream(extra).collect(Collectors.joining("\n"))); + } + @Test public void githubAuthTag() throws IOException { settingsGithubAuth("master"); @@ -63,6 +89,24 @@ public void gitlabAuthTag() throws IOException { gradleRunner().build(); } + @Test + public void bitbucketCloudAuth() throws IOException { + settingsBitbucketBasicAuth("master"); + write("build.gradle", + "apply plugin: 'com.diffplug.blowdryer'", + "assert 干.file('sample').text == 'a'"); + gradleRunner().build(); + } + + @Test + public void bitbucketServerAuth() throws IOException { + settingsBitbucketPersonalAccessTokenAuth("master"); + write("build.gradle", + "apply plugin: 'com.diffplug.blowdryer'", + "assert 干.file('checkstyle/spotless.gradle').text == 'a'"); + gradleRunner().build(); + } + /** Writes the given content to the given path. */ protected File write(String path, String... lines) throws IOException { File file = file(path); diff --git a/src/test/java/com/diffplug/blowdryer/BlowdryerPluginTest.java b/src/test/java/com/diffplug/blowdryer/BlowdryerPluginTest.java index 8e471c3..36f8543 100644 --- a/src/test/java/com/diffplug/blowdryer/BlowdryerPluginTest.java +++ b/src/test/java/com/diffplug/blowdryer/BlowdryerPluginTest.java @@ -26,6 +26,7 @@ public class BlowdryerPluginTest extends GradleHarness { private static final String SETTINGS_GRADLE = "settings.gradle"; private static final String BUILD_GRADLE = "build.gradle"; + private static final String BITBUCKET_REPO_ORG = "diffplug"; private void settingsGithub(String tag, String... extra) throws IOException { write(SETTINGS_GRADLE, @@ -55,6 +56,13 @@ private void settingsGitlabRootFolder(String tag, String... extra) throws IOExce Arrays.stream(extra).collect(Collectors.joining("\n"))); } + private void settingsBitbucket(String tag, String... extra) throws IOException { + write(SETTINGS_GRADLE, + "plugins { id 'com.diffplug.blowdryerSetup' }", + String.format("blowdryerSetup { bitbucket('%s/blowdryer', 'tag', '%s') }", BITBUCKET_REPO_ORG, tag), + Arrays.stream(extra).collect(Collectors.joining("\n"))); + } + private void settingsLocalJar(String dependency) throws IOException { write(SETTINGS_GRADLE, "plugins { id 'com.diffplug.blowdryerSetup' }", @@ -113,6 +121,32 @@ public void gitlabTag() throws IOException { gradleRunner().buildAndFail(); } + @Test + public void bitbucketTag() throws IOException { + settingsBitbucket("test/2/a"); + write(BUILD_GRADLE, + "apply plugin: 'com.diffplug.blowdryer'", + "assert 干.file('sample').text == 'a'", + "assert 干.prop('sample', 'name') == 'test'", + "assert 干.prop('sample', 'ver_spotless') == '1.2.0'"); + gradleRunner().build(); + + settingsBitbucket("test/2/b"); + write(BUILD_GRADLE, + "apply plugin: 'com.diffplug.blowdryer'", + "assert 干.file('sample').text == 'b'", + "assert 干.prop('sample', 'name') == 'testB'", + "assert 干.prop('sample', 'group') == 'com.diffplug.gradleB'"); + gradleRunner().build(); + + // double-check that failures do fail + settingsBitbucket("test/2/b"); + write(BUILD_GRADLE, + "plugins { id 'com.diffplug.blowdryer' }", + "assert Blowdryer.file('sample').text == 'a'"); + gradleRunner().buildAndFail(); + } + @Test public void customGitlabTag() throws IOException { settingsCustomGitlab("test/2/a"); diff --git a/src/test/java/com/diffplug/blowdryer/BlowdryerTest.java b/src/test/java/com/diffplug/blowdryer/BlowdryerTest.java index 3d126d6..92889b6 100644 --- a/src/test/java/com/diffplug/blowdryer/BlowdryerTest.java +++ b/src/test/java/com/diffplug/blowdryer/BlowdryerTest.java @@ -15,8 +15,21 @@ */ package com.diffplug.blowdryer; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; -import org.assertj.core.api.Assertions; +import com.diffplug.blowdryer.Blowdryer.AuthPlugin; +import com.diffplug.blowdryer.Blowdryer.ResourcePlugin; +import com.diffplug.blowdryer.BlowdryerSetup.Bitbucket; +import com.diffplug.blowdryer.BlowdryerSetup.GitAnchorType; +import java.lang.reflect.Field; +import java.util.Base64; +import java.util.UUID; +import okhttp3.Request; +import okhttp3.Request.Builder; import org.junit.Test; public class BlowdryerTest { @@ -28,23 +41,128 @@ public void filenameSafe() { filenameSafe("http://shortName.com/a+b-0-9~Z", "http-shortName.com-a+b-0-9-Z"); filenameSafe("https://raw.githubusercontent.com/diffplug/durian-build/07f588e52eb0f31e596eab0228a5df7233a98a14/gradle/spotless/spotless.license.java", "https-raw.githubusercontent.com-diffplug--3vpUTw--14-gradle-spotless-spotless.license.java"); + filenameSafe("https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=refs%2Fheads%2Fmaster", + "https-mycompany.bitbucket.com-projects-P--7T3UGg--at-refs-2Fheads-2Fmaster-spotless.gradle"); + filenameSafe("https://mycompany.bitbucket.com/projects/PRJ/repos/my-repo/raw/src/main/resources/checkstyle/spotless.gradle?at=07f588e52eb0f31e596eab0228a5df7233a98a14", + "https-mycompany.bitbucket.com-projects-P--K+HRow--596eab0228a5df7233a98a14-spotless.gradle"); } private void filenameSafe(String url, String safe) { - Assertions.assertThat(Blowdryer.filenameSafe(url)).isEqualTo(safe); + assertThat(Blowdryer.filenameSafe(url)).isEqualTo(safe); } @Test public void cachedFileDeleted_issue_11() { String test = "https://raw.githubusercontent.com/diffplug/blowdryer/test/2/b/src/main/resources/sample"; - Assertions.assertThat(Blowdryer.immutableUrl(test)).hasContent("b"); + assertThat(Blowdryer.immutableUrl(test)).hasContent("b"); Blowdryer.immutableUrl(test).delete(); - Assertions.assertThat(Blowdryer.immutableUrl(test)).hasContent("b"); + assertThat(Blowdryer.immutableUrl(test)).hasContent("b"); } @Test public void immutableUrlOfLocalJar() { String jarFile = BlowdryerPluginTest.class.getResource("test.jar").getFile(); - Assertions.assertThat(Blowdryer.immutableUrl(FILE_PROTOCOL + jarFile + JAR_FILE_RESOURCE_SEPARATOR + "sample")).exists(); + assertThat(Blowdryer.immutableUrl(FILE_PROTOCOL + jarFile + JAR_FILE_RESOURCE_SEPARATOR + "sample")).exists(); } + + @Test + public void bitbucketCloud_tagAnchorType() throws Exception { + final String hashRequestUrl = "https://api.bitbucket.org/2.0/repositories/testOrg/testRepo/refs/tags/testAnchor"; + final String hash = UUID.randomUUID().toString(); + final String expected = "https://api.bitbucket.org/2.0/repositories/testOrg/testRepo/src/" + hash + "/src/main/resources/test.properties"; + + Bitbucket spy = spy(setupBitbucketTestTarget(GitAnchorType.TAG)).authToken("un:pw"); + doReturn(hash).when(spy).getCommitHashFromBitbucket(hashRequestUrl); + final ResourcePlugin target = getResourcePlugin(); + + assertThat(target.toImmutableUrl("test.properties")).isEqualTo(expected); + } + + @Test + public void bitbucketCloud_commitAnchorType() throws Exception { + final String expected = "https://api.bitbucket.org/2.0/repositories/testOrg/testRepo/src/testAnchor/src/main/resources/test.properties"; + setupBitbucketTestTarget(GitAnchorType.COMMIT); + final ResourcePlugin target = getResourcePlugin(); + + assertThat(target.toImmutableUrl("test.properties")).isEqualTo(expected); + } + + @Test + public void bitbucketCloud_treeAnchorType() throws Exception { + setupBitbucketTestTarget(GitAnchorType.TREE); + final ResourcePlugin target = getResourcePlugin(); + + assertThatThrownBy(() -> target.toImmutableUrl("test.properties")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("TREE not supported for Bitbucket"); + } + + @Test + public void bitbucketServer_tagAnchorType() throws Exception { + final String expected = "https://my.bitbucket.com/projects/testOrg/repos/testRepo/raw/src/main/resources/test.properties?at=refs%2Ftags%2FtestAnchor"; + setupBitbucketTestTarget(GitAnchorType.TAG).customDomainHttps("my.bitbucket.com"); + final ResourcePlugin target = getResourcePlugin(); + + assertThat(target.toImmutableUrl("test.properties")).isEqualTo(expected); + } + + @Test + public void bitbucketServer_commitAnchorType() throws Exception { + final String expected = "https://my.bitbucket.com/projects/testOrg/repos/testRepo/raw/src/main/resources/test.properties?at=testAnchor"; + setupBitbucketTestTarget(GitAnchorType.COMMIT).customDomainHttps("my.bitbucket.com"); + final ResourcePlugin target = getResourcePlugin(); + + assertThat(target.toImmutableUrl("test.properties")).isEqualTo(expected); + } + + @Test + public void bitbucketServer_treeAnchorType() throws Exception { + setupBitbucketTestTarget(GitAnchorType.TREE).customDomainHttps("my.bitbucket.com"); + final ResourcePlugin target = getResourcePlugin(); + + assertThatThrownBy(() -> target.toImmutableUrl("test.properties")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("TREE not supported for Bitbucket"); + } + + @Test + public void bitbucketCloudAuth() throws Exception { + final String expected = "https://api.bitbucket.org/2.0/repositories/testOrg/testRepo/src/testAnchor/src/main/resources/test.properties"; + final String usernameAndAppPassword = String.format("%s:%s", randomUUID(), randomUUID()); + setupBitbucketTestTarget(GitAnchorType.COMMIT).authToken(usernameAndAppPassword); + + final ResourcePlugin target = getResourcePlugin(); + final AuthPlugin otherTarget = getAuthPlugin(); + final Builder requestBuilder = new Builder().url(expected); + otherTarget.addAuthToken(expected, requestBuilder); + final Request request = requestBuilder.build(); + + assertThat(target.toImmutableUrl("test.properties")).isEqualTo(expected); + final String encoded = Base64.getEncoder().encodeToString((usernameAndAppPassword) + .getBytes(UTF_8)); + assertThat(request.header("Authorization")).isEqualTo(String.format("Basic %s", encoded)); + } + + private Bitbucket setupBitbucketTestTarget(final GitAnchorType anchorType) { + final String repoOrg = "testOrg/testRepo"; + final String anchor = "testAnchor"; + return new BlowdryerSetup(null).bitbucket(repoOrg, anchorType, anchor); + } + + private ResourcePlugin getResourcePlugin() throws Exception { + final Field field = Blowdryer.class.getDeclaredField("plugin"); + field.setAccessible(true); + return (ResourcePlugin) field.get(null); + } + + private AuthPlugin getAuthPlugin() throws Exception { + final Field field = Blowdryer.class.getDeclaredField("authPlugin"); + field.setAccessible(true); + return (AuthPlugin) field.get(null); + } + + private String randomUUID() { + return UUID.randomUUID().toString(); + } + }