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<ClientController> 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.
This commit is contained in:
Paul Schneider 2026-06-21 21:14:20 +01:00
commit 6aaff74082
13 changed files with 582 additions and 23 deletions

3
.gitignore vendored
View file

@ -27,3 +27,6 @@ appsettings-*.*.json
generated/ generated/
*.tmp *.tmp
DataDir/ DataDir/
*.tests.trx
*.tests.html

View file

@ -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 today; consider adding a `Yavsc.Org.Tests` project that drives
the controller via `WebApplicationFactory<Program>`. the controller via `WebApplicationFactory<Program>`.
## 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<ApplicationUser>`,
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<IdentityRole>.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
(`<project>.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.

View file

@ -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;
/// <summary>
/// Integration tests for the per-collection edit pages of the OAuth2
/// client editor. Hits the real ASP.NET pipeline against
/// <see cref="WebServerFixture"/>: 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 <c>X-Test-Role: Administrator</c>
/// header. The <see cref="TestAuthPolicyProvider"/> short-circuits the
/// production policy to honour that header, sidestepping the login flow
/// (which is itself not exercised by these tests).
/// </summary>
[Collection("Yavsc Server")]
public class ClientControllerCollectionTests : IClassFixture<TestWebApplicationFactory>
{
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<ApplicationDbContext>();
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<ClientGrantType>
{
new() { GrantType = "authorization_code" },
},
AllowedScopes = new List<ClientScope>
{
new() { Scope = "openid" },
},
RedirectUris = new List<ClientRedirectUri>
{
new() { RedirectUri = "https://app.example.com/cb" },
},
});
db.SaveChanges();
}
private int TargetClientDbId()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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];
}
}

View file

@ -5,6 +5,7 @@
<!-- Yavsc.Org.Tests-specific versions --> <!-- Yavsc.Org.Tests-specific versions -->
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />

View file

@ -0,0 +1,62 @@
using System.IO;
using Xunit;
using Xunit.v3;
namespace Yavsc.Org.Tests;
/// <summary>
/// Diagnostic-only test that confirms the static-assets manifests
/// the WebServerFixture relies on are present in the test bin.
///
/// The MSBuild target <c>CopyYavscOrgStaticAssets</c> in
/// Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static-assets
/// manifests (<c>Yavsc.Org.staticwebassets.endpoints.json</c> and
/// <c>Yavsc.Org.staticwebassets.runtime.json</c>) from
/// <c>../Yavsc.Org/bin/$(Configuration)/$(TargetFramework)/</c> into
/// the test output directory. The fixture then calls
/// <c>_app.MapStaticAssets(testRuntimeManifest)</c> with the
/// <c>runtime.json</c> path so the test host resolves the manifest
/// explicitly by file location, bypassing the default
/// {AssemblyName}.staticwebassets.* lookup (which would look for
/// <c>Yavsc.Org.Tests.staticwebassets.*</c>, 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.
/// </summary>
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 <Target> 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.");
}
}

View file

@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace Yavsc.Org.Tests;
/// <summary>
/// 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
/// <c>X-Test-Role: Administrator</c> header — no login roundtrip, no
/// cookie, no database user.
///
/// The role names accepted in the header are the same as the
/// production <see cref="YavscConstants.AdminGroupName"/>. Any
/// policy that requires one of those roles short-circuits to success
/// when the matching header is present; otherwise the production
/// policy is preserved.
/// </summary>
public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider
{
public const string HeaderName = "X-Test-Role";
public const string AdminRole = "Administrator";
private readonly DefaultAuthorizationPolicyProvider _fallback;
public TestAuthPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public async Task<AuthorizationPolicy?> 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();
}
}

View file

@ -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;
/// <summary>
/// WebApplicationFactory-based fixture for integration tests that need
/// to override services registered by the production <c>Program</c>.
/// Uses the in-memory <see cref="TestServer"/> so tests can hit real
/// HTTP endpoints without sockets or self-signed certificates.
///
/// Currently overrides <see cref="TestAuthPolicyProvider"/> so that
/// <c>[Authorize("AdministratorOnly")]</c> (and any other policy
/// requiring a role) is satisfied by sending an
/// <c>X-Test-Role: Administrator</c> header, without a real login.
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
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<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
});
}
}

View file

@ -1,6 +1,7 @@
using IdentityServer8.EntityFramework.Entities; using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.Models; using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server;
@ -121,6 +122,13 @@ namespace Yavsc.Org.Tests
{ {
var builder = WebApplication.CreateBuilder(); 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<string, string?> builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{ {
[$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory" [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory"
@ -137,7 +145,30 @@ namespace Yavsc.Org.Tests
Configuration = builder.Configuration; 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<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
_app = builder.ConfigureWebAppServices(); _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; Services = _app.Services;
SiteSettings = _app.Services.GetRequiredService<IOptions<SiteSettings>>().Value; SiteSettings = _app.Services.GetRequiredService<IOptions<SiteSettings>>().Value;
@ -194,7 +225,7 @@ namespace Yavsc.Org.Tests
_app = await _app.ConfigurePipeline(); _app = await _app.ConfigurePipeline(testRuntimeManifest);
await _app.StartAsync(); await _app.StartAsync();

View file

@ -8,6 +8,7 @@
<UserSecretsId>78a4efec-68dc-4745-ba06-d8545ef9ee91</UserSecretsId> <UserSecretsId>78a4efec-68dc-4745-ba06-d8545ef9ee91</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<OutputType>exe</OutputType> <OutputType>exe</OutputType>
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
@ -18,6 +19,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
@ -48,4 +50,25 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
</ItemGroup> </ItemGroup>
<!--
MapStaticAssets() in the production pipeline resolves
{AssemblyName}.staticwebassets.endpoints.json from ContentRoot.
The referenced Yavsc.Org project produces
Yavsc.Org.staticwebassets.endpoints.json (and a runtime sibling).
Mirror them as Yavsc.Org.Tests.staticwebassets.* in the test bin
directory so the test WebApplicationFactory finds them at boot.
-->
<Target Name="CopyYavscOrgStaticAssets"
AfterTargets="Build">
<PropertyGroup>
<_YavscOrgStaticAssetsDir>$(MSBuildProjectDirectory)\..\Yavsc.Org\bin\$(Configuration)\$(TargetFramework)</_YavscOrgStaticAssetsDir>
</PropertyGroup>
<ItemGroup>
<_YavscOrgStaticAssetsFiles Include="$(_YavscOrgStaticAssetsDir)\Yavsc.Org.staticwebassets.*.json" />
</ItemGroup>
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)"
DestinationFolder="$(OutDir)"
SkipUnchangedFiles="true"
Condition="'@(_YavscOrgStaticAssetsFiles)' != ''" />
</Target>
</Project> </Project>

View file

@ -0,0 +1,42 @@
<RunSettings>
<RunConfiguration>
</RunConfiguration>
<LoggerRunSettings>
<Loggers>
<Logger friendlyName="blame" enabled="True" />
<Logger friendlyName="console" enabled="True">
<Configuration>
<Verbosity>quiet</Verbosity>
</Configuration>
</Logger>
<Logger friendlyName="trx" enabled="True">
<Configuration>
<LogFileName>Yavsc.Org.tests.trx</LogFileName>
</Configuration>
</Logger>
<Logger friendlyName="html" enabled="True">
<Configuration>
<LogFileName>Yavsc.Org.tests.html</LogFileName>
</Configuration>
</Logger>
</Loggers>
</LoggerRunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<!-- Enables blame -->
<DataCollector friendlyName="blame" enabled="True">
<Configuration>
<!-- Enables crash dump, with dump type "Full" or "Mini".
Requires ProcDump in PATH for .NET Framework. -->
<CollectDump DumpType="Full" />
<!-- Enables hang dump or testhost and its child processes
when a test hangs for more than 10 minutes.
Dump type "Full", "Mini" or "None" (just kill the processes). -->
<CollectDumpOnTestSessionHang TestTimeout="10min" HangDumpType="Full" />
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

View file

@ -27,12 +27,8 @@ public partial class ClientController
readonly IHtmlLocalizer _localizer; readonly IHtmlLocalizer _localizer;
public ClientController( // ClientController has a single constructor declared in
IHtmlLocalizer<ClientController> localizer // ClientController.cs; this partial shares its fields.
)
{
_localizer = localizer;
}
[HttpGet] [HttpGet]

View file

@ -3,6 +3,7 @@ using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.EntityFramework.Stores; using IdentityServer8.EntityFramework.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -21,7 +22,8 @@ namespace Yavsc.Controllers
public ClientController( public ClientController(
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
ClientStore clientStore, IOptions<SiteSettings> siteSettingsOptions ClientStore clientStore, IOptions<SiteSettings> siteSettingsOptions,
IHtmlLocalizer<ClientController> localizer
) )
@ -29,6 +31,7 @@ namespace Yavsc.Controllers
this.dbContext = dbContext; this.dbContext = dbContext;
this.clientStore = clientStore; this.clientStore = clientStore;
this.siteSettings = siteSettingsOptions.Value; this.siteSettings = siteSettingsOptions.Value;
_localizer = localizer;
} }
// GET: Client // GET: Client

View file

@ -337,7 +337,15 @@ public static class HostingExtensions
}); });
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(); identityServerBuilder.AddDeveloperSigningCredential();
} }
@ -774,7 +782,7 @@ public static class HostingExtensions
} }
public async static Task<WebApplication> ConfigurePipeline(this WebApplication app) public async static Task<WebApplication> ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath=null)
{ {
ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>(); ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<Program>(); var logger = loggerFactory.CreateLogger<Program>();
@ -815,8 +823,8 @@ public static class HostingExtensions
app.UseIdentityServer(); app.UseIdentityServer();
app.UseAuthorization(); app.UseAuthorization();
app.UseCors("default"); app.UseCors("default");
app.MapStaticAssets();
app.MapDefaultControllerRoute(); app.MapDefaultControllerRoute();
app.MapStaticAssets(staticAssetsManifestPath);
//app.MapRazorPages(); //app.MapRazorPages();
app.MapHub<ChatHub>("/chatHub"); app.MapHub<ChatHub>("/chatHub");