using System.Net; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Teams.Api; using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Events; using Microsoft.Teams.Common.Logging; using Moq; namespace Microsoft.Teams.Plugins.AspNetCore.Tests; public class AspNetCorePluginTests { private static AspNetCorePlugin CreatePlugin(Mock? loggerMock = null, EventFunction? events = null) { var plugin = new AspNetCorePlugin(); if (loggerMock is not null) { plugin.Logger = loggerMock.Object; } else { plugin.Logger = new ConsoleLogger("Test", LogLevel.Debug); } plugin.Client = new Mock().Object; if (events is not null) { plugin.Events += events; } return plugin; } private static DefaultHttpContext CreateHttpContext(IActivity activity, string bearer = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDI1MTUyMDB9.signature") { var ctx = new DefaultHttpContext(); ctx.TraceIdentifier = Guid.NewGuid().ToString(); ctx.Request.Headers.Append("Authorization", $"Bearer {bearer}"); var json = JsonSerializer.Serialize(activity, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); var bytes = Encoding.UTF8.GetBytes(json); ctx.Request.Body = new MemoryStream(bytes); ctx.Request.ContentLength = bytes.Length; return ctx; } private static MessageActivity CreateMessageActivity(string? serviceUrl = null) { return new MessageActivity("hi") { From = new() { Id = "user" }, Recipient = new() { Id = "bot" }, Conversation = new Conversation() { Id = "conv", Type = ConversationType.Personal }, ServiceUrl = serviceUrl }; } // Builds an unsigned-but-parseable JWT carrying the given serviceurl claim (omitted when null). // JsonWebToken/JwtSecurityTokenHandler.ReadJwtToken parses without verifying the signature. private static string CreateJwt(string? serviceUrl) { var payload = new Dictionary { ["exp"] = 4702515200L }; if (serviceUrl is not null) { payload["serviceurl"] = serviceUrl; } return $"{Base64Url(new { alg = "HS256", typ = "JWT" })}.{Base64Url(payload)}.signature"; } private static string Base64Url(object value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value))) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); [Fact] public async Task Test_Do_Http_CallsExtractTokenAndActivity_AndCallsCoreDo() { // Arrange var activity = CreateMessageActivity(); var coreResponse = new Response(HttpStatusCode.Accepted, new { ok = true }); var eventsCalled = new List(); EventFunction events = (plugin, name, payload, ct) => { eventsCalled.Add(name); if (name == "activity") return Task.FromResult(coreResponse); // returned directly by core Do return Task.FromResult(null); }; var logger = new Mock(); var plugin = CreatePlugin(logger, events); var ctx = CreateHttpContext(activity); // Act var result = await plugin.Do(ctx); // Assert Assert.Contains("activity", eventsCalled); var jsonResult = Assert.IsType>(result); Assert.Equal((int)coreResponse.Status, jsonResult.StatusCode); } [Fact] public async Task Test_Do_Http_SetsHeadersFromResponseMeta() { // Arrange var activity = CreateMessageActivity(); var response = new Response(HttpStatusCode.OK, new { hello = "world" }); response.Meta.Add("routes", 3); response.Meta.Add("custom", "value"); EventFunction events = (plugin, name, payload, ct) => { if (name == "activity") return Task.FromResult(response); return Task.FromResult(null); }; var plugin = CreatePlugin(new Mock(), events); var ctx = CreateHttpContext(activity); // Act var result = await plugin.Do(ctx); // Assert body result type var jsonResult = Assert.IsType>(result); Assert.Equal((int)HttpStatusCode.OK, jsonResult.StatusCode); // Headers: routes & custom should be present Assert.Contains("X-Teams-Routes", ctx.Response.Headers.Keys); // capitalized first char Assert.Contains("X-Teams-Custom", ctx.Response.Headers.Keys); } [Fact] public async Task Test_Do_Http_ErrorPath_ProducesProblemResult() { // Arrange -> throw inside events EventFunction events = (plugin, name, payload, ct) => { if (name == "activity") throw new InvalidOperationException("boom"); return Task.FromResult(null); }; var logger = new Mock(); var plugin = CreatePlugin(logger, events); var ctx = CreateHttpContext(CreateMessageActivity()); // Act var result = await plugin.Do(ctx); // Assert var problem = Assert.IsType>(result); Assert.Equal(500, problem.StatusCode); Assert.Contains("boom", problem.Value!.ToString()); logger.Verify(l => l.Error(It.IsAny()), Times.AtLeastOnce); } [Fact] public void Test_ExtractToken_ReturnsToken() { var plugin = CreatePlugin(); var ctx = CreateHttpContext(CreateMessageActivity(), "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDI1MTUyMDB9.token123"); var token = plugin.ExtractToken(ctx.Request); Assert.NotNull(token); Assert.Contains("token123", token.ToString()); } [Fact] public async Task Test_ExtractActivity_ReturnsActivity() { var plugin = CreatePlugin(); var activity = CreateMessageActivity(); var ctx = CreateHttpContext(activity); var extracted = await plugin.ParseActivity(ctx.Request); Assert.NotNull(extracted); Assert.True(activity.Type.Equals(extracted.Type)); } [Fact] public async Task Test_ExtractActivity_HttpRequestBodyAlreadyRead_ReturnsActivity() { var plugin = CreatePlugin(); var activity = CreateMessageActivity(); var ctx = CreateHttpContext(activity); // simulate body already read by setting position to end ctx.Request.Body.Position = ctx.Request.Body.Length; var extracted = await plugin.ParseActivity(ctx.Request); Assert.NotNull(extracted); Assert.True(activity.Type.Equals(extracted.Type)); } [Fact] public async Task Test_Do_Core_ReturnsResponseAndLogs() { // Arrange core path tests the ActivityEvent Do(ActivityEvent) var response = new Response(HttpStatusCode.OK, new { test = 1 }); EventFunction events = (plugin, name, payload, ct) => { if (name == "activity") return Task.FromResult(response); return Task.FromResult(null); }; var logger = new Mock(); var plugin = CreatePlugin(logger, events); var evt = new ActivityEvent() { Token = new JsonWebToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ3MDI1MTUyMDB9.signature"), Activity = CreateMessageActivity() }; // Act var res = await plugin.Do(evt); // Assert Assert.Same(response, res); logger.Verify(l => l.Debug(It.IsAny()), Times.AtLeastOnce); } [Theory] [InlineData("https://smba.trafficmanager.net/teams/", "https://smba.trafficmanager.net/teams/")] // exact match [InlineData("HTTPS://SMBA.TRAFFICMANAGER.NET/teams/", "https://smba.trafficmanager.net/teams/")] // case-insensitive [InlineData("https://smba.trafficmanager.net/teams", "https://smba.trafficmanager.net/teams/")] // trailing-slash normalized public async Task Test_Do_Http_ServiceUrlClaimMatches_Processes(string claimServiceUrl, string activityServiceUrl) { // Arrange var activity = CreateMessageActivity(activityServiceUrl); var coreResponse = new Response(HttpStatusCode.Accepted, new { ok = true }); var eventsCalled = new List(); EventFunction events = (plugin, name, payload, ct) => { eventsCalled.Add(name); if (name == "activity") return Task.FromResult(coreResponse); return Task.FromResult(null); }; var plugin = CreatePlugin(new Mock(), events); var ctx = CreateHttpContext(activity, CreateJwt(claimServiceUrl)); // Act var result = await plugin.Do(ctx); // Assert Assert.Contains("activity", eventsCalled); var jsonResult = Assert.IsType>(result); Assert.Equal((int)coreResponse.Status, jsonResult.StatusCode); } [Fact] public async Task Test_Do_Http_ServiceUrlClaimMismatch_ReturnsUnauthorized() { // Arrange var activity = CreateMessageActivity("https://smba.trafficmanager.net/teams/"); var eventsCalled = new List(); EventFunction events = (plugin, name, payload, ct) => { eventsCalled.Add(name); if (name == "activity") return Task.FromResult(new Response(HttpStatusCode.OK, new { ok = true })); return Task.FromResult(null); }; var logger = new Mock(); var plugin = CreatePlugin(logger, events); var ctx = CreateHttpContext(activity, CreateJwt("https://evil.example.com/")); // Act var result = await plugin.Do(ctx); // Assert: rejected with 401, activity never dispatched, neither URL echoed, logged server-side. var unauthorized = Assert.IsType(result); Assert.Equal(401, unauthorized.StatusCode); Assert.DoesNotContain("activity", eventsCalled); logger.Verify(l => l.Warn(It.IsAny()), Times.Once); } [Fact] public async Task Test_Do_Http_NoServiceUrlClaim_ReturnsUnauthorized() { // Arrange: activity carries a serviceUrl but the token has no serviceurl claim. var activity = CreateMessageActivity("https://smba.trafficmanager.net/teams/"); var eventsCalled = new List(); EventFunction events = (plugin, name, payload, ct) => { eventsCalled.Add(name); if (name == "activity") return Task.FromResult(new Response(HttpStatusCode.OK, new { ok = true })); return Task.FromResult(null); }; var logger = new Mock(); var plugin = CreatePlugin(logger, events); var ctx = CreateHttpContext(activity, CreateJwt(null)); // Act var result = await plugin.Do(ctx); // Assert: a token without a serviceurl claim is rejected when the activity has one. var unauthorized = Assert.IsType(result); Assert.Equal(401, unauthorized.StatusCode); Assert.DoesNotContain("activity", eventsCalled); logger.Verify(l => l.Warn(It.IsAny()), Times.Once); } }