Skip to content

Commit

Permalink
[C#] feat: add unit tests for Teams SSO auth related classes (#1112)
Browse files Browse the repository at this point in the history
## Linked issues

closes: #1053 #1110

## Details

1. Create an adapter for MSAL library to make the classes testable with
mock
2. Update existing class implementation to use the MSAL library adapter
3. Do not check token cache in TeamsSsoPrompt because the Authentication
handler already checks token cache before invoking the prompt
4. Add unit tests

## Attestation Checklist

- [x] My code follows the style guidelines of this project

- I have checked for/fixed spelling, linting, and other errors
- I have commented my code for clarity
- I have made corresponding changes to the documentation (we use
[TypeDoc](https://typedoc.org/) to document our code)
- My changes generate no new warnings
- I have added tests that validates my changes, and provides sufficient
test coverage. I have tested with:
  - Local testing
  - E2E testing in Teams
- New and existing unit tests pass locally with my changes

---------

Co-authored-by: Alex Acebo <[email protected]>
  • Loading branch information
blackchoey and aacebo authored Jan 4, 2024
1 parent 6f744df commit be8825a
Show file tree
Hide file tree
Showing 13 changed files with 985 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Microsoft.Identity.Client;
using System.Security.Cryptography.X509Certificates;

namespace Microsoft.Teams.AI.Tests.Application.Authentication
{
internal class AppConfig : IAppConfig

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Analyze

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (7.0)

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (6.0)

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 6 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/AppConfig.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Type 'AppConfig' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)
{
#pragma warning disable CS8618 // This class is for test purpose only
public AppConfig(string clientId, string tenantId)
#pragma warning restore CS8618
{
ClientId = clientId;
TenantId = tenantId;
}

public string ClientId { get; }

public bool EnablePiiLogging { get; }

public IMsalHttpClientFactory HttpClientFactory { get; }

public LogLevel LogLevel { get; }

public bool IsDefaultPlatformLoggingEnabled { get; }

public string RedirectUri { get; }

public string TenantId { get; }

public LogCallback LoggingCallback { get; }

public IDictionary<string, string> ExtraQueryParameters { get; }

public bool IsBrokerEnabled { get; }

public string ClientName { get; }

public string ClientVersion { get; }

[Obsolete]
public ITelemetryConfig TelemetryConfig { get; }

public bool ExperimentalFeaturesEnabled { get; }

public IEnumerable<string> ClientCapabilities { get; }

public bool LegacyCacheCompatibilityEnabled { get; }

public string ClientSecret { get; }

public X509Certificate2 ClientCredentialCertificate { get; }

public Func<object> ParentActivityOrWindowFunc { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ public async void Test_SignOut_DefaultHandler()
public async void Test_SignOut_SpecificHandler()
{
// arrange
var graphToken = "graph token";
var app = new TestApplication(new TestApplicationOptions());
var options = new AuthenticationOptions<TurnState>();
options._authenticationSettings = new Dictionary<string, object>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Identity.Client;
using Microsoft.Teams.AI.State;
using Microsoft.Teams.AI.Tests.TestUtils;
using Moq;
using Newtonsoft.Json.Linq;

namespace Microsoft.Teams.AI.Tests.Application.Authentication.Bot
{
public class TeamsSsoBotAuthenticationTests
{
internal class MockTeamsSsoBotAuthentication<TState> : TeamsSsoBotAuthentication<TState>

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Analyze

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (7.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (6.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (7.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint / Build/Test/Lint (6.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (7.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)

Check warning on line 14 in dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/Application/Authentication/Bot/TeamsSsoBotAuthenticationTests.cs

View workflow job for this annotation

GitHub Actions / Build/Test/Lint (6.0)

Type 'MockTeamsSsoBotAuthentication' can be sealed because it has no subtypes in its containing assembly and is not externally visible (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1852)
where TState : TurnState, new()
{
public MockTeamsSsoBotAuthentication(Application<TState> app, string name, TeamsSsoSettings settings, TeamsSsoPrompt? mockPrompt = null) : base(app, name, settings, null)
{
if (mockPrompt != null)
{
_prompt = mockPrompt;
}
}

public async Task<bool> TokenExchangeRouteSelectorPublic(ITurnContext context, CancellationToken cancellationToken)
{
return await base.TokenExchangeRouteSelector(context, cancellationToken);
}
}


[Fact]
public async void Test_RunDialog_BeginNew()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var mockedPrompt = CreateTeamsSsoPromptMock(settings);
var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "TokenName", settings, mockedPrompt.Object);
var messageContext = MockTurnContext();
var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(messageContext);

// act
var result = await botAuthentication.RunDialog(messageContext, turnState, "dialogStateProperty");

// assert
Assert.Equal(DialogTurnStatus.Waiting, result.Status);
}

[Fact]
public async void Test_RunDialog_ContinueExisting()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var mockedPrompt = CreateTeamsSsoPromptMock(settings);
var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "TokenName", settings, mockedPrompt.Object);
var messageContext = MockTurnContext();
var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(messageContext);
await botAuthentication.RunDialog(messageContext, turnState, "dialogStateProperty"); // Begin new dialog first

// act
var tokenExchangeContext = MockTokenExchangeContext();
var result = await botAuthentication.RunDialog(tokenExchangeContext, turnState, "dialogStateProperty");

// assert
Assert.Equal(DialogTurnStatus.Complete, result.Status);
}


[Fact]
public async void Test_ContinueDialog()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var mockedPrompt = CreateTeamsSsoPromptMock(settings);
var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "TokenName", settings, mockedPrompt.Object);
var messageContext = MockTurnContext();
var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(messageContext);
await botAuthentication.RunDialog(messageContext, turnState, "dialogStateProperty"); // Begin new dialog first

// act
var tokenExchangeContext = MockTokenExchangeContext();
var result = await botAuthentication.ContinueDialog(tokenExchangeContext, turnState, "dialogStateProperty");

// assert
Assert.Equal(DialogTurnStatus.Complete, result.Status);
}

[Fact]
public async void Test_TokenExchangeRouteSelector_NameMatched()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var turnContext = MockTokenExchangeContext("test");

var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "test", settings);

// act
var result = await botAuthentication.TokenExchangeRouteSelectorPublic(turnContext, CancellationToken.None);

// assert
Assert.True(result);
}

[Fact]
public async void Test_TokenExchangeRouteSelector_NameNotMatch()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var turnContext = MockTokenExchangeContext("AnotherTokenName");

var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "test", settings);

// act
var result = await botAuthentication.TokenExchangeRouteSelectorPublic(turnContext, CancellationToken.None);

// assert
Assert.False(result);
}

[Fact]
public async void Test_Dedupe()
{
// arrange
var app = new Application<TurnState>(new ApplicationOptions<TurnState>());
var msal = ConfidentialClientApplicationBuilder.Create("clientId").WithClientSecret("clientSecret").Build();
var settings = new TeamsSsoSettings(new string[] { "User.Read" }, "https://localhost/auth-start.html", msal);
var mockedPrompt = CreateTeamsSsoPromptMock(settings);
var botAuthentication = new MockTeamsSsoBotAuthentication<TurnState>(app, "TokenName", settings, mockedPrompt.Object);

// act
var messageContext = MockTurnContext();
var turnState = await TurnStateConfig.GetTurnStateWithConversationStateAsync(messageContext);
await botAuthentication.RunDialog(messageContext, turnState, "dialogStateProperty");
var tokenExchangeContext = MockTokenExchangeContext();
var tokenExchangeResult = await botAuthentication.ContinueDialog(tokenExchangeContext, turnState, "dialogStateProperty");

// assert
Assert.NotNull(tokenExchangeResult.Result);
Assert.Equal("test token", ((TokenResponse)tokenExchangeResult.Result).Token);

// act - simulate processing duplicate request
await botAuthentication.RunDialog(messageContext, turnState, "dialogStateProperty");
tokenExchangeResult = await botAuthentication.ContinueDialog(tokenExchangeContext, turnState, "dialogStateProperty");

// assert
Assert.Equal(DialogTurnStatus.Waiting, tokenExchangeResult.Status);
}

private static Mock<TeamsSsoPrompt> CreateTeamsSsoPromptMock(TeamsSsoSettings settings)
{
var mockedPrompt = new Mock<TeamsSsoPrompt>("TeamsSsoPrompt", "TokenName", settings);
mockedPrompt
.Setup(mock => mock.BeginDialogAsync(It.IsAny<DialogContext>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DialogTurnResult(DialogTurnStatus.Waiting));
mockedPrompt
.Setup(mock => mock.ContinueDialogAsync(It.IsAny<DialogContext>(), It.IsAny<CancellationToken>()))
.Returns(async (DialogContext dc, CancellationToken cancellationToken) =>
{
return await dc.EndDialogAsync(new TokenResponse(token: "test token"));
});
return mockedPrompt;
}

private static TurnContext MockTurnContext(string type = ActivityTypes.Message, string? name = null)
{
return new TurnContext(new SimpleAdapter(), new Activity()
{
Type = type,
Recipient = new() { Id = "recipientId" },
Conversation = new() { Id = "conversationId" },
From = new() { Id = "fromId" },
ChannelId = "channelId",
Name = name
});
}

private static TurnContext MockTokenExchangeContext(string settingName = "test")
{
JObject activityValue = new();
activityValue["id"] = $"{Guid.NewGuid()}-{settingName}";

return new TurnContext(new SimpleAdapter(), new Activity()
{
Type = ActivityTypes.Invoke,
Name = SignInConstants.TokenExchangeOperationName,
Recipient = new() { Id = "recipientId" },
Conversation = new() { Id = "conversationId" },
From = new() { Id = "fromId" },
ChannelId = "channelId",
Value = activityValue
});
}
}
}
Loading

0 comments on commit be8825a

Please sign in to comment.