microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/msal-agentic-cache

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/test/Microsoft.Teams.Apps.UnitTests/OAuthFlowTests.cs

513lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using Microsoft.AspNetCore.Http;
5using Microsoft.Extensions.Configuration;
6using Microsoft.Extensions.Logging.Abstractions;
7using Microsoft.Teams.Apps.Api.Clients;
8using Microsoft.Teams.Apps.Handlers;
9using Microsoft.Teams.Apps.OAuth;
10using Microsoft.Teams.Apps.Schema;
11using Microsoft.Teams.Core;
12using Microsoft.Teams.Core.Schema;
13using Moq;
14
15namespace Microsoft.Teams.Apps.UnitTests;
16
17public class OAuthFlowTests
18{
19 private const string GraphConnection = "graph";
20 private const string GitHubConnection = "github";
21 private const string TestUserId = "user-1";
22 private const string TestChannelId = "msteams";
23
24 // ==================== signin/failure scoping ====================
25
26 [Fact]
27 public async Task SignInFailure_OnlyNotifiesFlowWithPendingSignIn()
28 {
29 // Arrange
30 TestHarness harness = CreateHarness(GraphConnection, GitHubConnection);
31 bool graphFailureFired = false;
32 bool githubFailureFired = false;
33
34 harness.GraphFlow!.OnSignInFailure((_, _, _) => { graphFailureFired = true; return Task.CompletedTask; });
35 harness.GitHubFlow!.OnSignInFailure((_, _, _) => { githubFailureFired = true; return Task.CompletedTask; });
36
37 // Initiate sign-in only for Graph (sends OAuthCard -> marks pending)
38 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
39 SetupGetSignInResource(harness.MockUserTokenClient);
40 SetupSendActivity(harness);
41
42 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
43 await harness.GraphFlow.SignInAsync(ctx);
44
45 // Act - simulate signin/failure invoke for the same user
46 Context<InvokeActivity> failureCtx = CreateInvokeContext(harness, TestUserId);
47 SignInFailureValue failureValue = new() { Code = "tokenmissing", Message = "Token acquisition failed." };
48
49 // The route handler filters by HasPendingSignIn, so verify the flags
50 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
51 Assert.False(harness.GitHubFlow.HasPendingSignIn(TestUserId));
52
53 await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, failureValue, CancellationToken.None);
54
55 // Assert - only Graph callback fired
56 Assert.True(graphFailureFired);
57 Assert.False(githubFailureFired);
58 }
59
60 [Fact]
61 public async Task SignInFailure_ClearsPendingSignIn()
62 {
63 TestHarness harness = CreateHarness(GraphConnection);
64
65 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
66 SetupGetSignInResource(harness.MockUserTokenClient);
67 SetupSendActivity(harness);
68
69 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
70 await harness.GraphFlow!.SignInAsync(ctx);
71
72 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
73
74 // Act
75 Context<InvokeActivity> failureCtx = CreateInvokeContext(harness, TestUserId);
76 await harness.GraphFlow.HandleSignInFailureAsync(failureCtx, new SignInFailureValue { Code = "invokeerror" }, CancellationToken.None);
77
78 // Assert
79 Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId));
80 }
81
82 [Fact]
83 public async Task TokenExchange_Success_ClearsPendingSignIn()
84 {
85 TestHarness harness = CreateHarness(GraphConnection);
86
87 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
88 SetupGetSignInResource(harness.MockUserTokenClient);
89 SetupSendActivity(harness);
90
91 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
92 await harness.GraphFlow!.SignInAsync(ctx);
93
94 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
95
96 // Arrange exchange
97 harness.MockUserTokenClient
98 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()))
99 .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection });
100
101 SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-1", ConnectionName = GraphConnection, Token = "sso-token" };
102 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
103
104 // Act
105 InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
106
107 // Assert
108 Assert.Equal(200, response.Status);
109 Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId));
110 }
111
112 [Fact]
113 public async Task TokenExchange_Failure_ClearsPendingSignIn()
114 {
115 TestHarness harness = CreateHarness(GraphConnection);
116
117 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
118 SetupGetSignInResource(harness.MockUserTokenClient);
119 SetupSendActivity(harness);
120
121 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
122 await harness.GraphFlow!.SignInAsync(ctx);
123
124 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
125
126 // Arrange exchange failure
127 harness.MockUserTokenClient
128 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "bad-token", It.IsAny<CancellationToken>()))
129 .ThrowsAsync(new HttpRequestException("Unauthorized", null, System.Net.HttpStatusCode.Unauthorized));
130
131 SignInTokenExchangeValue exchangeValue = new() { Id = "exchange-2", ConnectionName = GraphConnection, Token = "bad-token" };
132 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
133
134 // Act
135 InvokeResponse response = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
136
137 // Assert - 401 passed through (unexpected code)
138 Assert.Equal(401, response.Status);
139 Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId));
140 }
141
142 [Fact]
143 public async Task VerifyState_Success_ClearsPendingSignIn()
144 {
145 TestHarness harness = CreateHarness(GraphConnection);
146
147 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
148 SetupGetSignInResource(harness.MockUserTokenClient);
149 SetupSendActivity(harness);
150
151 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
152 await harness.GraphFlow!.SignInAsync(ctx);
153
154 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
155
156 // Arrange verify state
157 harness.MockUserTokenClient
158 .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "123456", It.IsAny<CancellationToken>()))
159 .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection });
160
161 SignInVerifyStateValue verifyValue = new() { State = "123456" };
162 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
163
164 // Act
165 InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None);
166
167 // Assert
168 Assert.Equal(200, response.Status);
169 Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId));
170 }
171
172 // ==================== No pending sign-in for unrelated user ====================
173
174 [Fact]
175 public async Task HasPendingSignIn_FalseForDifferentUser()
176 {
177 TestHarness harness = CreateHarness(GraphConnection);
178
179 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
180 SetupGetSignInResource(harness.MockUserTokenClient);
181 SetupSendActivity(harness);
182
183 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
184 await harness.GraphFlow!.SignInAsync(ctx);
185
186 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
187 Assert.False(harness.GraphFlow.HasPendingSignIn("other-user"));
188 }
189
190 // ==================== Token exchange error code mapping ====================
191
192 [Fact]
193 public async Task TokenExchange_ExpectedError_Returns412WithBody()
194 {
195 TestHarness harness = CreateHarness(GraphConnection);
196
197 harness.MockUserTokenClient
198 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()))
199 .ThrowsAsync(new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound));
200
201 SignInTokenExchangeValue exchangeValue = new() { Id = "ex-1", ConnectionName = GraphConnection, Token = "sso-token" };
202 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
203
204 InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
205
206 Assert.Equal(412, response.Status);
207 Assert.NotNull(response.Body);
208 }
209
210 [Fact]
211 public async Task TokenExchange_UnexpectedError_ReturnsOriginalStatusCode()
212 {
213 TestHarness harness = CreateHarness(GraphConnection);
214
215 harness.MockUserTokenClient
216 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()))
217 .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden));
218
219 SignInTokenExchangeValue exchangeValue = new() { Id = "ex-2", ConnectionName = GraphConnection, Token = "sso-token" };
220 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
221
222 InvokeResponse response = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
223
224 Assert.Equal(403, response.Status);
225 }
226
227 // ==================== Token exchange deduplication ====================
228
229 [Fact]
230 public async Task TokenExchange_Duplicate_Returns200NoOp()
231 {
232 TestHarness harness = CreateHarness(GraphConnection);
233
234 harness.MockUserTokenClient
235 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()))
236 .ReturnsAsync(new GetTokenResult { Token = "access-token", ConnectionName = GraphConnection });
237
238 SignInTokenExchangeValue exchangeValue = new() { Id = "dup-1", ConnectionName = GraphConnection, Token = "sso-token" };
239 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
240
241 // First call
242 InvokeResponse first = await harness.GraphFlow!.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
243 Assert.Equal(200, first.Status);
244
245 // Second call with same exchange ID
246 InvokeResponse second = await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
247 Assert.Equal(200, second.Status);
248
249 // ExchangeTokenAsync only called once
250 harness.MockUserTokenClient.Verify(
251 c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()),
252 Times.Once);
253 }
254
255 // ==================== verifyState error codes ====================
256
257 [Fact]
258 public async Task VerifyState_NullState_Returns404()
259 {
260 TestHarness harness = CreateHarness(GraphConnection);
261
262 SignInVerifyStateValue verifyValue = new() { State = null };
263 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
264
265 InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None);
266
267 Assert.Equal(404, response.Status);
268 }
269
270 [Fact]
271 public async Task VerifyState_NoToken_Returns412_WithoutFiringFailureCallback()
272 {
273 TestHarness harness = CreateHarness(GraphConnection);
274 bool failureFired = false;
275 harness.GraphFlow!.OnSignInFailure((_, _, _) => { failureFired = true; return Task.CompletedTask; });
276
277 harness.MockUserTokenClient
278 .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "badcode", It.IsAny<CancellationToken>()))
279 .ReturnsAsync((GetTokenResult?)null);
280
281 SignInVerifyStateValue verifyValue = new() { State = "badcode" };
282 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
283
284 InvokeResponse response = await harness.GraphFlow.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None);
285
286 Assert.Equal(412, response.Status);
287 // No token means the code belongs to another connection — NOT a failure
288 Assert.False(failureFired);
289 }
290
291 [Fact]
292 public async Task VerifyState_ExpectedError_Returns412()
293 {
294 TestHarness harness = CreateHarness(GraphConnection);
295
296 harness.MockUserTokenClient
297 .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny<CancellationToken>()))
298 .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest));
299
300 SignInVerifyStateValue verifyValue = new() { State = "code" };
301 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
302
303 InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None);
304
305 Assert.Equal(412, response.Status);
306 }
307
308 [Fact]
309 public async Task VerifyState_UnexpectedError_ReturnsOriginalStatusCode()
310 {
311 TestHarness harness = CreateHarness(GraphConnection);
312
313 harness.MockUserTokenClient
314 .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, "code", It.IsAny<CancellationToken>()))
315 .ThrowsAsync(new HttpRequestException("Forbidden", null, System.Net.HttpStatusCode.Forbidden));
316
317 SignInVerifyStateValue verifyValue = new() { State = "code" };
318 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
319
320 InvokeResponse response = await harness.GraphFlow!.HandleVerifyStateAsync(invokeCtx, verifyValue, CancellationToken.None);
321
322 Assert.Equal(403, response.Status);
323 }
324
325 // ==================== signin/failure callback receives failure details ====================
326
327 [Fact]
328 public async Task SignInFailure_CallbackReceivesFailureDetails()
329 {
330 TestHarness harness = CreateHarness(GraphConnection);
331 SignInFailureValue? receivedFailure = null;
332
333 harness.GraphFlow!.OnSignInFailure((_, failure, _) => { receivedFailure = failure; return Task.CompletedTask; });
334
335 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
336 SignInFailureValue failureValue = new() { Code = "resourcematchfailed", Message = "URI mismatch" };
337
338 await harness.GraphFlow.HandleSignInFailureAsync(invokeCtx, failureValue, CancellationToken.None);
339
340 Assert.NotNull(receivedFailure);
341 Assert.Equal("resourcematchfailed", receivedFailure.Code);
342 Assert.Equal("URI mismatch", receivedFailure.Message);
343 }
344
345 [Fact]
346 public async Task TokenExchange_FailureCallback_ReceivesNullFailureValue()
347 {
348 TestHarness harness = CreateHarness(GraphConnection);
349 SignInFailureValue? receivedFailure = new() { Code = "sentinel" };
350 bool callbackFired = false;
351
352 harness.GraphFlow!.OnSignInFailure((_, failure, _) =>
353 {
354 callbackFired = true;
355 receivedFailure = failure;
356 return Task.CompletedTask;
357 });
358
359 harness.MockUserTokenClient
360 .Setup(c => c.ExchangeTokenAsync(TestUserId, GraphConnection, TestChannelId, "sso-token", It.IsAny<CancellationToken>()))
361 .ThrowsAsync(new HttpRequestException("Bad request", null, System.Net.HttpStatusCode.BadRequest));
362
363 SignInTokenExchangeValue exchangeValue = new() { Id = "ex-fail", ConnectionName = GraphConnection, Token = "sso-token" };
364 Context<InvokeActivity> invokeCtx = CreateInvokeContext(harness, TestUserId);
365
366 await harness.GraphFlow.HandleTokenExchangeAsync(invokeCtx, exchangeValue, CancellationToken.None);
367
368 Assert.True(callbackFired);
369 Assert.Null(receivedFailure);
370 }
371
372 // ==================== SignInAsync returns token when cached ====================
373
374 [Fact]
375 public async Task SignInAsync_WithCachedToken_ReturnsToken()
376 {
377 TestHarness harness = CreateHarness(GraphConnection);
378
379 harness.MockUserTokenClient
380 .Setup(c => c.GetTokenAsync(TestUserId, GraphConnection, TestChannelId, null, It.IsAny<CancellationToken>()))
381 .ReturnsAsync(new GetTokenResult { Token = "cached-token", ConnectionName = GraphConnection });
382
383 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
384 string? token = await harness.GraphFlow!.SignInAsync(ctx);
385
386 Assert.Equal("cached-token", token);
387 Assert.False(harness.GraphFlow.HasPendingSignIn(TestUserId));
388 }
389
390 [Fact]
391 public async Task SignInAsync_NoToken_SendsOAuthCardAndReturnsNull()
392 {
393 TestHarness harness = CreateHarness(GraphConnection);
394
395 SetupSilentTokenReturnsNull(harness.MockUserTokenClient, GraphConnection);
396 SetupGetSignInResource(harness.MockUserTokenClient);
397 SetupSendActivity(harness);
398
399 Context<MessageActivity> ctx = CreateMessageContext(harness, TestUserId);
400 string? token = await harness.GraphFlow!.SignInAsync(ctx);
401
402 Assert.Null(token);
403 Assert.True(harness.GraphFlow.HasPendingSignIn(TestUserId));
404 }
405
406 // ==================== Helpers ====================
407
408 private sealed class TestHarness
409 {
410 public required TeamsBotApplication App { get; init; }
411 public required Mock<UserTokenClient> MockUserTokenClient { get; init; }
412 public required Mock<ConversationClient> MockConversationClient { get; init; }
413 public OAuthFlow? GraphFlow { get; init; }
414 public OAuthFlow? GitHubFlow { get; init; }
415 }
416
417 private static TestHarness CreateHarness(params string[] connectionNames)
418 {
419 Mock<UserTokenClient> mockUserTokenClient = CreateMockUserTokenClient();
420 Mock<ConversationClient> mockConversationClient = new(new HttpClient(), NullLogger<ConversationClient>.Instance);
421
422 ApiClient apiClient = new(
423 new HttpClient(),
424 mockConversationClient.Object,
425 mockUserTokenClient.Object);
426
427 TeamsBotApplication app = new(
428 apiClient,
429 new HttpContextAccessor(),
430 NullLogger<TeamsBotApplication>.Instance,
431 new TeamsBotApplicationOptions { AppId = "test-app-id" });
432
433 OAuthFlow? graphFlow = null;
434 OAuthFlow? githubFlow = null;
435
436 foreach (string name in connectionNames)
437 {
438 OAuthFlow flow = app.AddOAuthFlow(name);
439 if (name == GraphConnection) graphFlow = flow;
440 else if (name == GitHubConnection) githubFlow = flow;
441 }
442
443 return new TestHarness
444 {
445 App = app,
446 MockUserTokenClient = mockUserTokenClient,
447 MockConversationClient = mockConversationClient,
448 GraphFlow = graphFlow,
449 GitHubFlow = githubFlow
450 };
451 }
452
453 private static Mock<UserTokenClient> CreateMockUserTokenClient()
454 {
455 Mock<IConfiguration> mockConfig = new();
456 return new Mock<UserTokenClient>(
457 new HttpClient(),
458 mockConfig.Object,
459 NullLogger<UserTokenClient>.Instance);
460 }
461
462 private static Context<MessageActivity> CreateMessageContext(TestHarness harness, string userId)
463 {
464 MessageActivity activity = new("hello")
465 {
466 ChannelId = TestChannelId,
467 From = new TeamsConversationAccount { Id = userId },
468 Recipient = new TeamsConversationAccount { Id = "bot-id" },
469 Conversation = new TeamsConversation { Id = "conv-1" },
470 ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"),
471 };
472
473 return new Context<MessageActivity>(harness.App, activity);
474 }
475
476 private static Context<InvokeActivity> CreateInvokeContext(TestHarness harness, string userId)
477 {
478 InvokeActivity activity = new()
479 {
480 ChannelId = TestChannelId,
481 From = new TeamsConversationAccount { Id = userId },
482 Recipient = new TeamsConversationAccount { Id = "bot-id" },
483 Conversation = new TeamsConversation { Id = "conv-1" },
484 ServiceUrl = new Uri("https://smba.trafficmanager.net/test/"),
485 };
486
487 return new Context<InvokeActivity>(harness.App, activity);
488 }
489
490 private static void SetupSilentTokenReturnsNull(Mock<UserTokenClient> mock, string connectionName)
491 {
492 mock.Setup(c => c.GetTokenAsync(TestUserId, connectionName, TestChannelId, null, It.IsAny<CancellationToken>()))
493 .ReturnsAsync((GetTokenResult?)null);
494 }
495
496 private static void SetupGetSignInResource(Mock<UserTokenClient> mock)
497 {
498 mock.Setup(c => c.GetSignInResourceAsync(It.IsAny<string>(), null, null, null, It.IsAny<CancellationToken>()))
499 .ReturnsAsync(new GetSignInResourceResult
500 {
501 SignInLink = "https://login.microsoftonline.com/test",
502 TokenExchangeResource = new TokenExchangeResource { Id = "tex-1", Uri = new Uri("api://test") },
503 TokenPostResource = new TokenPostResource { SasUrl = new Uri("https://token.botframework.com/test") }
504 });
505 }
506
507 private static void SetupSendActivity(TestHarness harness)
508 {
509 harness.MockConversationClient
510 .Setup(c => c.SendActivityAsync(It.IsAny<CoreActivity>(), It.IsAny<Dictionary<string, string>>(), It.IsAny<CancellationToken>()))
511 .ReturnsAsync(new SendActivityResponse { Id = "activity-1" });
512 }
513}
514