diff --git a/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs index 2e0993df..563f6218 100644 --- a/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs +++ b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs @@ -82,7 +82,7 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds Assert.Fail(TC.WebAppCrashedString + " " + runningProcesses.ToString()); } - LabResponse labResponse = await LabUserHelper.GetSpecificUserAsync(TC.OIDCUser); + LabResponse labResponse = await LabUserHelper.GetSpecificUserAsync(TC.MsidLab3User); // Initial sign in _output.WriteLine("Starting web app sign-in flow."); diff --git a/UiTests/Common/Common.csproj b/UiTests/Common/Common.csproj index 7fefe64d..8fe7bda9 100644 --- a/UiTests/Common/Common.csproj +++ b/UiTests/Common/Common.csproj @@ -9,6 +9,7 @@ + diff --git a/UiTests/Common/TestConstants.cs b/UiTests/Common/TestConstants.cs index 426e4fec..7662a2ec 100644 --- a/UiTests/Common/TestConstants.cs +++ b/UiTests/Common/TestConstants.cs @@ -20,7 +20,8 @@ public static class TestConstants public const string HttpsStarColon = "https://*:"; public const string KestrelEndpointEnvVar = "Kestrel:Endpoints:Http:Url"; public const string LocalhostUrl = @"https://localhost:"; - public const string OIDCUser = "fIDLAB@MSIDLAB3.com"; + public const string MsidLab3User = "fIDLAB@MSIDLAB3.com"; + public const string MsidLab4User = "idlab@msidlab4.onmicrosoft.com"; public const string PasswordText = "Password"; public const string ServerFilePrefix = "server_"; public const string TodoTitle1 = "Testing create todo item"; diff --git a/UiTests/Common/UiTestHelpers.cs b/UiTests/Common/UiTestHelpers.cs index 75a467f7..970dc3fd 100644 --- a/UiTests/Common/UiTestHelpers.cs +++ b/UiTests/Common/UiTestHelpers.cs @@ -3,6 +3,7 @@ using Azure.Core; using Azure.Security.KeyVault.Secrets; +using Microsoft.Data.SqlClient; using Microsoft.Playwright; using System.Diagnostics; using System.Management; @@ -556,11 +557,94 @@ string solutionFileName finally { SwapFiles(appsettingsAbsPath, testAppsettingsAbsPath); } } + /// + /// Builds the sample app using the appsettings.json file in the sample app's directory. + /// + /// Absolute path to the current test's working directory + /// Relative path to the sample app to build starting at the repo's root, does not include appsettings filename + /// Filename for the sln file to build public static void BuildSampleUsingSampleAppsettings(string testAssemblyLocation, string sampleRelPath, string solutionFileName) { string appsDirectory = GetAbsoluteAppDirectory(testAssemblyLocation, sampleRelPath); BuildSolution(Path.Combine(appsDirectory, solutionFileName)); } + + /// + /// Checks to see if the specified database and token cache table exist in the given server and creates them if they do not. + /// + /// The string representing the server location + /// Name of the database where the Token Cache will be held + /// Name of the table that holds the token cache + /// Enables writing to the test's output + public static void EnsureDatabaseAndTokenCacheTableExist(string serverConnectionString, string databaseName, string tableName, ITestOutputHelper output) + { + using (SqlConnection connection = new SqlConnection(serverConnectionString)) + { + connection.Open(); + + // Check if database exists and create it if it does not + if (DatabaseExists(connection, databaseName)) + { + output.WriteLine("Database already exists."); + } + else + { + CreateDatabase(connection, databaseName); + output.WriteLine("Database created."); + } + + // Switch to the database + connection.ChangeDatabase(databaseName); + + // Check if table exists and create it if it does not + if (TableExists(connection, tableName)) + { + output.WriteLine("Table already exists."); + } + else + { + CreateTokenCacheTable(connection, tableName); + output.WriteLine("Table created."); + } + } + } + + private static bool DatabaseExists(SqlConnection connection, string databaseName) + { + string checkDatabaseQuery = $"SELECT database_id FROM sys.databases WHERE name = '{databaseName}'"; + using SqlCommand command = new SqlCommand(checkDatabaseQuery, connection); + object result = command.ExecuteScalar(); + return result != null; + } + + private static void CreateDatabase(SqlConnection connection, string databaseName) + { + string createDatabaseQuery = $"CREATE DATABASE {databaseName}"; + using SqlCommand createCommand = new SqlCommand(createDatabaseQuery, connection); + createCommand.ExecuteNonQuery(); + } + + private static bool TableExists(SqlConnection connection, string tableName) + { + string checkTableQuery = $"SELECT object_id('{tableName}', 'U')"; + using SqlCommand command = new SqlCommand(checkTableQuery, connection); + object result = command.ExecuteScalar(); + return result.GetType() != typeof(DBNull); + } + + private static void CreateTokenCacheTable(SqlConnection connection, string tableName) + { + string createCacheTableQuery = $@" + CREATE TABLE [dbo].[{tableName}] ( + [Id] NVARCHAR(449) NOT NULL PRIMARY KEY, + [Value] VARBINARY(MAX) NOT NULL, + [ExpiresAtTime] DATETIMEOFFSET NOT NULL, + [SlidingExpirationInSeconds] BIGINT NULL, + [AbsoluteExpiration] DATETIMEOFFSET NULL + )"; + using SqlCommand createCommand = new SqlCommand(createCacheTableQuery, connection); + createCommand.ExecuteNonQuery(); + } } /// diff --git a/UiTests/GraphUserTokenCache/GraphUserTokenCache.csproj b/UiTests/GraphUserTokenCache/GraphUserTokenCache.csproj new file mode 100644 index 00000000..a1f3bdbc --- /dev/null +++ b/UiTests/GraphUserTokenCache/GraphUserTokenCache.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + false + enable + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/UiTests/GraphUserTokenCache/GraphUserTokenCacheTest.cs b/UiTests/GraphUserTokenCache/GraphUserTokenCacheTest.cs new file mode 100644 index 00000000..a1fe2dfd --- /dev/null +++ b/UiTests/GraphUserTokenCache/GraphUserTokenCacheTest.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Common; +using Microsoft.Identity.Lab.Api; +using Microsoft.Playwright; +using System.Text; +using Xunit; +using Xunit.Abstractions; +using Process = System.Diagnostics.Process; +using TC = Common.TestConstants; + + +namespace GraphUserTokenCacheTest +{ + public class GraphUserTokenCacheTest + { + private const uint ClientPort = 44321; + private const uint NumProcessRetries = 3; + private const string SampleSlnFileName = "2-2-TokenCache.sln"; + private const string SignOutPageUriPath = @"/MicrosoftIdentity/Account/SignedOut"; + private const string SqlDbName = "MY_TOKEN_CACHE_DATABASE"; + private const string SqlServerConnectionString = "Server=(localdb)\\mssqllocaldb;Integrated Security=true"; + private const string SqlTableName = "TokenCache"; + private const string TraceFileClassName = "GraphUserTokenCacheTest"; + private readonly LocatorAssertionsToBeVisibleOptions _assertVisibleOptions = new() { Timeout = 25000 }; + private readonly ITestOutputHelper _output; + private readonly string _sampleAppPath = "2-WebApp-graph-user" + Path.DirectorySeparatorChar + "2-2-TokenCache" + Path.DirectorySeparatorChar.ToString(); + private readonly string _testAppsettingsPath = "UiTests" + Path.DirectorySeparatorChar + "GraphUserTokenCache" + Path.DirectorySeparatorChar.ToString() + TC.AppSetttingsDotJson; + private readonly string _testAssemblyLocation = typeof(GraphUserTokenCacheTest).Assembly.Location; + + public GraphUserTokenCacheTest(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_LoginLogoutAsync() + { + // Setup web app and api environmental variables. + var clientEnvVars = new Dictionary + { + {"ASPNETCORE_ENVIRONMENT", "Development"}, + {TC.KestrelEndpointEnvVar, TC.HttpsStarColon + ClientPort} + }; + + Dictionary? processes = null; + + // Arrange Playwright setup, to see the browser UI set Headless = false. + const string TraceFileName = TraceFileClassName + "_LoginLogout"; + using IPlaywright playwright = await Playwright.CreateAsync(); + IBrowser browser = await playwright.Chromium.LaunchAsync(new() { Headless = true }); + IBrowserContext context = await browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true }); + await context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true, Sources = true }); + IPage page = await context.NewPageAsync(); + string uriWithPort = TC.LocalhostUrl + ClientPort; + + try + { + // Make sure database and table for cache exist, if not they will be created. + UiTestHelpers.EnsureDatabaseAndTokenCacheTableExist(SqlServerConnectionString, SqlDbName, SqlTableName, _output); + + // Build the sample app with correct appsettings file. + UiTestHelpers.BuildSampleUsingTestAppsettings(_testAssemblyLocation, _sampleAppPath, _testAppsettingsPath, SampleSlnFileName); + + // Start the web app and api processes. + // The delay before starting client prevents transient devbox issue where the client fails to load the first time after rebuilding + var clientProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _sampleAppPath, TC.s_oidcWebAppExe, clientEnvVars); + + bool areProcessesRunning = UiTestHelpers.StartAndVerifyProcessesAreRunning([clientProcessOptions], out processes, NumProcessRetries); + + if (!areProcessesRunning) + { + _output.WriteLine($"Process not started after {NumProcessRetries} attempts."); + StringBuilder runningProcesses = new(); + foreach (var process in processes) + { +#pragma warning disable CA1305 // Specify IFormatProvider + runningProcesses.AppendLine($"Is {process.Key} running: {UiTestHelpers.ProcessIsAlive(process.Value)}"); +#pragma warning restore CA1305 // Specify IFormatProvider + } + Assert.Fail(TC.WebAppCrashedString + " " + runningProcesses.ToString()); + } + + LabResponse labResponse = await LabUserHelper.GetSpecificUserAsync(TC.MsidLab4User); + + // Initial sign in + _output.WriteLine("Starting web app sign-in flow."); + string email = labResponse.User.Upn; + await UiTestHelpers.NavigateToWebApp(uriWithPort, page); + await UiTestHelpers.FirstLogin_MicrosoftIdFlow_ValidEmailPassword(page, email, labResponse.User.GetOrFetchPassword()); + await Assertions.Expect(page.GetByText("Integrating Azure AD V2")).ToBeVisibleAsync(_assertVisibleOptions); + await Assertions.Expect(page.GetByText(email)).ToBeVisibleAsync(_assertVisibleOptions); + _output.WriteLine("Web app sign-in flow successful."); + + // Sign out + _output.WriteLine("Starting web app sign-out flow."); + await page.GetByRole(AriaRole.Link, new() { Name = "Sign out" }).ClickAsync(); + await UiTestHelpers.PerformSignOut_MicrosoftIdFlow(page, email, TC.LocalhostUrl + ClientPort + SignOutPageUriPath, _output); + _output.WriteLine("Web app sign out successful."); + } + catch (Exception ex) + { + // Adding guid in case of multiple test runs. This will allow screenshots to be matched to their appropriate test runs. + var guid = Guid.NewGuid().ToString(); + try + { + if (page != null) + { + await page.ScreenshotAsync(new PageScreenshotOptions() { Path = $"ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_TodoAppFunctionsCorrectlyScreenshotFail{guid}.png", FullPage = true }); + } + } + catch + { + _output.WriteLine("No Screenshot."); + } + + string runningProcesses = UiTestHelpers.GetRunningProcessAsString(processes); + Assert.Fail($"the UI automation failed: {ex} output: {ex.Message}.\n{runningProcesses}\nTest run: {guid}"); + } + finally + { + // Make sure all processes and their children are stopped. + UiTestHelpers.EndProcesses(processes); + + // Stop tracing and export it into a zip archive. + string path = UiTestHelpers.GetTracePath(_testAssemblyLocation, TraceFileName); + await context.Tracing.StopAsync(new() { Path = path }); + _output.WriteLine($"Trace data for {TraceFileName} recorded to {path}."); + + // Close the browser and stop Playwright. + await browser.CloseAsync(); + playwright.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/UiTests/GraphUserTokenCache/appsettings.json b/UiTests/GraphUserTokenCache/appsettings.json new file mode 100644 index 00000000..4afa0c12 --- /dev/null +++ b/UiTests/GraphUserTokenCache/appsettings.json @@ -0,0 +1,32 @@ +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "Domain": "msidlab4.onmicrosoft.com", + "TenantId": "f645ad92-e38d-4d1a-b510-d1b09a74a8ca", + "ClientId": "9a192b78-6580-4f8a-aace-f36ffea4f7be", + "CallbackPath": "/signin-oidc", + "SignedOutCallbackPath": "/signout-callback-oidc", + "ClientCertificates": [ + { + "SourceType": "KeyVault", + "KeyVaultUrl": "https://webappsapistests.vault.azure.net", + "KeyVaultCertificateName": "Self-Signed-5-5-22" + } + ] + }, + "DownstreamApi": { + "BaseUrl": "https://graph.microsoft.com/v1.0", + "Scopes": "user.read" + }, + "ConnectionStrings": { + "TokenCacheDbConnStr": "Data Source=(LocalDb)\\MSSQLLocalDB;Database=MY_TOKEN_CACHE_DATABASE;Trusted_Connection=True;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/UiTests/UiTests.sln b/UiTests/UiTests.sln index 6ebc613e..af7897cc 100644 --- a/UiTests/UiTests.sln +++ b/UiTests/UiTests.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "B2CUiTest", "B2CUiTest\B2CUiTest.csproj", "{BF7D9973-9B92-4BED-ADE2-09087DDA9C85}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphUserTokenCache", "GraphUserTokenCache\GraphUserTokenCache.csproj", "{B083D288-AB6E-4849-9AC2-E1DA1F727483}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +34,10 @@ Global {BF7D9973-9B92-4BED-ADE2-09087DDA9C85}.Debug|Any CPU.Build.0 = Debug|Any CPU {BF7D9973-9B92-4BED-ADE2-09087DDA9C85}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF7D9973-9B92-4BED-ADE2-09087DDA9C85}.Release|Any CPU.Build.0 = Release|Any CPU + {B083D288-AB6E-4849-9AC2-E1DA1F727483}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B083D288-AB6E-4849-9AC2-E1DA1F727483}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B083D288-AB6E-4849-9AC2-E1DA1F727483}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B083D288-AB6E-4849-9AC2-E1DA1F727483}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE