microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
next/core

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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