From a651410d74c3aaa343fdd05f7871711cf75f589e Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Tue, 16 Sep 2025 17:02:34 -0500 Subject: [PATCH 01/11] Create test infrastructure for end to end tests --- .../Hosts.ServiceDefaults/AppHostServices.cs | 1 - .../aspire/AppHosts/All/appsettings.json | 2 +- .../IdentityServerAppHostRoutes.cs | 43 ++++++ .../ServiceDefaults/ServiceDefaults.csproj | 20 +-- identity-server/identity-server.slnf | 4 +- .../IdentityServer.EndToEndTests.csproj | 36 +++++ .../IdentityServerTests.cs | 36 +++++ .../IdentityServerAppHostCollection.cs | 13 ++ .../TestInfra/IdentityServerClient.cs | 143 ++++++++++++++++++ .../IdentityServerHostTestFixture.cs | 12 ++ .../IdentityServerPlaywrightTestBase.cs | 12 ++ products.slnx | 1 + shared/Xunit.Playwright/AppHostFixture.cs | 2 + .../Duende.Xunit.Playwright.csproj | 2 +- 14 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 identity-server/aspire/ServiceDefaults/IdentityServerAppHostRoutes.cs create mode 100644 identity-server/test/IdentityServer.EndToEndTests/IdentityServer.EndToEndTests.csproj create mode 100644 identity-server/test/IdentityServer.EndToEndTests/IdentityServerTests.cs create mode 100644 identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerAppHostCollection.cs create mode 100644 identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerClient.cs create mode 100644 identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerHostTestFixture.cs create mode 100644 identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerPlaywrightTestBase.cs diff --git a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs index 591b4dd91..7cf273569 100644 --- a/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs +++ b/bff/hosts/Hosts.ServiceDefaults/AppHostServices.cs @@ -28,5 +28,4 @@ public static class AppHostServices BffDpop, Migrations ]; - } diff --git a/identity-server/aspire/AppHosts/All/appsettings.json b/identity-server/aspire/AppHosts/All/appsettings.json index 73da574cd..02f210a2c 100644 --- a/identity-server/aspire/AppHosts/All/appsettings.json +++ b/identity-server/aspire/AppHosts/All/appsettings.json @@ -7,7 +7,7 @@ } }, "AspireProjectConfiguration": { - "IdentityHost": "Host_EntityFramework9", + "IdentityHost": "Host_Main9", "UseClients": { "ConsoleCibaClient": false, "ConsoleClientCredentialsFlow": false, diff --git a/identity-server/aspire/ServiceDefaults/IdentityServerAppHostRoutes.cs b/identity-server/aspire/ServiceDefaults/IdentityServerAppHostRoutes.cs new file mode 100644 index 000000000..cf13312e2 --- /dev/null +++ b/identity-server/aspire/ServiceDefaults/IdentityServerAppHostRoutes.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Xunit.Playwright; + +namespace ServiceDefaults; + +public class IdentityServerAppHostRoutes : IAppHostServiceRoutes +{ + public string[] ServiceNames => [ + AppHostServices.IdentityServer, + AppHostServices.Web + ]; + + public Uri UrlTo(string clientName) + { + var url = clientName switch + { + AppHostServices.IdentityServer => "https://localhost:5001", + AppHostServices.MvcAutomaticTokenManagement => "https://localhost:44301", + AppHostServices.MvcCode => "https://localhost:44302", + AppHostServices.MvcDPoP => "https://localhost:44310", + AppHostServices.MvcHybridBackChannel => "https://localhost:44303", + AppHostServices.MvcJarJwt => "https://localhost:44304", + AppHostServices.MvcJarUriJwt => "https://localhost:44305", + AppHostServices.Web => "https://localhost:44306", + _ => throw new InvalidOperationException("client not configured") + }; + return new Uri(url); + } +} + +public class AppHostServices +{ + public const string IdentityServer = "is-host"; + public const string MvcAutomaticTokenManagement = "mvc-automatic-token-management"; + public const string MvcCode = "mvc-code"; + public const string MvcDPoP = "mvc-dpop"; + public const string MvcHybridBackChannel = "mvc-hybrid-backchannel"; + public const string MvcJarJwt = "mvc-jar-jwt"; + public const string MvcJarUriJwt = "mvc-jar-uri-jwt"; + public const string Web = "web"; +} diff --git a/identity-server/aspire/ServiceDefaults/ServiceDefaults.csproj b/identity-server/aspire/ServiceDefaults/ServiceDefaults.csproj index 9e7da9461..838a6f9d9 100644 --- a/identity-server/aspire/ServiceDefaults/ServiceDefaults.csproj +++ b/identity-server/aspire/ServiceDefaults/ServiceDefaults.csproj @@ -6,23 +6,25 @@ enable true false + true - - - - - - - - - + + + + + + + + + + diff --git a/identity-server/identity-server.slnf b/identity-server/identity-server.slnf index dfb9e0b42..064422568 100644 --- a/identity-server/identity-server.slnf +++ b/identity-server/identity-server.slnf @@ -69,8 +69,10 @@ "identity-server\\templates\\src\\IdentityServer\\IdentityServerTemplate.csproj", "identity-server\\templates\\src\\WebApp\\TemplateWebApp.csproj", "identity-server\\test\\IdentityServer.IntegrationTests\\IdentityServer.IntegrationTests.csproj", + "identity-server\\test\\IdentityServer.EndToEndTests\\IdentityServer.EndToEndTests.csproj", "identity-server\\test\\IdentityServer.UnitTests\\IdentityServer.UnitTests.csproj", - "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj" + "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj", + "shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj" ] } } diff --git a/identity-server/test/IdentityServer.EndToEndTests/IdentityServer.EndToEndTests.csproj b/identity-server/test/IdentityServer.EndToEndTests/IdentityServer.EndToEndTests.csproj new file mode 100644 index 000000000..0353b7e41 --- /dev/null +++ b/identity-server/test/IdentityServer.EndToEndTests/IdentityServer.EndToEndTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + Duende.IdentityServer.EndToEndTests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/identity-server/test/IdentityServer.EndToEndTests/IdentityServerTests.cs b/identity-server/test/IdentityServer.EndToEndTests/IdentityServerTests.cs new file mode 100644 index 000000000..9bf655878 --- /dev/null +++ b/identity-server/test/IdentityServer.EndToEndTests/IdentityServerTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.EndToEndTests.TestInfra; +using Duende.Xunit.Playwright; +using Projects; +using ServiceDefaults; +using Xunit.Abstractions; + +namespace Duende.IdentityServer.EndToEndTests; + +[Collection(IdentityServerAppHostCollection.CollectionName)] +public class IdentityServerTests : IntegrationTestBase +{ + private readonly HttpClient _identityServerClient; + private readonly HttpClient _webClient; + + public IdentityServerTests(ITestOutputHelper output, IdentityServerHostTestFixture fixture) : base(output, fixture) + { + _identityServerClient = CreateHttpClient(AppHostServices.IdentityServer); + _webClient = CreateHttpClient(AppHostServices.Web); + } + + [Fact] + public void Can_setup_fixture() => true.ShouldBeTrue(); + + [Fact] + public async Task Can_invoke_discovery() + { + var discoResponse = await _identityServerClient.GetAsync("/.well-known/openid-configuration"); + discoResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + + var webResponse = await _webClient.GetAsync("/"); + webResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + } +} diff --git a/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerAppHostCollection.cs b/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerAppHostCollection.cs new file mode 100644 index 000000000..78cccc035 --- /dev/null +++ b/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerAppHostCollection.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.EndToEndTests.TestInfra; + +[CollectionDefinition(CollectionName)] +public class IdentityServerAppHostCollection : ICollectionFixture +{ + public const string CollectionName = "apphost collection"; + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerClient.cs b/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerClient.cs new file mode 100644 index 000000000..2c95dbcd5 --- /dev/null +++ b/identity-server/test/IdentityServer.EndToEndTests/TestInfra/IdentityServerClient.cs @@ -0,0 +1,143 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json; +using AngleSharp; +using AngleSharp.Html.Dom; + +namespace Duende.IdentityServer.EndToEndTests.TestInfra; + +/// +/// Client for the BFF. All the methods that can be invoked are here. +/// +public class IdentityServerClient +{ + private readonly HttpClient _client; + + public IdentityServerClient(HttpClient client) + { + _client = client; + + // Add a header that will trigger pre-flight cors checks + _client.DefaultRequestHeaders.Add("X-CSRF", "1"); + } + + public async Task TriggerLogin(string userName = "alice", string password = "alice", CancellationToken ct = default) + { + var triggerLoginResponse = await _client.GetAsync("/bff/login"); + + triggerLoginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + + var loginPage = triggerLoginResponse.RequestMessage?.RequestUri ?? + throw new InvalidOperationException("Can't find the login page."); + loginPage.AbsolutePath.ShouldBe("/Account/Login"); + + var html = await triggerLoginResponse.Content.ReadAsStringAsync(); + var form = await ExtractFormFieldsAsync(html); + + form.Fields["Input.Username"] = "alice"; + form.Fields["Input.Password"] = "alice"; + form.Fields["Input.Button"] = "login"; + + var postLoginResponse = + await _client.PostAsync(new Uri(loginPage, form.FormUrl), new FormUrlEncodedContent(form.Fields), ct); + + postLoginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + postLoginResponse.RequestMessage?.RequestUri?.Authority.ShouldBe(_client.BaseAddress?.Authority, + await postLoginResponse.Content.ReadAsStringAsync(ct)); + } + + private async Task
ExtractFormFieldsAsync(string htmlContent) + { + // Create a configuration for AngleSharp + var config = AngleSharp.Configuration.Default.WithDefaultLoader(); + + // Load the HTML content into an AngleSharp browsing context + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Content(htmlContent)); + + // Find the first form on the page + var form = document.QuerySelector("form"); + if (form == null) + { + throw new InvalidOperationException("No form found in the provided HTML content."); + } + + // Extract all form fields and their values + var formFields = new Dictionary(); + foreach (var element in form.QuerySelectorAll("input")) + { + var name = element.GetAttribute("name") ?? throw new InvalidOperationException("input doesn't have a name"); + if (string.IsNullOrEmpty(name)) + { + continue; // Skip elements without a name attribute + } + + var value = element.GetAttribute("value") ?? string.Empty; + + if (element is IHtmlSelectElement selectElement) + { + // Handle s in some browsers, due to the limited stylability of `s in IE10+.\n &::-ms-expand {\n border: 0;\n background-color: transparent;\n }\n\n // Disabled and read-only inputs\n //\n // HTML5 says that controls under a fieldset > legend:first-child won't be\n // disabled if the fieldset is disabled. Due to implementation difficulty, we\n // don't honor that edge case; we style them as disabled anyway.\n &[disabled],\n &[readonly],\n fieldset[disabled] & {\n background-color: @input-bg-disabled;\n opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655\n }\n\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n }\n\n // Reset height for `textarea`s\n textarea& {\n height: auto;\n }\n}\n\n\n// Search inputs in iOS\n//\n// This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n\n\n// Special styles for iOS temporal inputs\n//\n// In Mobile Safari, setting `display: block` on temporal inputs causes the\n// text within the input to become vertically misaligned. As a workaround, we\n// set a pixel line-height that matches the given height of the input, but only\n// for Safari. See https://bugs.webkit.org/show_bug.cgi?id=139848\n//\n// Note that as of 8.3, iOS doesn't support `datetime` or `week`.\n\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"],\n input[type=\"time\"],\n input[type=\"datetime-local\"],\n input[type=\"month\"] {\n &.form-control {\n line-height: @input-height-base;\n }\n\n &.input-sm,\n .input-group-sm & {\n line-height: @input-height-small;\n }\n\n &.input-lg,\n .input-group-lg & {\n line-height: @input-height-large;\n }\n }\n}\n\n\n// Form groups\n//\n// Designed to help with the organization and spacing of vertical forms. For\n// horizontal forms, use the predefined grid classes.\n\n.form-group {\n margin-bottom: @form-group-margin-bottom;\n}\n\n\n// Checkboxes and radios\n//\n// Indent the labels to position radios/checkboxes as hanging controls.\n\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n\n label {\n min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n }\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing\n}\n\n// Radios and checkboxes on same line\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px; // space out consecutive inline controls\n}\n\n// Apply same disabled cursor tweak as for inputs\n// Some special care is needed because