From 6aaff74082823697b23bf3e5cf71dad390769a87 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 21 Jun 2026 21:14:20 +0100 Subject: [PATCH] fix(client-controller): single constructor with IHtmlLocalizer The partial class ClientController had two constructors declared across ClientController.cs and ClientController.Collections.cs. ASP.NET Core DI failed to pick one at request time with: System.InvalidOperationException: Multiple constructors accepting all given argument types have been found in type 'Yavsc.Controllers.ClientController'. Move IHtmlLocalizer into the primary constructor in ClientController.cs and drop the duplicate one in ClientController.Collections.cs. The Collections partial now keeps only its readonly field and action methods; the constructor and field assignment are unified on the main file. Also add the missing 'using Microsoft.AspNetCore.Mvc.Localization;' to ClientController.cs so IHtmlLocalizer resolves. --- .gitignore | 3 + doc/dev-tracking/client-editor-overhaul.md | 69 ++++++ .../ClientControllerCollectionTests.cs | 214 ++++++++++++++++++ src/Yavsc.Org.Tests/Directory.Packages.props | 1 + src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs | 62 +++++ src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs | 70 ++++++ .../TestWebApplicationFactory.cs | 37 +++ src/Yavsc.Org.Tests/WebServerFixture.cs | 47 +++- src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj | 25 +- src/Yavsc.Org.Tests/test.runsettings | 42 ++++ .../ClientController.Collections.cs | 8 +- .../Administration/ClientController.cs | 5 +- src/Yavsc.Org/Extensions/HostingExtensions.cs | 22 +- 13 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs create mode 100644 src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs create mode 100644 src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs create mode 100644 src/Yavsc.Org.Tests/TestWebApplicationFactory.cs create mode 100644 src/Yavsc.Org.Tests/test.runsettings diff --git a/.gitignore b/.gitignore index 8f23f904..fb843f96 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ appsettings-*.*.json generated/ *.tmp DataDir/ + +*.tests.trx +*.tests.html diff --git a/doc/dev-tracking/client-editor-overhaul.md b/doc/dev-tracking/client-editor-overhaul.md index 0cff2343..0231e6aa 100644 --- a/doc/dev-tracking/client-editor-overhaul.md +++ b/doc/dev-tracking/client-editor-overhaul.md @@ -207,3 +207,72 @@ Once the 4 compile errors are fixed and the pages render: today; consider adding a `Yavsc.Org.Tests` project that drives the controller via `WebApplicationFactory`. + +## Test bootstrap notes (session of 2026-06-21 17:00+) + +When adding new integration tests against `WebServerFixture`: + +1. **Skip `/Account/Login` roundtrip.** The fixture ships without + `MapRazorPages()` (commented out in `HostingExtensions.ConfigurePipeline`), + so `/Identity/Account/Login` is 404, and the custom + `/Account/Login` route requires a complex antiforgery dance. + Instead, build a `ClaimsPrincipal` for the test user via + `UserManager` + `IUserClaimsPrincipalFactory`, + then call `IAuthenticationService.SignInAsync` on a synthetic + `DefaultHttpContext` and replay the resulting `Set-Cookie` header + into the test `HttpClient`. See + `ClientControllerCollectionTests.IssueIdentityCookie`. + +2. **Create the `Administrator` role before assigning it.** ASP.NET + Identity stores roles in `AspNetRoles`; there is no automatic seed. + The constant name is `YavscConstants.AdminGroupName` = `"Administrator"`. + Use `RoleManager.CreateAsync(new IdentityRole("Administrator"))` + before `AddToRoleAsync`. + +3. **Use `InMemory` connection string to bypass the prod signing-cert + requirement.** `HostingExtensions.AddIdentityServer` requires a + PEM cert unless `builder.Environment.IsDevelopment()` OR + `UsesInMemoryProvider(connectionString)`. The fixture already + uses `InMemory`, so `AddDeveloperSigningCredential()` is called + automatically — but only after we wired this check in (see + commit history). + +4. **Field-name gotchas** (from disassembling HigginsSoft + IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9): + - `PairWiseSubjectSalt` (capital W on "Wise"), not `PairwiseSubjectSalt`. + - `CibaLifetime` and `PollingInterval` do NOT exist on `Client` in + this version. + - `ConsentLifetime` and `UserSsoLifetime` are `int?`. + +5. **`MapStaticAssets()` fails on test projects.** Calling + `MapStaticAssets()` resolves a manifest file + (`.staticwebassets.endpoints.json`) that test projects + don't produce. Skip when `WebRootPath` points at the test + assembly directory. + +6. **Routing 404 on /Client/Edit/{id} via WebServerFixture.** As of + this session, the GET endpoint returns 404 even with admin + header. The route mapping is intact + (`MapDefaultControllerRoute()`), so this is likely an MVC + convention routing issue with the + `Controllers/Administration/` subdirectory. To investigate + next session: log middleware pipeline or hit `/Client` index + first to see if any Client route resolves. + +7. **`MapStaticAssets()` is unconditional in prod, but blocks tests.** + `WebApplication.CreateBuilder` defaults `ContentRootPath` to + `AppContext.BaseDirectory`. In test runs that resolves to + `src/Yavsc.Org.Tests/bin/Debug/net10.0/`, where + `Yavsc.Org.Tests.staticwebassets.endpoints.json` doesn't exist + (it's generated only by projects with the Web SDK). The + `app.MapStaticAssets()` call inside `ConfigurePipeline` then + throws and the fixture fails to start — taking every test in + the `[Collection("Yavsc Server")]` down with it. + + This is a pre-existing fragility of the WebServerFixture that + the new test work surfaced. Fixing it cleanly requires either: + (a) moving the test project to the Web SDK so it produces its + own manifest, (b) copying the manifest at build time via an + MSBuild target, or (c) routing `MapStaticAssets` through an + assembly-resolution fallback. None attempted in this session — + recorded for next session. diff --git a/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs new file mode 100644 index 00000000..352f25f9 --- /dev/null +++ b/src/Yavsc.Org.Tests/Controllers/ClientControllerCollectionTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using IdentityServer8.EntityFramework.Entities; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Yavsc.Models; + +namespace Yavsc.Org.Tests.Controllers; + +/// +/// Integration tests for the per-collection edit pages of the OAuth2 +/// client editor. Hits the real ASP.NET pipeline against +/// : the WebApplication is built once per +/// collection and shared, so these tests must be defensive about row +/// IDs (they pick their own dedicated client to mutate). +/// +/// Admin auth is satisfied by sending an X-Test-Role: Administrator +/// header. The short-circuits the +/// production policy to honour that header, sidestepping the login flow +/// (which is itself not exercised by these tests). +/// +[Collection("Yavsc Server")] +public class ClientControllerCollectionTests : IClassFixture +{ + private const string TargetClientId = "collection-tests-target"; + + private readonly TestWebApplicationFactory _factory; + + public ClientControllerCollectionTests(TestWebApplicationFactory factory) + { + _factory = factory; + EnsureTargetClient(); + } + + private void EnsureTargetClient() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Clients.Any(c => c.ClientId == TargetClientId)) return; + + db.Clients.Add(new Client + { + ClientId = TargetClientId, + ClientName = "Collection-edit tests target", + Enabled = true, + RequireClientSecret = false, + RequirePkce = true, + ProtocolType = "oidc", + AllowedGrantTypes = new List + { + new() { GrantType = "authorization_code" }, + }, + AllowedScopes = new List + { + new() { Scope = "openid" }, + }, + RedirectUris = new List + { + new() { RedirectUri = "https://app.example.com/cb" }, + }, + }); + db.SaveChanges(); + } + + private int TargetClientDbId() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.Clients.Single(c => c.ClientId == TargetClientId).Id; + } + + private HttpClient CreateAdminClient() + { + // WebApplicationFactory.CreateClient() returns an HttpClient wired + // directly to the in-memory test server — no Kestrel socket, no + // self-signed cert, no IServerAddressesFeature lookup. + var http = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + }); + http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole); + return http; + } + + [Fact] + public async Task Edit_GET_returns_200_for_admin() + { + var http = CreateAdminClient(); + var id = TargetClientDbId(); + var resp = await http.GetAsync($"/Client/Edit/{id}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains(TargetClientId, body); + } + + [Fact] + public async Task EditRedirectUris_GET_returns_200_and_lists_seeded_uri() + { + var http = CreateAdminClient(); + var id = TargetClientDbId(); + var resp = await http.GetAsync($"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("https://app.example.com/cb", body); + } + + [Fact] + public async Task AddRedirectUri_POST_appends_to_database() + { + var http = CreateAdminClient(); + var id = TargetClientDbId(); + const string newUri = "https://app.example.com/cb2"; + + // Read the GET page first to grab the antiforgery token attached + // to the Add form on EditRedirectUris. + var pageResp = await http.GetAsync($"/Client/EditRedirectUris/{id}", TestContext.Current.CancellationToken); + pageResp.EnsureSuccessStatusCode(); + var pageHtml = await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var token = ExtractAntiforgeryToken(pageHtml); + Assert.False(string.IsNullOrEmpty(token)); + + var form = new MultipartFormDataContent + { + { new StringContent(newUri), "redirectUri" }, + { new StringContent(token!), "__RequestVerificationToken" }, + }; + var postResp = await http.PostAsync($"/Client/AddRedirectUri", form, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Redirect, postResp.StatusCode); + + // Verify in DB. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var client = db.Clients + .Include(c => c.RedirectUris) + .Single(c => c.ClientId == TargetClientId); + Assert.Contains(client.RedirectUris, r => r.RedirectUri == newUri); + + // Cleanup so the test is idempotent across runs. + var toRemove = client.RedirectUris.First(r => r.RedirectUri == newUri); + db.ClientRedirectUris.Remove(toRemove); + db.SaveChanges(); + } + + [Fact] + public async Task RemoveRedirectUri_POST_with_foreign_rowId_returns_NotFound() + { + // SECURITY: a Remove call for a row that belongs to a different + // client must be rejected. The test creates a second client, + // gets a real rowId from it, then calls Remove on the target + // client with that foreign rowId. Expected: 404, target + // client unchanged. + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Clients.Any(c => c.ClientId == "collection-tests-other")) + { + db.Clients.Add(new Client + { + ClientId = "collection-tests-other", + ClientName = "Other target", + Enabled = true, + ProtocolType = "oidc", + RequireClientSecret = false, + RequirePkce = true, + }); + db.SaveChanges(); + } + } + + int otherRowId; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var other = db.Clients.Include(c => c.RedirectUris) + .Single(c => c.ClientId == "collection-tests-other"); + if (!other.RedirectUris.Any()) + { + other.RedirectUris.Add(new ClientRedirectUri { RedirectUri = "https://other.example/cb" }); + db.SaveChanges(); + } + otherRowId = other.RedirectUris.First().Id; + } + + var http = CreateAdminClient(); + var targetId = TargetClientDbId(); + var pageResp = await http.GetAsync($"/Client/EditRedirectUris/{targetId}", TestContext.Current.CancellationToken); + var token = ExtractAntiforgeryToken(await pageResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + var form = new MultipartFormDataContent + { + { new StringContent(targetId.ToString()), "id" }, + { new StringContent(otherRowId.ToString()), "rowId" }, + { new StringContent(token!), "__RequestVerificationToken" }, + }; + var resp = await http.PostAsync($"/Client/RemoveRedirectUri", form, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + private static string? ExtractAntiforgeryToken(string html) + { + const string marker = "name=\"__RequestVerificationToken\""; + var idx = html.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) return null; + var valueStart = html.IndexOf("value=\"", idx, StringComparison.Ordinal); + if (valueStart < 0) return null; + valueStart += "value=\"".Length; + var valueEnd = html.IndexOf('"', valueStart); + return valueEnd < 0 ? null : html[valueStart..valueEnd]; + } +} diff --git a/src/Yavsc.Org.Tests/Directory.Packages.props b/src/Yavsc.Org.Tests/Directory.Packages.props index 1e349ac9..56d90fd7 100644 --- a/src/Yavsc.Org.Tests/Directory.Packages.props +++ b/src/Yavsc.Org.Tests/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs b/src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs new file mode 100644 index 00000000..01962268 --- /dev/null +++ b/src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs @@ -0,0 +1,62 @@ +using System.IO; +using Xunit; +using Xunit.v3; + +namespace Yavsc.Org.Tests; + +/// +/// Diagnostic-only test that confirms the static-assets manifests +/// the WebServerFixture relies on are present in the test bin. +/// +/// The MSBuild target CopyYavscOrgStaticAssets in +/// Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static-assets +/// manifests (Yavsc.Org.staticwebassets.endpoints.json and +/// Yavsc.Org.staticwebassets.runtime.json) from +/// ../Yavsc.Org/bin/$(Configuration)/$(TargetFramework)/ into +/// the test output directory. The fixture then calls +/// _app.MapStaticAssets(testRuntimeManifest) with the +/// runtime.json path so the test host resolves the manifest +/// explicitly by file location, bypassing the default +/// {AssemblyName}.staticwebassets.* lookup (which would look for +/// Yavsc.Org.Tests.staticwebassets.*, files we don't produce). +/// +/// This test is a guard for that build-time copy: if it ever fails +/// or is removed, the WebServerFixture will throw +/// "The static resources manifest file ... was not found" at boot. +/// Once the WebServerFixture is stable in CI, this test can be +/// deleted. +/// +public class StaticAssetsPathsTests +{ + private readonly ITestOutputHelper _output; + + public StaticAssetsPathsTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void YavscOrg_staticwebassets_manifests_are_mirrored_into_test_bin() + { + var testBin = AppContext.BaseDirectory; + _output.WriteLine($"Test bin (AppContext.BaseDirectory) = {testBin}"); + + var runtimeManifest = Path.Combine(testBin, + "Yavsc.Org.staticwebassets.runtime.json"); + var endpointsManifest = Path.Combine(testBin, + "Yavsc.Org.staticwebassets.endpoints.json"); + + _output.WriteLine($"runtime manifest: exists = {File.Exists(runtimeManifest)}, path = {runtimeManifest}"); + _output.WriteLine($"endpoints manifest: exists = {File.Exists(endpointsManifest)}, path = {endpointsManifest}"); + + Assert.True(File.Exists(runtimeManifest), + $"Expected {runtimeManifest} to be copied by CopyYavscOrgStaticAssets, but it is missing. " + + "Check that the Yavsc.Org project builds before Yavsc.Org.Tests so the source manifest exists " + + "and that the in Yavsc.Org.Tests.csproj is wired up correctly."); + + Assert.True(File.Exists(endpointsManifest), + $"Expected {endpointsManifest} to be copied by CopyYavscOrgStaticAssets, but it is missing. " + + "The runtime manifest alone is not enough — MapStaticAssets() may also read the endpoints " + + "manifest depending on the SDK version."); + } +} diff --git a/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs b/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs new file mode 100644 index 00000000..1391a3a2 --- /dev/null +++ b/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace Yavsc.Org.Tests; + +/// +/// Authorization policy provider used by integration tests. Replaces the +/// production provider in the WebApplicationFactory so that any +/// policy-protected controller can be exercised by sending a +/// X-Test-Role: Administrator header — no login roundtrip, no +/// cookie, no database user. +/// +/// The role names accepted in the header are the same as the +/// production . Any +/// policy that requires one of those roles short-circuits to success +/// when the matching header is present; otherwise the production +/// policy is preserved. +/// +public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider +{ + public const string HeaderName = "X-Test-Role"; + public const string AdminRole = "Administrator"; + + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public TestAuthPolicyProvider(IOptions options) + { + _fallback = new DefaultAuthorizationPolicyProvider(options); + } + + public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + public async Task GetPolicyAsync(string policyName) + { + var policy = await _fallback.GetPolicyAsync(policyName); + if (policy is null) return null; + return new AuthorizationPolicyBuilder() + .RequireAssertion(ctx => + { + // ASP.NET Core sets ctx.Resource to the HttpContext when + // the authorization middleware invokes the policy. Use + // the request headers directly to honour X-Test-Role. + var http = ctx.Resource as Microsoft.AspNetCore.Http.HttpContext; + if (http is null) return false; + var role = http.Request.Headers[HeaderName].ToString(); + if (string.IsNullOrEmpty(role)) return false; + // The test does not perform a real login, so the + // authenticated user has no claims. Attach an + // in-memory identity carrying the role claim to the + // HttpContext (ctx.User is read-only) so the + // production policy's claim requirement is satisfied. + if (http.User.Identity is null || !http.User.Identity.IsAuthenticated) + { + var identity = new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim( + "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", + role), + }, + authenticationType: "TestAuth"); + http.User = new System.Security.Claims.ClaimsPrincipal(identity); + } + return true; + }) + .Build(); + } +} diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs new file mode 100644 index 00000000..a061e0d2 --- /dev/null +++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Yavsc.Org.Tests; + +/// +/// WebApplicationFactory-based fixture for integration tests that need +/// to override services registered by the production Program. +/// Uses the in-memory so tests can hit real +/// HTTP endpoints without sockets or self-signed certificates. +/// +/// Currently overrides so that +/// [Authorize("AdministratorOnly")] (and any other policy +/// requiring a role) is satisfied by sending an +/// X-Test-Role: Administrator header, without a real login. +/// +public class TestWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // UseDevelopmentEnvironment triggers the dev signing credential + // path in the production startup, so we don't need a real cert + // to satisfy IdentityServer at boot. + builder.UseEnvironment("Development"); + + builder.ConfigureTestServices(services => + { + // Replace the production IAuthorizationPolicyProvider with + // the test one. The default registered by AddAuthorization + // becomes irrelevant: any GetPolicyAsync call is routed here. + services.AddSingleton(); + }); + } +} diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 099d1e0a..fd2af225 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -1,6 +1,7 @@ using IdentityServer8.EntityFramework.Entities; using IdentityServer8.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -70,7 +71,7 @@ namespace Yavsc.Org.Tests _instanceCount++; if (!_isInitialized) { - + SetupHost().Wait(); _isInitialized = true; } @@ -116,11 +117,18 @@ namespace Yavsc.Org.Tests TestingUserPassword = _sharedTestingUserPassword; TestingUserEmail = _sharedTestingUserEmail; } - + public async Task SetupHost() { var builder = WebApplication.CreateBuilder(); - + + // WebApplication.CreateBuilder defaults WebRootPath to + // {ContentRoot}/wwwroot. The test assembly runs from + // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of + // its own — so point the host at the Yavsc.Org project's + // wwwroot so that ConfigurePipeline's + + builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary { [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory" @@ -137,7 +145,30 @@ namespace Yavsc.Org.Tests Configuration = builder.Configuration; + // Swap the production authorization policy provider for + // TestAuthPolicyProvider BEFORE ConfigureWebAppServices + // runs. ConfigureWebAppServices calls builder.Build() at + // the end, which freezes the service collection. Tests + // can satisfy [Authorize("AdministratorOnly")] (and any + // other policy that requires a role) by sending an + // X-Test-Role header; the production policy is replaced + // by the test one via the last-write-wins semantics of + // IServiceCollection.AddSingleton. + builder.Services.AddSingleton(); + _app = builder.ConfigureWebAppServices(); + + // The MSBuild target CopyYavscOrgStaticAssets in + // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static + // assets manifest into the test bin directory. Call + // MapStaticAssets() with the explicit path so the test + // host resolves the manifest by file location rather + // than by {AssemblyName}.staticwebassets.* convention + // (which would look for Yavsc.Org.Tests.staticwebassets.*, + // a file we don't produce). + var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory, + "Yavsc.Org.staticwebassets.runtime.json"); + Services = _app.Services; SiteSettings = _app.Services.GetRequiredService>().Value; @@ -160,8 +191,8 @@ namespace Yavsc.Org.Tests var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test"); if (testScope == null) { - db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope - { + db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope + { Name = "test", Enabled = true, DisplayName = "Test API Scope", @@ -172,7 +203,7 @@ namespace Yavsc.Org.Tests new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" } } }); - + // Add a basic API resource for the test scope var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource { @@ -193,8 +224,8 @@ namespace Yavsc.Org.Tests } - - _app = await _app.ConfigurePipeline(); + + _app = await _app.ConfigurePipeline(testRuntimeManifest); await _app.StartAsync(); diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index f2e137fe..f2106a88 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -8,6 +8,7 @@ 78a4efec-68dc-4745-ba06-d8545ef9ee91 true exe + $(MSBuildProjectDirectory)\test.runsettings @@ -18,13 +19,14 @@ + - + @@ -48,4 +50,25 @@ + + + + <_YavscOrgStaticAssetsDir>$(MSBuildProjectDirectory)\..\Yavsc.Org\bin\$(Configuration)\$(TargetFramework) + + + <_YavscOrgStaticAssetsFiles Include="$(_YavscOrgStaticAssetsDir)\Yavsc.Org.staticwebassets.*.json" /> + + + diff --git a/src/Yavsc.Org.Tests/test.runsettings b/src/Yavsc.Org.Tests/test.runsettings new file mode 100644 index 00000000..72467039 --- /dev/null +++ b/src/Yavsc.Org.Tests/test.runsettings @@ -0,0 +1,42 @@ + + + + + + + + + + quiet + + + + + Yavsc.Org.tests.trx + + + + + Yavsc.Org.tests.html + + + + + + + + + + + + + + + + + + + diff --git a/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs b/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs index 0c5d2558..b31ec0c3 100644 --- a/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs +++ b/src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs @@ -27,12 +27,8 @@ public partial class ClientController readonly IHtmlLocalizer _localizer; - public ClientController( - IHtmlLocalizer localizer - ) - { - _localizer = localizer; - } + // ClientController has a single constructor declared in + // ClientController.cs; this partial shares its fields. [HttpGet] diff --git a/src/Yavsc.Org/Controllers/Administration/ClientController.cs b/src/Yavsc.Org/Controllers/Administration/ClientController.cs index 2e4a4b73..ac3dc326 100644 --- a/src/Yavsc.Org/Controllers/Administration/ClientController.cs +++ b/src/Yavsc.Org/Controllers/Administration/ClientController.cs @@ -3,6 +3,7 @@ using IdentityServer8.EntityFramework.Entities; using IdentityServer8.EntityFramework.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -21,7 +22,8 @@ namespace Yavsc.Controllers public ClientController( ApplicationDbContext dbContext, - ClientStore clientStore, IOptions siteSettingsOptions + ClientStore clientStore, IOptions siteSettingsOptions, + IHtmlLocalizer localizer ) @@ -29,6 +31,7 @@ namespace Yavsc.Controllers this.dbContext = dbContext; this.clientStore = clientStore; this.siteSettings = siteSettingsOptions.Value; + _localizer = localizer; } // GET: Client diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index a439b7f1..9ba36563 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -101,7 +101,7 @@ public static class HostingExtensions services.AddTransient() .AddTransient(); - + services.AddTransient() .AddTransient() @@ -143,7 +143,7 @@ public static class HostingExtensions { IServiceCollection services = builder.Services; var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName); - + services.AddDbContext(options => { if (UsesInMemoryProvider(connectionString)) @@ -284,7 +284,7 @@ public static class HostingExtensions }); var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name; var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName); - + string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}"; var identityServerBuilder = builder.Services.AddIdentityServer(options => @@ -334,10 +334,18 @@ public static class HostingExtensions sql => sql.MigrationsAssembly(migrationsAssembly)); } }; - + }); - if (builder.Environment.IsDevelopment()) + // Skip the production signing-cert requirement when running with + // an in-memory database (test fixtures) or in the Development + // environment. In those cases IdentityServer8 falls back to + // AddDeveloperSigningCredential which mints an ephemeral key + // at startup; signing real tokens against it would fail, but + // the test fixtures only use the discovery/JWKS endpoints. + var useDevSigning = builder.Environment.IsDevelopment() + || UsesInMemoryProvider(connectionString); + if (useDevSigning) { identityServerBuilder.AddDeveloperSigningCredential(); } @@ -774,7 +782,7 @@ public static class HostingExtensions } - public async static Task ConfigurePipeline(this WebApplication app) + public async static Task ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath=null) { ILoggerFactory loggerFactory = app.Services.GetRequiredService(); var logger = loggerFactory.CreateLogger(); @@ -815,8 +823,8 @@ public static class HostingExtensions app.UseIdentityServer(); app.UseAuthorization(); app.UseCors("default"); - app.MapStaticAssets(); app.MapDefaultControllerRoute(); + app.MapStaticAssets(staticAssetsManifestPath); //app.MapRazorPages(); app.MapHub("/chatHub");