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/
|
||||
*.tmp
|
||||
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
|
||||
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 -->
|
||||
<ItemGroup>
|
||||
<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.Options" 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.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
|
|
@ -121,6 +122,13 @@ namespace Yavsc.Org.Tests
|
|||
{
|
||||
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?>
|
||||
{
|
||||
[$"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<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
|
||||
|
||||
_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<IOptions<SiteSettings>>().Value;
|
||||
|
||||
|
|
@ -194,7 +225,7 @@ namespace Yavsc.Org.Tests
|
|||
|
||||
|
||||
|
||||
_app = await _app.ConfigurePipeline();
|
||||
_app = await _app.ConfigurePipeline(testRuntimeManifest);
|
||||
|
||||
await _app.StartAsync();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<UserSecretsId>78a4efec-68dc-4745-ba06-d8545ef9ee91</UserSecretsId>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<OutputType>exe</OutputType>
|
||||
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
|
|
@ -48,4 +50,25 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
</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>
|
||||
|
|
|
|||
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;
|
||||
|
||||
public ClientController(
|
||||
IHtmlLocalizer<ClientController> localizer
|
||||
)
|
||||
{
|
||||
_localizer = localizer;
|
||||
}
|
||||
// ClientController has a single constructor declared in
|
||||
// ClientController.cs; this partial shares its fields.
|
||||
|
||||
|
||||
[HttpGet]
|
||||
|
|
|
|||
|
|
@ -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<SiteSettings> siteSettingsOptions
|
||||
ClientStore clientStore, IOptions<SiteSettings> siteSettingsOptions,
|
||||
IHtmlLocalizer<ClientController> localizer
|
||||
|
||||
|
||||
)
|
||||
|
|
@ -29,6 +31,7 @@ namespace Yavsc.Controllers
|
|||
this.dbContext = dbContext;
|
||||
this.clientStore = clientStore;
|
||||
this.siteSettings = siteSettingsOptions.Value;
|
||||
_localizer = localizer;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
|
@ -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>();
|
||||
var logger = loggerFactory.CreateLogger<Program>();
|
||||
|
|
@ -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>("/chatHub");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue