Skip to content

Commit e705869

Browse files
committed
Issue 573 Refactor Bitbucket implementation to allow for the support of Bitbucket DC's OAuth2 implementation
The BitbucketAuthentication and BitbucketHostProvider remain the single points of entry to processing authentication for all Bitbucket hosts. Bitbucket DC and Bitbucket Cloud do not share common REST APIs or common OAuth2 implementations. The interface IBitbucketRestApi and abstract class BitbucketOAuth2Client effectively hide the differences in implementation from BitbucketAuthentication and BitbucketHostProvider The interface IBitbucketRestApi and abstract class BitbucketOAuth2Client provide clear extension points to implement OAuth2 support for Bitbucket DC
1 parent 0542166 commit e705869

33 files changed

+835
-370
lines changed

src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<PrivateAssets>all</PrivateAssets>
1414
</PackageReference>
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
16-
<PackageReference Include="ReportGenerator" Version="4.8.13" />
16+
<PackageReference Include="ReportGenerator" Version="5.1.9" />
1717
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
1818
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
1919
</ItemGroup>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System;
2+
using Xunit;
3+
4+
namespace Atlassian.Bitbucket.Tests
5+
{
6+
public class BitbucketHelperTest
7+
{
8+
[Theory]
9+
[InlineData(null, false)]
10+
[InlineData("", false)]
11+
[InlineData(" ", false)]
12+
[InlineData("bitbucket.org", true)]
13+
[InlineData("BITBUCKET.ORG", true)]
14+
[InlineData("BiTbUcKeT.OrG", true)]
15+
[InlineData("bitbucket.example.com", false)]
16+
[InlineData("bitbucket.example.org", false)]
17+
[InlineData("bitbucket.org.com", false)]
18+
[InlineData("bitbucket.org.org", false)]
19+
public void BitbucketHelper_IsBitbucketOrg_StringHost(string str, bool expected)
20+
{
21+
bool actual = BitbucketHelper.IsBitbucketOrg(str);
22+
Assert.Equal(expected, actual);
23+
}
24+
25+
[Theory]
26+
[InlineData("http://bitbucket.org", true)]
27+
[InlineData("https://bitbucket.org", true)]
28+
[InlineData("http://bitbucket.org/path", true)]
29+
[InlineData("https://bitbucket.org/path", true)]
30+
[InlineData("http://BITBUCKET.ORG", true)]
31+
[InlineData("https://BITBUCKET.ORG", true)]
32+
[InlineData("http://BITBUCKET.ORG/PATH", true)]
33+
[InlineData("https://BITBUCKET.ORG/PATH", true)]
34+
[InlineData("http://BiTbUcKeT.OrG", true)]
35+
[InlineData("https://BiTbUcKeT.OrG", true)]
36+
[InlineData("http://BiTbUcKeT.OrG/pAtH", true)]
37+
[InlineData("https://BiTbUcKeT.OrG/pAtH", true)]
38+
[InlineData("http://bitbucket.example.com", false)]
39+
[InlineData("https://bitbucket.example.com", false)]
40+
[InlineData("http://bitbucket.example.com/path", false)]
41+
[InlineData("https://bitbucket.example.com/path", false)]
42+
[InlineData("http://bitbucket.example.org", false)]
43+
[InlineData("https://bitbucket.example.org", false)]
44+
[InlineData("http://bitbucket.example.org/path", false)]
45+
[InlineData("https://bitbucket.example.org/path", false)]
46+
[InlineData("http://bitbucket.org.com", false)]
47+
[InlineData("https://bitbucket.org.com", false)]
48+
[InlineData("http://bitbucket.org.com/path", false)]
49+
[InlineData("https://bitbucket.org.com/path", false)]
50+
[InlineData("http://bitbucket.org.org", false)]
51+
[InlineData("https://bitbucket.org.org", false)]
52+
[InlineData("http://bitbucket.org.org/path", false)]
53+
[InlineData("https://bitbucket.org.org/path", false)]
54+
public void BitbucketHelper_IsBitbucketOrg_Uri(string str, bool expected)
55+
{
56+
bool actual = BitbucketHelper.IsBitbucketOrg(new Uri(str));
57+
Assert.Equal(expected, actual);
58+
}
59+
}
60+
}

src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs

Lines changed: 80 additions & 109 deletions
Large diffs are not rendered by default.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Collections.Generic;
2+
using GitCredentialManager;
3+
using Moq;
4+
using Xunit;
5+
6+
namespace Atlassian.Bitbucket.Tests
7+
{
8+
public class BitbucketRestApiRegistryTest
9+
{
10+
private Mock<ICommandContext> context = new Mock<ICommandContext>(MockBehavior.Strict);
11+
private Mock<ISettings> settings = new Mock<ISettings>(MockBehavior.Strict);
12+
13+
[Fact]
14+
public void BitbucketRestApiRegistry_Get_ReturnsCloudApi_ForBitbucketOrg()
15+
{
16+
// Given
17+
settings.Setup(s => s.RemoteUri).Returns(new System.Uri("https://bitbucket.org"));
18+
context.Setup(c => c.Settings).Returns(settings.Object);
19+
20+
var input = new InputArguments(new Dictionary<string, string>
21+
{
22+
["protocol"] = "https",
23+
["host"] = "bitbucket.org",
24+
});
25+
26+
// When
27+
var registry = new BitbucketRestApiRegistry(context.Object);
28+
var api = registry.Get(input);
29+
30+
// Then
31+
Assert.NotNull(api);
32+
Assert.IsType<Atlassian.Bitbucket.Cloud.BitbucketRestApi>(api);
33+
34+
}
35+
}
36+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Newtonsoft.Json;
2+
using Xunit;
3+
4+
namespace Atlassian.Bitbucket.Tests
5+
{
6+
public class BitbucketTokenEndpointResponseJsonTest
7+
{
8+
[Fact]
9+
public void BitbucketTokenEndpointResponseJson_Deserialize_Scopes_Not_Scope()
10+
{
11+
var scopesString = "a,b,c";
12+
var json = "{access_token: '', token_type: '', scopes:'" + scopesString + "', scope: 'x,y,z'}";
13+
14+
var result = JsonConvert.DeserializeObject<BitbucketTokenEndpointResponseJson>(json);
15+
16+
Assert.Equal(scopesString, result.Scope);
17+
}
18+
}
19+
}

src/shared/Atlassian.Bitbucket.Tests/BitbucketOauth2ClientTest.cs renamed to src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44
using System.Net.Http;
55
using System.Threading;
66
using System.Threading.Tasks;
7+
using Atlassian.Bitbucket.Cloud;
78
using GitCredentialManager;
89
using GitCredentialManager.Authentication.OAuth;
910
using Moq;
1011
using Xunit;
1112

12-
namespace Atlassian.Bitbucket.Tests
13+
namespace Atlassian.Bitbucket.Tests.Cloud
1314
{
1415
public class BitbucketOAuth2ClientTest
1516
{
1617
private Mock<HttpClient> httpClient = new Mock<HttpClient>(MockBehavior.Strict);
1718
private Mock<ISettings> settings = new Mock<ISettings>(MockBehavior.Loose);
19+
private Mock<Trace> trace = new Mock<Trace>(MockBehavior.Loose);
1820
private Mock<IOAuth2WebBrowser> browser = new Mock<IOAuth2WebBrowser>(MockBehavior.Strict);
1921
private Mock<IOAuth2CodeGenerator> codeGenerator = new Mock<IOAuth2CodeGenerator>(MockBehavior.Strict);
2022
private IEnumerable<string> scopes = new List<string>();
@@ -32,13 +34,13 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode()
3234

3335
Uri finalCallbackUri = MockFinalCallbackUri();
3436

35-
MockGetAuthenticationCodeAsync(finalCallbackUri, null);
37+
Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client();
3638

37-
MockCodeGenerator();
39+
MockGetAuthenticationCodeAsync(finalCallbackUri, null, client.Scopes);
3840

39-
BitbucketOAuth2Client client = GetBitbucketOAuth2Client();
41+
MockCodeGenerator();
4042

41-
var result = await client.GetAuthorizationCodeAsync(scopes, browser.Object, ct);
43+
var result = await client.GetAuthorizationCodeAsync(browser.Object, ct);
4244

4345
VerifyAuthorizationCodeResult(result);
4446
}
@@ -52,36 +54,42 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_RespectsClient
5254

5355
Uri finalCallbackUri = MockFinalCallbackUri();
5456

55-
MockGetAuthenticationCodeAsync(finalCallbackUri, clientId);
57+
Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client();
58+
59+
MockGetAuthenticationCodeAsync(finalCallbackUri, clientId, client.Scopes);
5660

5761
MockCodeGenerator();
5862

59-
BitbucketOAuth2Client client = GetBitbucketOAuth2Client();
60-
61-
var result = await client.GetAuthorizationCodeAsync(scopes, browser.Object, ct);
63+
var result = await client.GetAuthorizationCodeAsync(browser.Object, ct);
6264

6365
VerifyAuthorizationCodeResult(result);
6466
}
6567

6668
[Fact]
6769
public async Task BitbucketOAuth2Client_GetDeviceCodeAsync()
6870
{
69-
var client = new BitbucketOAuth2Client(httpClient.Object, settings.Object);
71+
var client = new Bitbucket.Cloud.BitbucketOAuth2Client(httpClient.Object, settings.Object, trace.Object);
7072
await Assert.ThrowsAsync<InvalidOperationException>(async () => await client.GetDeviceCodeAsync(scopes, ct));
7173
}
7274

73-
private void VerifyOAuth2TokenResult(OAuth2TokenResult result)
75+
[Theory]
76+
[InlineData("https", "example.com", "john", "https://example.com/refresh_token")]
77+
[InlineData("http", "example.com", "john", "http://example.com/refresh_token")]
78+
[InlineData("https", "example.com", "dave", "https://example.com/refresh_token")]
79+
[InlineData("https", "example.com/", "john", "https://example.com/refresh_token")]
80+
public void BitbucketOAuth2Client_GetRefreshTokenServiceName(string protocol, string host, string username, string expectedResult)
7481
{
75-
Assert.NotNull(result);
76-
IEnumerable<char> access_token = null;
77-
Assert.Equal(access_token, result.AccessToken);
78-
IEnumerable<char> refresh_token = null;
79-
Assert.Equal(refresh_token, result.RefreshToken);
80-
IEnumerable<char> tokenType = null;
81-
Assert.Equal(tokenType, result.TokenType);
82-
Assert.Null(result.Scopes);
82+
var client = new Bitbucket.Cloud.BitbucketOAuth2Client(httpClient.Object, settings.Object, trace.Object);
83+
var input = new InputArguments(new Dictionary<string, string>
84+
{
85+
["protocol"] = protocol,
86+
["host"] = host,
87+
["username"] = username
88+
});
89+
Assert.Equal(expectedResult, client.GetRefreshTokenServiceName(input));
8390
}
8491

92+
8593
private void VerifyAuthorizationCodeResult(OAuth2AuthorizationCodeResult result)
8694
{
8795
Assert.NotNull(result);
@@ -90,9 +98,9 @@ private void VerifyAuthorizationCodeResult(OAuth2AuthorizationCodeResult result)
9098
Assert.Equal(pkceCodeVerifier, result.CodeVerifier);
9199
}
92100

93-
private BitbucketOAuth2Client GetBitbucketOAuth2Client()
101+
private Bitbucket.Cloud.BitbucketOAuth2Client GetBitbucketOAuth2Client()
94102
{
95-
var client = new BitbucketOAuth2Client(httpClient.Object, settings.Object);
103+
var client = new Bitbucket.Cloud.BitbucketOAuth2Client(httpClient.Object, settings.Object, trace.Object);
96104
client.CodeGenerator = codeGenerator.Object;
97105
return client;
98106
}
@@ -104,16 +112,17 @@ private void MockCodeGenerator()
104112
codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge);
105113
}
106114

107-
private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrideClientId)
115+
private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
108116
{
109-
var authorizationUri = new UriBuilder(BitbucketConstants.OAuth2AuthorizationEndpoint)
117+
var authorizationUri = new UriBuilder(CloudConstants.OAuth2AuthorizationEndpoint)
110118
{
111119
Query = "?response_type=code"
112-
+ "&client_id=" + (overrideClientId ?? BitbucketConstants.OAuth2ClientId)
120+
+ "&client_id=" + (overrideClientId ?? CloudConstants.OAuth2ClientId)
113121
+ "&state=12345"
114122
+ "&code_challenge_method=" + OAuth2Constants.AuthorizationEndpoint.PkceChallengeMethodS256
115123
+ "&code_challenge=" + WebUtility.UrlEncode(pkceCodeChallenge).ToLower()
116124
+ "&redirect_uri=" + WebUtility.UrlEncode(rootCallbackUri.AbsoluteUri).ToLower()
125+
+ "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToLower()
117126
}.Uri;
118127

119128
browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, rootCallbackUri, ct)).Returns(Task.FromResult(finalCallbackUri));
@@ -133,8 +142,8 @@ private string MockeClientIdOverride(bool set)
133142
private string MockClientIdOverride(bool set, string value)
134143
{
135144
settings.Setup(s => s.TryGetSetting(
136-
BitbucketConstants.EnvironmentVariables.DevOAuthClientId,
137-
Constants.GitConfiguration.Credential.SectionName, BitbucketConstants.GitConfiguration.Credential.DevOAuthClientId,
145+
CloudConstants.EnvironmentVariables.OAuthClientId,
146+
Constants.GitConfiguration.Credential.SectionName, CloudConstants.GitConfiguration.Credential.OAuthClientId,
138147
out value)).Returns(set);
139148
return value;
140149
}

src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiTest.cs renamed to src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketRestApiTest.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
using System.Net;
33
using System.Net.Http;
44
using System.Threading.Tasks;
5+
using Atlassian.Bitbucket.Cloud;
56
using GitCredentialManager.Tests;
67
using GitCredentialManager.Tests.Objects;
78
using Xunit;
89

9-
namespace Atlassian.Bitbucket.Tests
10+
namespace Atlassian.Bitbucket.Tests.Cloud
1011
{
1112
public class BitbucketRestApiTest
1213
{
@@ -51,9 +52,9 @@ public async Task BitbucketRestApi_GetUserInformationAsync_ReturnsUserInfo_ForSu
5152

5253
Assert.NotNull(result);
5354
Assert.Equal(username, result.Response.UserName);
54-
Assert.Equal(accountId, result.Response.AccountId);
55-
Assert.Equal(uuid, result.Response.Uuid);
5655
Assert.Equal(twoFactorAuthenticationEnabled, result.Response.IsTwoFactorAuthenticationEnabled);
56+
Assert.Equal(accountId, ((UserInfo)result.Response).AccountId);
57+
Assert.Equal(uuid, ((UserInfo)result.Response).Uuid);
5758

5859
httpHandler.AssertRequest(HttpMethod.Get, expectedRequestUri, 1);
5960
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Threading.Tasks;
2+
using Atlassian.Bitbucket.Cloud;
3+
using Xunit;
4+
5+
namespace Atlassian.Bitbucket.Tests.Cloud
6+
{
7+
public class UserInfoTest
8+
{
9+
[Fact]
10+
public void UserInfo_Set()
11+
{
12+
var uuid = System.Guid.NewGuid();
13+
var userInfo = new UserInfo()
14+
{
15+
AccountId = "abc",
16+
IsTwoFactorAuthenticationEnabled = false,
17+
UserName = "123",
18+
Uuid = uuid
19+
};
20+
21+
Assert.Equal("abc", userInfo.AccountId);
22+
Assert.False(userInfo.IsTwoFactorAuthenticationEnabled);
23+
Assert.Equal("123", userInfo.UserName);
24+
Assert.Equal(uuid, userInfo.Uuid);
25+
}
26+
}
27+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net.Http;
4+
using Atlassian.Bitbucket.Cloud;
5+
using GitCredentialManager;
6+
using Moq;
7+
using Xunit;
8+
9+
namespace Atlassian.Bitbucket.Tests
10+
{
11+
public class OAuth2ClientRegistryTest
12+
{
13+
private Mock<ICommandContext> context = new Mock<ICommandContext>(MockBehavior.Loose);
14+
private Mock<ISettings> settings = new Mock<ISettings>(MockBehavior.Strict);
15+
private Mock<IHttpClientFactory> httpClientFactory = new Mock<IHttpClientFactory>(MockBehavior.Strict);
16+
private Mock<ITrace> trace = new Mock<ITrace>(MockBehavior.Strict);
17+
18+
[Fact]
19+
public void BitbucketRestApiRegistry_Get_ReturnsCloudOAuth2Client()
20+
{
21+
var host = "bitbucket.org";
22+
23+
// Given
24+
settings.Setup(s => s.RemoteUri).Returns(new System.Uri("https://" + host));
25+
context.Setup(c => c.Settings).Returns(settings.Object);
26+
MockSettingOverride(CloudConstants.EnvironmentVariables.OAuthClientId, CloudConstants.GitConfiguration.Credential.OAuthClientId, "never used", false);
27+
MockSettingOverride(CloudConstants.EnvironmentVariables.OAuthClientSecret, CloudConstants.GitConfiguration.Credential.OAuthClientSecret, "never used", false);
28+
MockSettingOverride(CloudConstants.EnvironmentVariables.OAuthRedirectUri, CloudConstants.GitConfiguration.Credential.OAuthRedirectUri, "never used", false);
29+
MockHttpClientFactory();
30+
var input = MockInputArguments(host);
31+
32+
// When
33+
var registry = new OAuth2ClientRegistry(context.Object);
34+
var api = registry.Get(input);
35+
36+
// Then
37+
Assert.NotNull(api);
38+
Assert.IsType<Atlassian.Bitbucket.Cloud.BitbucketOAuth2Client>(api);
39+
40+
}
41+
42+
private static InputArguments MockInputArguments(string host)
43+
{
44+
return new InputArguments(new Dictionary<string, string>
45+
{
46+
["protocol"] = "https",
47+
["host"] = host,
48+
});
49+
}
50+
51+
private void MockHttpClientFactory()
52+
{
53+
context.Setup(c => c.HttpClientFactory).Returns(httpClientFactory.Object);
54+
httpClientFactory.Setup(f => f.CreateClient()).Returns(new HttpClient());
55+
}
56+
57+
private string MockSettingOverride(string envKey, string configKey, string settingValue, bool isOverridden)
58+
{
59+
settings.Setup(s => s.TryGetSetting(
60+
envKey,
61+
Constants.GitConfiguration.Credential.SectionName, configKey,
62+
out settingValue)).Returns(isOverridden);
63+
return settingValue;
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)