feat(admin): runtime feature flags (catalog-in-code, admin toggle, FE+backend)
Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// WP-47: runtime feature flags — catalog in code, admin-toggled, server-enforced.
|
||||
public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private HttpRequestMessage Admin(HttpMethod method, string path, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path) { Headers = { { "X-Role", "admin" } } };
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_has_unique_keys()
|
||||
{
|
||||
var keys = FeatureFlags.Catalog.Select(f => f.Key).ToList();
|
||||
Assert.Equal(keys.Count, keys.Distinct().Count());
|
||||
Assert.All(FeatureFlags.Catalog, f => Assert.False(string.IsNullOrWhiteSpace(f.Key)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_flags_returns_the_catalog()
|
||||
{
|
||||
var flags = await _client.GetFromJsonAsync<List<FeatureFlagDto>>("/api/v1/flags");
|
||||
Assert.Contains(flags!, f => f.Key == FeatureFlags.InschrijvingOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggling_is_admin_only_and_an_unknown_key_is_404()
|
||||
{
|
||||
// Non-admin (no X-Role → drafter) may not toggle.
|
||||
var denied = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
|
||||
// Admin, unknown flag → 404.
|
||||
var unknown = await _client.SendAsync(Admin(HttpMethod.Put, "/api/v1/admin/flags/does-not-exist", new { enabled = true }));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_inschrijving_blocks_a_registratie_then_reopening_allows_it()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Off → POST /applications for a registratie is refused.
|
||||
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false })))
|
||||
.EnsureSuccessStatusCode();
|
||||
var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode);
|
||||
|
||||
// On → allowed again.
|
||||
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true })))
|
||||
.EnsureSuccessStatusCode();
|
||||
var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
||||
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Leave the flag on (shared DB across this class).
|
||||
await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,9 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||
Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, me!.Capabilities);
|
||||
Assert.Equal(
|
||||
new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" },
|
||||
me!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user