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
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue