diff --git a/build/pipeline-releasebuild.yaml b/build/pipeline-releasebuild.yaml index c6a1ff6c9..e79fd73ab 100644 --- a/build/pipeline-releasebuild.yaml +++ b/build/pipeline-releasebuild.yaml @@ -38,6 +38,13 @@ steps: BuildPlatform: '$(BuildPlatform)' BuildConfiguration: '$(BuildConfiguration)' MsIdentityWebSemVer: $(MsIdentityWebSemVer) + +# Run all tests +- template: template-run-unit-tests.yaml + parameters: + BuildPlatform: '$(BuildPlatform)' + BuildConfiguration: '$(BuildConfiguration)' + MsIdentityWebSemVer: $(MsIdentityWebSemVer) # Run Post-build code analysis (e.g. Roslyn) - template: template-postbuild-code-analysis.yaml diff --git a/build/template-restore-build-MSIdentityWeb.yaml b/build/template-restore-build-MSIdentityWeb.yaml index 4438c4d3d..94292336a 100644 --- a/build/template-restore-build-MSIdentityWeb.yaml +++ b/build/template-restore-build-MSIdentityWeb.yaml @@ -10,13 +10,14 @@ steps: - script: | dotnet workload restore tests\DevApps\blazorwasm-b2c\blazorwasm2-b2c.csproj displayName: 'Install wasm-tools' - + - task: DotNetCoreCLI@2 - displayName: 'Build solution Microsoft.Identity.Web.sln and run tests' + displayName: 'Build solution Microsoft.Identity.Web.sln' inputs: - command: test + command: 'custom' + custom: 'build' projects: 'Microsoft.Identity.Web.sln' - arguments: '--collect "Code coverage" --settings "build\CodeCoverage.runsettings" --configuration ${{ parameters.BuildConfiguration }} -p:RunCodeAnalysis=true -p:MsIdentityWebSemVer=${{ parameters.MsIdentityWebSemVer }} -p:SourceLinkCreate=true' + arguments: '-p:configuration=${{ parameters.BuildConfiguration }} -p:RunCodeAnalysis=true -p:MsIdentityWebSemVer=${{ parameters.MsIdentityWebSemVer }} -p:SourceLinkCreate=true' # This task is needed so that the 1CS Rolsyn analyzers task works. # The previous task does the restore diff --git a/build/template-run-unit-tests.yaml b/build/template-run-unit-tests.yaml index 02677911f..b60a59bcf 100644 --- a/build/template-run-unit-tests.yaml +++ b/build/template-run-unit-tests.yaml @@ -3,18 +3,69 @@ steps: -- task: DotNetCoreCLI@2 +- task: VSTest@2 + displayName: 'Run unit tests' inputs: - command: 'test' - projects: | - Tests/**/*.csproj - !Tests/DevApps/**/*.csproj - arguments: '--collect "Code coverage" --settings "build\CodeCoverage.runsettings"' - continueOnError: 'true' + testSelector: 'testAssemblies' + testAssemblyVer2: 'tests\Microsoft.Identity.Web.Test\bin\**\Microsoft.Identity.Web.Test.dll' + searchFolder: '$(System.DefaultWorkingDirectory)' + runInParallel: true + codeCoverageEnabled: true + failOnMinTestsNotRun: true + minimumExpectedTests: '1' + runSettingsFile: 'build\CodeCoverage.runsettings' + +- task: VSTest@2 + displayName: 'Run integration tests' + inputs: + testSelector: 'testAssemblies' + testAssemblyVer2: 'tests\Microsoft.Identity.Web.Test.Integration\bin\**\Microsoft.Identity.Web.Test.Integration.dll' + searchFolder: '$(System.DefaultWorkingDirectory)' + rerunFailedTests: true + rerunMaxAttempts: '3' + runInParallel: false + codeCoverageEnabled: true + failOnMinTestsNotRun: false + minimumExpectedTests: '1' + runSettingsFile: 'build\CodeCoverage.runsettings' + +- task: VSTest@2 + displayName: 'Run E2E tests' + inputs: + testSelector: 'testAssemblies' + testAssemblyVer2: | + tests\E2E Tests\WebAppUiTests\bin\**\WebAppUiTests.dll + tests\E2E Tests\TokenAcquirerTests\bin\**\TokenAcquirerTests.dll + tests\E2E Tests\NET 7 tests\IntegrationTests\bin\**\IntegrationTests.dll + searchFolder: '$(System.DefaultWorkingDirectory)' + rerunFailedTests: true + rerunMaxAttempts: '3' + runInParallel: false + codeCoverageEnabled: true + failOnMinTestsNotRun: false + minimumExpectedTests: '1' + runSettingsFile: 'build\CodeCoverage.runsettings' - task: PublishBuildArtifacts@1 displayName: 'Publish traces after test' inputs: PathtoPublish: '$(Build.SourcesDirectory)/tests/E2E Tests/PlaywrightTraces/' ArtifactName: 'traces-after-tests-$(Build.BuildNumber)' + condition: failed() + +# Copy all packages out to staging +- task: CopyFiles@2 + displayName: 'Copy screenshots to staging directory' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/tests/E2E Tests/' + Contents: '**/*screenshotFail.png' + TargetFolder: '$(Build.ArtifactStagingDirectory)\screenshots' + flattenFolders: true + condition: failed() + +- task: PublishBuildArtifacts@1 + displayName: 'Publish Screenshot after test' + inputs: + PathtoPublish: '$(Build.ArtifactStagingDirectory)\screenshots' + ArtifactName: 'ScreenshotFail' condition: failed() \ No newline at end of file diff --git a/tests/DevApps/WebAppCallsMicrosoftGraph/appsettings.json b/tests/DevApps/WebAppCallsMicrosoftGraph/appsettings.json index 319ab4f0c..e79e1c74b 100644 --- a/tests/DevApps/WebAppCallsMicrosoftGraph/appsettings.json +++ b/tests/DevApps/WebAppCallsMicrosoftGraph/appsettings.json @@ -22,10 +22,10 @@ "KeyVaultUrl": "https://webappsapistests.vault.azure.net", "KeyVaultCertificateName": "Self-Signed-5-5-22" } - // { - // "SourceType": "ClientSecret", - // "ClientSecret": "" - // } + //{ + // "SourceType": "ClientSecret", + // "ClientSecret": "" + //} ] // Id.Web v1.0 way of declaring client credentials diff --git a/tests/DevApps/ciam/myWebApi/Properties/launchSettings.json b/tests/DevApps/ciam/myWebApi/Properties/launchSettings.json index 111db5a5b..2946688bc 100644 --- a/tests/DevApps/ciam/myWebApi/Properties/launchSettings.json +++ b/tests/DevApps/ciam/myWebApi/Properties/launchSettings.json @@ -10,14 +10,14 @@ }, "profiles": { "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7082;http://localhost:5299", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7082;http://localhost:5299;", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } }, "IIS Express": { "commandName": "IISExpress", diff --git a/tests/DevApps/ciam/myWebApi/appsettings.json b/tests/DevApps/ciam/myWebApi/appsettings.json index 7fed354d7..dda0345bf 100644 --- a/tests/DevApps/ciam/myWebApi/appsettings.json +++ b/tests/DevApps/ciam/myWebApi/appsettings.json @@ -1,8 +1,8 @@ { "AzureAd": { - "ClientId": "63e6d091-0e6d-4c8b-be67-d405e02ae3d8", + "ClientId": "634de702-3173-4a71-b336-a4fab786a479", "Scopes": "access_as_user", - "Authority": "https://TrialTenantJmprieur.ciamlogin.com/" + "Authority": "https://MSIDLABCIAM6.ciamlogin.com" }, "Logging": { "LogLevel": { diff --git a/tests/DevApps/ciam/myWebApi/myWebApi.csproj b/tests/DevApps/ciam/myWebApi/myWebApi.csproj index e62b170cc..489101475 100644 --- a/tests/DevApps/ciam/myWebApi/myWebApi.csproj +++ b/tests/DevApps/ciam/myWebApi/myWebApi.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable aspnet-myWebApi-17be892d-3bb3-4282-92e2-301b02c86ed6 diff --git a/tests/DevApps/ciam/myWebApp/Program.cs b/tests/DevApps/ciam/myWebApp/Program.cs index 9912bf33d..6efae26a6 100644 --- a/tests/DevApps/ciam/myWebApp/Program.cs +++ b/tests/DevApps/ciam/myWebApp/Program.cs @@ -2,15 +2,13 @@ // Licensed under the MIT License. using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.Identity.Web; using Microsoft.Identity.Web.UI; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -var initialScopes = builder.Configuration["DownstreamApi:Scopes"]?.Split(' '); +var initialScopes = builder.Configuration.GetSection("DownstreamApi:Scopes").Get(); builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) diff --git a/tests/DevApps/ciam/myWebApp/appsettings.json b/tests/DevApps/ciam/myWebApp/appsettings.json index 44871c979..501c3db4e 100644 --- a/tests/DevApps/ciam/myWebApp/appsettings.json +++ b/tests/DevApps/ciam/myWebApp/appsettings.json @@ -1,16 +1,30 @@ { - "AzureAd": { - "ClientId": "4c8068d9-c00f-4908-afd7-44e4b263f66c", - "ClientSecret": "See user secrets", - "ClientCertificates": [], - "CallbackPath": "/signin-oidc", - "Authority": "https://TrialTenantJmprieur.ciamlogin.com/", - "Prompt": "login" - }, - "DownstreamApi": { - "BaseUrl": "https://localhost:7082/weatherforecast", - "Scopes": [ "api://63e6d091-0e6d-4c8b-be67-d405e02ae3d8/.default" ] - }, + "AzureAd": { + "ClientId": "b244c86f-ed88-45bf-abda-6b37aa482c79", + "ClientCertificates": [], + "CallbackPath": "/signin-oidc", + "Authority": "https://MSIDLABCIAM6.ciamlogin.com", + "Prompt": "login", + "ClientCredentials": [ + //{ + // "SourceType": "SignedAssertionFromManagedIdentity", + // "ManagedIdentityClientId": "" + //}, + { + "SourceType": "KeyVault", + "KeyVaultUrl": "https://webappsapistests.vault.azure.net", + "KeyVaultCertificateName": "Self-Signed-5-5-22" + } + //{ + // "SourceType": "ClientSecret", + // "ClientSecret": "" + //} + ] + }, + "DownstreamApi": { + "BaseUrl": "http://localhost:5299/WeatherForecast", + "Scopes": [ "api://634de702-3173-4a71-b336-a4fab786a479/.default" ] + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/DevApps/ciam/myWebApp/myWebApp.csproj b/tests/DevApps/ciam/myWebApp/myWebApp.csproj index 663f0129e..b14eb92a3 100644 --- a/tests/DevApps/ciam/myWebApp/myWebApp.csproj +++ b/tests/DevApps/ciam/myWebApp/myWebApp.csproj @@ -5,6 +5,7 @@ enable enable aspnet-myWebApp-23b8728b-775f-4796-8b63-e85cbaccc27c + true diff --git a/tests/E2E Tests/TokenAcquirerTests/TokenAcquirer.cs b/tests/E2E Tests/TokenAcquirerTests/TokenAcquirer.cs index e10c2d805..361afed5f 100644 --- a/tests/E2E Tests/TokenAcquirerTests/TokenAcquirer.cs +++ b/tests/E2E Tests/TokenAcquirerTests/TokenAcquirer.cs @@ -33,6 +33,13 @@ public class TokenAcquirer "Self-Signed-5-5-22") }; + private static readonly CredentialDescription[] s_ciamClientCredentials = new[] + { + CertificateDescription.FromKeyVault( + "https://buildautomation.vault.azure.net", + "AzureADIdentityDivisionTestAgentCert") + }; + public TokenAcquirer() { TokenAcquirerFactory.ResetDefaultInstance(); // Test only @@ -169,6 +176,23 @@ public async Task AcquireToken_WithMicrosoftIdentityApplicationOptions_ClientCre await CreateGraphClientAndAssert(tokenAcquirerFactory, services); } + [IgnoreOnAzureDevopsFact(Skip = "https://github.com/AzureAD/microsoft-identity-web/issues/2732")] + //[Fact] + public async Task AcquireToken_WithMicrosoftIdentityApplicationOptions_ClientCredentialsCiamAsync() + { + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + IServiceCollection services = tokenAcquirerFactory.Services; + + services.Configure(s_optionName, option => + { + option.Authority = "https://MSIDLABCIAM6.ciamlogin.com"; + option.ClientId = "b244c86f-ed88-45bf-abda-6b37aa482c79"; + option.ClientCredentials = s_clientCredentials; + }); + + await CreateGraphClientAndAssert(tokenAcquirerFactory, services); + } + [IgnoreOnAzureDevopsFact] // [Fact] public async Task AcquireToken_WithFactoryAndMicrosoftIdentityApplicationOptions_ClientCredentialsAsync() @@ -374,6 +398,7 @@ private static async Task CreateGraphClientAndAssert(TokenAcquirerFactory tokenA Assert.True(users!=null && users.Value!=null && users.Value.Count >0); */ + // Alternatively to calling Microsoft Graph, you can get a token acquirer service // and get a token, and use it in an SDK. ITokenAcquirer tokenAcquirer = tokenAcquirerFactory.GetTokenAcquirer(s_optionName); @@ -382,7 +407,7 @@ private static async Task CreateGraphClientAndAssert(TokenAcquirerFactory tokenA } } - public class AcquireTokenManagedIdentity + public class AcquireTokenManagedIdentity { [OnlyOnAzureDevopsFact] //[Fact] @@ -404,7 +429,7 @@ public async Task AcquireTokenWithManagedIdentity_UserAssigned() Assert.False(string.IsNullOrEmpty(result)); } - private static AuthorizationHeaderProviderOptions GetAuthHeaderOptions_ManagedId(string baseUrl, string? userAssignedClientId=null) + private static AuthorizationHeaderProviderOptions GetAuthHeaderOptions_ManagedId(string baseUrl, string? userAssignedClientId = null) { ManagedIdentityOptions managedIdentityOptions = new() { diff --git a/tests/E2E Tests/WebAppUiTests/TestingWebAppLocally.cs b/tests/E2E Tests/WebAppUiTests/TestingWebAppLocally.cs index 5afbf8715..0677142da 100644 --- a/tests/E2E Tests/WebAppUiTests/TestingWebAppLocally.cs +++ b/tests/E2E Tests/WebAppUiTests/TestingWebAppLocally.cs @@ -13,6 +13,7 @@ using Xunit; using Xunit.Abstractions; using System.Threading; +using System.Net; namespace WebAppUiTests; @@ -24,6 +25,7 @@ public class TestingWebAppLocally : IClassFixture(); + + await ExecuteWebAppCallsGraphFlow(labResponse.User.Upn, labResponse.User.GetOrFetchPassword(), clientEnvVars, TraceFileClassName).ConfigureAwait(false); + } + + [Theory] + [InlineData("https://MSIDLABCIAM6.ciamlogin.com")] // CIAM authority + [InlineData("https://login.msidlabsciam.com/fe362aec-5d43-45d1-b730-9755e60dc3b9/v2.0/")] // CIAM CUD Authority + [SupportedOSPlatform("windows")] + public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailWithCiamPassword(string authority) + { + var clientEnvVars = new Dictionary + { + {"AzureAd__ClientId", "b244c86f-ed88-45bf-abda-6b37aa482c79"}, + {"AzureAd__Authority", authority}, + {"AzureAd__TenantId", ""}, + {"AzureAd__Domain", ""}, + {"AzureAd__Instance", "" } + }; + + await ExecuteWebAppCallsGraphFlow("idlab@msidlabciam6.onmicrosoft.com", LabUserHelper.FetchUserPassword("msidlabciam6"), clientEnvVars, TraceFileClassNameCiam).ConfigureAwait(false); + } + + private async Task ExecuteWebAppCallsGraphFlow(string upn, string credential, Dictionary? clientEnvVars, string traceFileClassName) { // Arrange Process? process = null; - const string TraceFileName = TraceFileClassName + "_ValidEmailPassword"; + string TraceFileName = traceFileClassName + "_ValidEmailPassword"; using IPlaywright playwright = await Playwright.CreateAsync(); IBrowser browser = await playwright.Chromium.LaunchAsync(new() { Headless = true }); IBrowserContext context = await browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true }); @@ -49,9 +78,10 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPassword() try { - process = UiTestHelpers.StartProcessLocally(_uiTestAssemblyLocation, _devAppPath, _devAppExecutable); + process = UiTestHelpers.StartProcessLocally(_uiTestAssemblyLocation, _devAppPath, _devAppExecutable, clientEnvVars); - if (!UiTestHelpers.ProcessIsAlive(process)) { Assert.Fail(TC.WebAppCrashedString); } + if (!UiTestHelpers.ProcessIsAlive(process)) + { Assert.Fail(TC.WebAppCrashedString); } IPage page = await browser.NewPageAsync(); @@ -68,16 +98,15 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPassword() { await Task.Delay(1000); InitialConnectionRetryCount--; - if (InitialConnectionRetryCount == 0) { throw ex; } + if (InitialConnectionRetryCount == 0) + { throw ex; } } } - LabResponse labResponse = await LabUserHelper.GetDefaultUserAsync().ConfigureAwait(false); - // Act Trace.WriteLine("Starting Playwright automation: web app sign-in & call Graph."); - string email = labResponse.User.Upn; - await UiTestHelpers.FirstLogin_MicrosoftIdFlow_ValidEmailPassword(page, email, labResponse.User.GetOrFetchPassword(), _output); + string email = upn; + await UiTestHelpers.FirstLogin_MicrosoftIdFlow_ValidEmailPassword(page, email, credential, _output); // Assert await Assertions.Expect(page.GetByText("Welcome")).ToBeVisibleAsync(_assertVisibleOptions); @@ -91,7 +120,8 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPassword() { // Cleanup the web app process and any child processes Queue processes = new(); - if (process != null) { processes.Enqueue(process); } + if (process != null) + { processes.Enqueue(process); } UiTestHelpers.KillProcessTrees(processes); // Cleanup Playwright diff --git a/tests/E2E Tests/WebAppUiTests/UiTestHelpers.cs b/tests/E2E Tests/WebAppUiTests/UiTestHelpers.cs index e6c7f3136..ece31af2a 100644 --- a/tests/E2E Tests/WebAppUiTests/UiTestHelpers.cs +++ b/tests/E2E Tests/WebAppUiTests/UiTestHelpers.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Management; using System.Runtime.Versioning; +using System.Threading; using System.Threading.Tasks; using Azure.Core; using Azure.Security.KeyVault.Secrets; @@ -309,7 +310,63 @@ internal static async Task GetValueFromKeyvaultWitDefaultCreds(Uri keyva SecretClient client = new(keyvaultUri, creds); return (await client.GetSecretAsync(keyvaultSecretName)).Value.Value; } + + internal static bool StartAndVerifyProcessesAreRunning(List processDataEntries, out Dictionary processes) + { + processes = new Dictionary(); + + //Start Processes + foreach (ProcessStartOptions processDataEntry in processDataEntries) + { + var process = UiTestHelpers.StartProcessLocally( + processDataEntry.TestAssemblyLocation, + processDataEntry.AppLocation, + processDataEntry.ExecutableName, + processDataEntry.EnvironmentVariables); + + processes.Add(processDataEntry.ExecutableName, process); + Thread.Sleep(5000); + } + + //Verify that processes are running + for (int i = 0; i < 2; i++) + { + if (!UiTestHelpers.ProcessesAreAlive(processes.Values.ToList())) + { + RestartProcesses(processes, processDataEntries); + } + } + + if (!UiTestHelpers.ProcessesAreAlive(processes.Values.ToList())) + { + return false; + } + + return true; + } + + static void RestartProcesses(Dictionary processes, List processDataEntries) + { + //attempt to restart failed processes + foreach (KeyValuePair processEntry in processes) + { + if (!ProcessIsAlive(processEntry.Value)) + { + var processDataEntry = processDataEntries.Where(x => x.ExecutableName == processEntry.Key).Single(); + var process = StartProcessLocally( + processDataEntry.TestAssemblyLocation, + processDataEntry.AppLocation, + processDataEntry.ExecutableName, + processDataEntry.EnvironmentVariables); + Thread.Sleep(5000); + + //Update process in collection + processes[processEntry.Key] = process; + } + } + } } + /// /// Fixture class that installs Playwright browser once per xunit test class that implements it /// @@ -323,5 +380,28 @@ public void Dispose() { } } + + public class ProcessStartOptions + { + public string TestAssemblyLocation { get; } + + public string AppLocation { get; } + + public string ExecutableName { get; } + + public Dictionary? EnvironmentVariables { get; } + + public ProcessStartOptions( + string testAssemblyLocation, + string appLocation, + string executableName, + Dictionary? environmentVariables = null) + { + TestAssemblyLocation = testAssemblyLocation; + AppLocation = appLocation; + ExecutableName = executableName; + EnvironmentVariables = environmentVariables; + } + } } diff --git a/tests/E2E Tests/WebAppUiTests/WebAppCallsApiCallsGraphLocally.cs b/tests/E2E Tests/WebAppUiTests/WebAppCallsApiCallsGraphLocally.cs index eb0bde9ed..3169b5dff 100644 --- a/tests/E2E Tests/WebAppUiTests/WebAppCallsApiCallsGraphLocally.cs +++ b/tests/E2E Tests/WebAppUiTests/WebAppCallsApiCallsGraphLocally.cs @@ -13,6 +13,8 @@ using Xunit; using Xunit.Abstractions; using Process = System.Diagnostics.Process; +using System.Linq; +using System.Text; namespace WebAppUiTests #if !FROM_GITHUB_ACTION @@ -25,9 +27,12 @@ public class WebAppCallsApiCallsGraphLocally : IClassFixture processes = null; // Arrange Playwright setup, to see the browser UI set Headless = false. const string TraceFileName = TraceFileClassName + "_TodoAppFunctionsCorrectly"; @@ -69,40 +72,32 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds 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 = null; try { // 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. - grpcProcess = UiTestHelpers.StartProcessLocally(_testAssemblyLocation, _devAppPath + _grpcPath, _grpcExecutable, grpcEnvVars); - serviceProcess = UiTestHelpers.StartProcessLocally(_testAssemblyLocation, _devAppPath + TC.s_todoListServicePath, TC.s_todoListServiceExe, serviceEnvVars); - await Task.Delay(3000); - clientProcess = UiTestHelpers.StartProcessLocally(_testAssemblyLocation, _devAppPath + TC.s_todoListClientPath, TC.s_todoListClientExe, clientEnvVars); + var grpcProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _devAppPath + _grpcPath, _grpcExecutable, grpcEnvVars); + var serviceProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _devAppPath + TC.s_todoListServicePath, TC.s_todoListServiceExe, serviceEnvVars); + var clientProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _devAppPath + TC.s_todoListClientPath, TC.s_todoListClientExe, clientEnvVars); - if ( !UiTestHelpers.ProcessesAreAlive(new List() { clientProcess, serviceProcess, grpcProcess })) - { - Assert.Fail(TC.WebAppCrashedString); - } + bool areProcessesRunning = UiTestHelpers.StartAndVerifyProcessesAreRunning(new List { grpcProcessOptions, serviceProcessOptions, clientProcessOptions }, out processes); - // Navigate to web app - IPage page = await context.NewPageAsync(); - - // The retry logic ensures the web app has time to start up to establish a connection. - uint InitialConnectionRetryCount = 5; - while (InitialConnectionRetryCount > 0) + if (!areProcessesRunning) { - try + _output.WriteLine("Process not started after 3 attempts."); + StringBuilder runningProcesses = new StringBuilder(); + foreach (var process in processes) { - await page.GotoAsync(TC.LocalhostUrl + TodoListClientPort); - break; - } - catch (PlaywrightException ex) - { - await Task.Delay(1000); - InitialConnectionRetryCount--; - if (InitialConnectionRetryCount == 0) { throw ex; } +#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()); } + + page = await NavigateToWebApp(context, TodoListClientPort).ConfigureAwait(false); LabResponse labResponse = await LabUserHelper.GetDefaultUserAsync().ConfigureAwait(false); // Initial sign in @@ -151,16 +146,136 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds } catch (Exception ex) { - Assert.Fail($"the UI automation failed: {ex} output: {ex.Message}."); + //Adding guid incase of multiple test runs. This will allow screenshots to be matched to their appropriet test runs. + var guid = Guid.NewGuid().ToString(); + try + { + await page.ScreenshotAsync(new PageScreenshotOptions() { Path = $"ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_TodoAppFunctionsCorrectlyScreenshotFail{guid}.png", FullPage = true }); + } + catch + { + _output.WriteLine("No Screenshot."); + } + + string runningProcesses = GetRunningProcessAsString(processes); + + Assert.Fail($"the UI automation failed: {ex} output: {ex.Message}.\n{runningProcesses}\nTest run: {guid}"); + } + finally + { + // Add the following to make sure all processes and their children are stopped. + 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(); + } + } + + [Theory] + [InlineData("https://MSIDLABCIAM6.ciamlogin.com")] // CIAM authority + [InlineData("https://login.msidlabsciam.com/fe362aec-5d43-45d1-b730-9755e60dc3b9/v2.0/")] // CIAM CUD Authority + [SupportedOSPlatform("windows")] + public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_CallsDownStreamApiWithCiam(string authority) + { + // Setup web app and api environmental variables. + var serviceEnvVars = new Dictionary + { + {"ASPNETCORE_ENVIRONMENT", "Development"}, + {"AzureAd__ClientId", "634de702-3173-4a71-b336-a4fab786a479"}, + {"AzureAd__Authority", authority}, + {TC.KestrelEndpointEnvVar, TC.HttpStarColon + WebApiCiamPort} + }; + var clientEnvVars = new Dictionary + { + {"ASPNETCORE_ENVIRONMENT", "Development"}, + {"AzureAd__ClientId", "b244c86f-ed88-45bf-abda-6b37aa482c79"}, + {"AzureAd__Authority", authority}, + {"DownstreamApi__Scopes__0", "api://634de702-3173-4a71-b336-a4fab786a479/.default"}, + {TC.KestrelEndpointEnvVar, TC.HttpsStarColon + WebAppCiamPort} + }; + + Dictionary processes = null; + + // Arrange Playwright setup, to see the browser UI set Headless = false. + const string TraceFileName = TraceFileClassName + "_CiamWebApp_WebApiFunctionsCorrectly"; + 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 = null; + + try + { + // 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 serviceProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _devAppPathCiam + TC.s_myWebApiPath, TC.s_myWebApiExe, serviceEnvVars); + var clientProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _devAppPathCiam + TC.s_myWebAppPath, TC.s_myWebAppExe, clientEnvVars); + bool areProcessesRunning = UiTestHelpers.StartAndVerifyProcessesAreRunning(new List { serviceProcessOptions, clientProcessOptions }, out processes); + + if (!areProcessesRunning) + { + _output.WriteLine("Process not started after 3 attempts."); + StringBuilder runningProcesses = new StringBuilder(); + 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()); + } + + page = await NavigateToWebApp(context, WebAppCiamPort); + + // Initial sign in + _output.WriteLine("Starting web app sign-in flow."); + string email = "idlab@msidlabciam6.onmicrosoft.com"; + await UiTestHelpers.FirstLogin_MicrosoftIdFlow_ValidEmailPassword(page, email, LabUserHelper.FetchUserPassword("msidlabciam6"), _output); + await Assertions.Expect(page.GetByText("Welcome")).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 + WebAppCiamPort + SignOutPageUriPath, _output); + _output.WriteLine("Web app sign out successful."); + + // Sign in again using Todo List button + _output.WriteLine("Starting web app sign-in flow using sign in button after sign out."); + await page.GetByRole(AriaRole.Link, new() { Name = "Sign in" }).ClickAsync(); + await UiTestHelpers.FirstLogin_MicrosoftIdFlow_ValidEmailPassword(page, email, LabUserHelper.FetchUserPassword("msidlabciam6"), _output); + await Assertions.Expect(page.GetByText("Welcome")).ToBeVisibleAsync(_assertVisibleOptions); + await Assertions.Expect(page.GetByText(email)).ToBeVisibleAsync(_assertVisibleOptions); + _output.WriteLine("Web app sign-in flow successful using Sign in button after sign out."); + } + catch (Exception ex) + { + //Adding guid incase of multiple test runs. This will allow screenshots to be matched to their appropriet test runs. + var guid = Guid.NewGuid().ToString(); + try + { + await page.ScreenshotAsync(new PageScreenshotOptions() { Path = $"ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_CallsDownStreamApiWithCiamScreenshotFail{guid}.png", FullPage = true }); + } + catch + { + _output.WriteLine("No Screenshot."); + } + + string runningProcesses = GetRunningProcessAsString(processes); + + Assert.Fail($"the UI automation failed: {ex} output: {ex.Message}.\n{runningProcesses}\nTest run: {guid}"); } finally { // Add the following to make sure all processes and their children are stopped. - Queue processes = new(); - if (serviceProcess != null) { processes.Enqueue(serviceProcess); } - if (clientProcess != null) { processes.Enqueue(clientProcess); } - if (grpcProcess != null) { processes.Enqueue(grpcProcess); } - UiTestHelpers.KillProcessTrees(processes); + EndProcesses(processes); // Stop tracing and export it into a zip archive. string path = UiTestHelpers.GetTracePath(_testAssemblyLocation, TraceFileName); @@ -172,6 +287,60 @@ public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds playwright.Dispose(); } } + + private string GetRunningProcessAsString(Dictionary? processes) + { + StringBuilder runningProcesses = new StringBuilder(); + if (processes != null) + { + 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 + } + } + return runningProcesses.ToString(); + } + + private void EndProcesses(Dictionary? processes) + { + Queue processQueue = new(); + if (processes != null) + { + foreach (var process in processes) + { + processQueue.Enqueue(process.Value); + } + } + UiTestHelpers.KillProcessTrees(processQueue); + } + + private async Task NavigateToWebApp(IBrowserContext context, uint port) + { + // Navigate to web app + IPage page = await context.NewPageAsync(); + + // The retry logic ensures the web app has time to start up to establish a connection. + uint InitialConnectionRetryCount = 5; + while (InitialConnectionRetryCount > 0) + { + try + { + await page.GotoAsync(TC.LocalhostUrl + port); + break; + } + catch (PlaywrightException ex) + { + await Task.Delay(1000); + InitialConnectionRetryCount--; + if (InitialConnectionRetryCount == 0) + { throw ex; } + } + } + + return page; + } } } #endif // !FROM_GITHUB_ACTION diff --git a/tests/E2E Tests/WebAppUiTests/WebAppUiTests.csproj b/tests/E2E Tests/WebAppUiTests/WebAppUiTests.csproj index 0e1d8bc98..2d1a9055a 100644 --- a/tests/E2E Tests/WebAppUiTests/WebAppUiTests.csproj +++ b/tests/E2E Tests/WebAppUiTests/WebAppUiTests.csproj @@ -32,6 +32,10 @@ + + + + diff --git a/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs b/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs index cc5413875..8dcaa9fff 100644 --- a/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs +++ b/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs @@ -184,6 +184,10 @@ public static class TestConstants public static readonly string s_todoListClientPath = Path.DirectorySeparatorChar.ToString() + "Client"; public static readonly string s_todoListServiceExe = Path.DirectorySeparatorChar.ToString() + "TodoListService.exe"; public static readonly string s_todoListServicePath = Path.DirectorySeparatorChar.ToString() + "TodoListService"; + public static readonly string s_myWebAppExe = Path.DirectorySeparatorChar.ToString() + "myWebApp.exe"; + public static readonly string s_myWebAppPath = Path.DirectorySeparatorChar.ToString() + "myWebApp"; + public static readonly string s_myWebApiExe = Path.DirectorySeparatorChar.ToString() + "myWebApi.exe"; + public static readonly string s_myWebApiPath = Path.DirectorySeparatorChar.ToString() + "myWebApi"; // TokenAcqusitionOptions and ManagedIdentityOptions