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:
parent
ecac359344
commit
6aaff74082
13 changed files with 582 additions and 23 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -27,3 +27,6 @@ appsettings-*.*.json
|
||||||
generated/
|
generated/
|
||||||
*.tmp
|
*.tmp
|
||||||
DataDir/
|
DataDir/
|
||||||
|
|
||||||
|
*.tests.trx
|
||||||
|
*.tests.html
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
62
src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs
Normal file
62
src/Yavsc.Org.Tests/StaticAssetsPathsTests.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
Normal file
70
src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
Normal file
37
src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
Normal 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>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
42
src/Yavsc.Org.Tests/test.runsettings
Normal file
42
src/Yavsc.Org.Tests/test.runsettings
Normal 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>
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue