Skip to content

Commit 8e351df

Browse files
committed
azrepos: add Azure authority cache
Add a cache of the Azure backing authority for Azure DevOps orgs. This cache is only consulted when the credential type is "oauth" and not "pat". We use Git's configuration as the persistence mechanism.
1 parent 7e493d7 commit 8e351df

File tree

5 files changed

+376
-14
lines changed

5 files changed

+376
-14
lines changed
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using Microsoft.Git.CredentialManager;
7+
using Microsoft.Git.CredentialManager.Tests.Objects;
8+
using Xunit;
9+
10+
namespace Microsoft.AzureRepos.Tests
11+
{
12+
public class AzureReposAuthorityCacheTests
13+
{
14+
[Fact]
15+
public void AzureReposAuthorityCache_GetAuthority_Null_ThrowException()
16+
{
17+
var trace = new NullTrace();
18+
var git = new TestGit();
19+
var cache = new AzureDevOpsAuthorityCache(trace, git);
20+
21+
Assert.Throws<ArgumentNullException>(() => cache.GetAuthority(null));
22+
}
23+
24+
[Fact]
25+
public void AzureReposAuthorityCache_GetAuthority_NoCachedAuthority_ReturnsNull()
26+
{
27+
string key = CreateKey("contoso");
28+
29+
var trace = new NullTrace();
30+
var git = new TestGit();
31+
var cache = new AzureDevOpsAuthorityCache(trace, git);
32+
33+
string authority = cache.GetAuthority(key);
34+
35+
Assert.Null(authority);
36+
}
37+
38+
[Fact]
39+
public void AzureReposAuthorityCache_GetAuthority_CachedAuthority_ReturnsAuthority()
40+
{
41+
const string orgName = "contoso";
42+
string key = CreateKey(orgName);
43+
const string expectedAuthority = "https://login.contoso.com";
44+
45+
var git = new TestGit
46+
{
47+
Configuration =
48+
{
49+
Global =
50+
{
51+
[key] = new[] {expectedAuthority}
52+
}
53+
}
54+
};
55+
56+
var trace = new NullTrace();
57+
var cache = new AzureDevOpsAuthorityCache(trace, git);
58+
59+
string actualAuthority = cache.GetAuthority(orgName);
60+
61+
Assert.Equal(expectedAuthority, actualAuthority);
62+
}
63+
64+
[Fact]
65+
public void AzureReposAuthorityCache_UpdateAuthority_NoCachedAuthority_SetsAuthority()
66+
{
67+
const string orgName = "contoso";
68+
string key = CreateKey(orgName);
69+
const string expectedAuthority = "https://login.contoso.com";
70+
71+
var trace = new NullTrace();
72+
var git = new TestGit();
73+
var cache = new AzureDevOpsAuthorityCache(trace, git);
74+
75+
cache.UpdateAuthority(orgName, expectedAuthority);
76+
77+
Assert.True(git.Configuration.Global.TryGetValue(key, out IList<string> values));
78+
Assert.Single(values);
79+
string actualAuthority = values[0];
80+
Assert.Equal(expectedAuthority, actualAuthority);
81+
}
82+
83+
[Fact]
84+
public void AzureReposAuthorityCache_UpdateAuthority_CachedAuthority_UpdatesAuthority()
85+
{
86+
const string orgName = "contoso";
87+
string key = CreateKey(orgName);
88+
const string oldAuthority = "https://old-login.contoso.com";
89+
const string expectedAuthority = "https://login.contoso.com";
90+
91+
var git = new TestGit
92+
{
93+
Configuration =
94+
{
95+
Global =
96+
{
97+
[key] = new[] {oldAuthority}
98+
}
99+
}
100+
};
101+
102+
var trace = new NullTrace();
103+
var cache = new AzureDevOpsAuthorityCache(trace, git);
104+
105+
cache.UpdateAuthority(orgName, expectedAuthority);
106+
107+
Assert.True(git.Configuration.Global.TryGetValue(key, out IList<string> values));
108+
Assert.Single(values);
109+
string actualAuthority = values[0];
110+
Assert.Equal(expectedAuthority, actualAuthority);
111+
}
112+
113+
[Fact]
114+
public void AzureReposAuthorityCache_EraseAuthority_NoCachedAuthority_DoesNothing()
115+
{
116+
const string orgName = "contoso";
117+
string key = CreateKey(orgName);
118+
string otherKey = CreateKey("org.fabrikam.authority");
119+
const string otherAuthority = "https://fabrikam.com/login";
120+
121+
var git = new TestGit
122+
{
123+
Configuration =
124+
{
125+
Global =
126+
{
127+
[otherKey] = new[] {otherAuthority}
128+
}
129+
}
130+
};
131+
132+
var trace = new NullTrace();
133+
var cache = new AzureDevOpsAuthorityCache(trace, git);
134+
135+
cache.EraseAuthority(orgName);
136+
137+
// Other entries should remain
138+
Assert.False(git.Configuration.Global.ContainsKey(key));
139+
Assert.Single(git.Configuration.Global);
140+
Assert.True(git.Configuration.Global.TryGetValue(otherKey, out IList<string> values));
141+
Assert.Single(values);
142+
string actualOtherAuthority = values[0];
143+
Assert.Equal(otherAuthority, actualOtherAuthority);
144+
}
145+
146+
[Fact]
147+
public void AzureReposAuthorityCache_EraseAuthority_CachedAuthority_RemovesAuthority()
148+
{
149+
const string orgName = "contoso";
150+
string key = CreateKey(orgName);
151+
const string authority = "https://login.contoso.com";
152+
string otherKey = CreateKey("fabrikam");
153+
const string otherAuthority = "https://fabrikam.com/login";
154+
155+
var git = new TestGit
156+
{
157+
Configuration =
158+
{
159+
Global =
160+
{
161+
[key] = new[] {authority},
162+
[otherKey] = new[] {otherAuthority}
163+
}
164+
}
165+
};
166+
167+
var trace = new NullTrace();
168+
var cache = new AzureDevOpsAuthorityCache(trace, git);
169+
170+
cache.EraseAuthority(orgName);
171+
172+
// Only the other entries should remain
173+
Assert.False(git.Configuration.Global.ContainsKey(key));
174+
Assert.Single(git.Configuration.Global);
175+
Assert.True(git.Configuration.Global.TryGetValue(otherKey, out IList<string> values));
176+
Assert.Single(values);
177+
string actualOtherAuthority = values[0];
178+
Assert.Equal(otherAuthority, actualOtherAuthority);
179+
}
180+
181+
private static string CreateKey(string orgName)
182+
{
183+
return string.Format(CultureInfo.InvariantCulture, "{0}.{1}:{2}/{3}.{4}",
184+
Constants.GitConfiguration.Credential.SectionName,
185+
AzureDevOpsConstants.UrnScheme, AzureDevOpsConstants.UrnOrgPrefix, orgName,
186+
AzureDevOpsConstants.GitConfiguration.Credential.AzureAuthority);
187+
}
188+
}
189+
}

src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,17 @@ public async Task AzureReposProvider_GetCredentialAsync_UnencryptedHttp_ThrowsEx
132132
var context = new TestCommandContext();
133133
var azDevOps = Mock.Of<IAzureDevOpsRestApi>();
134134
var msAuth = Mock.Of<IMicrosoftAuthentication>();
135+
var authorityCache = Mock.Of<IAzureDevOpsAuthorityCache>();
135136

136-
var provider = new AzureReposHostProvider(context, azDevOps, msAuth);
137+
var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache);
137138

138139
await Assert.ThrowsAsync<Exception>(() => provider.GetCredentialAsync(input));
139140
}
140141

141142
[Fact]
142-
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_ReturnsCredential()
143+
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_VsComUrlUser_ReturnsCredential()
143144
{
145+
var orgName = "org";
144146
var urlAccount = "jane.doe";
145147

146148
var input = new InputArguments(new Dictionary<string, string>
@@ -172,7 +174,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_Ret
172174
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
173175
.ReturnsAsync(authResult);
174176

175-
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
177+
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
178+
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);
179+
180+
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);
176181

177182
ICredential credential = await provider.GetCredentialAsync(input);
178183

@@ -182,8 +187,9 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_Ret
182187
}
183188

184189
[Fact]
185-
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_ReturnsCredential()
190+
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlUser_ReturnsCredential()
186191
{
192+
var orgName = "org";
187193
var urlAccount = "jane.doe";
188194

189195
var input = new InputArguments(new Dictionary<string, string>
@@ -216,7 +222,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_
216222
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
217223
.ReturnsAsync(authResult);
218224

219-
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
225+
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
226+
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);
227+
228+
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);
220229

221230
ICredential credential = await provider.GetCredentialAsync(input);
222231

@@ -226,8 +235,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_
226235
}
227236

228237
[Fact]
229-
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgName_ReturnsCredential()
238+
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlOrgName_ReturnsCredential()
230239
{
240+
var orgName = "org";
241+
231242
var input = new InputArguments(new Dictionary<string, string>
232243
{
233244
["protocol"] = "https",
@@ -259,7 +270,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgNa
259270
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
260271
.ReturnsAsync(authResult);
261272

262-
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
273+
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
274+
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);
275+
276+
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);
263277

264278
ICredential credential = await provider.GetCredentialAsync(input);
265279

@@ -269,8 +283,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgNa
269283
}
270284

271285
[Fact]
272-
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCredential()
286+
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthority_ReturnsCredential()
273287
{
288+
var orgName = "org";
289+
274290
var input = new InputArguments(new Dictionary<string, string>
275291
{
276292
["protocol"] = "https",
@@ -301,7 +317,11 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCr
301317
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
302318
.ReturnsAsync(authResult);
303319

304-
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
320+
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
321+
authorityCacheMock.Setup(x => x.GetAuthority(It.IsAny<string>())).Returns((string)null);
322+
authorityCacheMock.Setup(x => x.UpdateAuthority(orgName, authorityUrl));
323+
324+
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);
305325

306326
ICredential credential = await provider.GetCredentialAsync(input);
307327

@@ -313,6 +333,8 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCr
313333
[Fact]
314334
public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_GeneratesCredential()
315335
{
336+
var orgName = "org";
337+
316338
var input = new InputArguments(new Dictionary<string, string>
317339
{
318340
["protocol"] = "https",
@@ -342,7 +364,9 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge
342364
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
343365
.ReturnsAsync(authResult);
344366

345-
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
367+
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
368+
369+
var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);
346370

347371
ICredential credential = await provider.GetCredentialAsync(input);
348372

@@ -372,8 +396,9 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu
372396

373397
var azDevOps = Mock.Of<IAzureDevOpsRestApi>();
374398
var msAuth = Mock.Of<IMicrosoftAuthentication>();
399+
var authorityCache = Mock.Of<IAzureDevOpsAuthorityCache>();
375400

376-
var provider = new AzureReposHostProvider(context, azDevOps, msAuth);
401+
var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache);
377402

378403
ICredential credential = await provider.GetCredentialAsync(input);
379404

0 commit comments

Comments
 (0)