Purge audit: Detail carries window and count, never the network tag the member dashboard would render

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 09:56:08 +02:00
commit 162d7b1f0d
3 changed files with 59 additions and 3 deletions

View file

@ -158,13 +158,14 @@ public sealed class ConceptAppraisalService(
.ToListAsync(cancellationToken);
dbContext.ConceptAppraisals.RemoveRange(rows);
// The audit write saves the removals in the same SaveChanges. Tag, window and count only; no voter keys.
// The audit write saves the removals in the same SaveChanges. The member dashboard renders Detail, so it
// carries the window and count only: no network tag and no voter keys. The actor and window audit the purge.
await auditTrail.RecordAsync(
actor.UserAccount.Id,
nameof(ConceptAppraisal),
Guid.Empty,
"Purge",
$"tag={networkTag} from={fromUtc:O} to={toUtc:O} count={rows.Count}",
$"from={fromUtc:O} to={toUtc:O} count={rows.Count}",
cancellationToken);
return rows.Count;
}

View file

@ -290,7 +290,10 @@ public sealed class ConceptAppraisalServiceTests
var audit = Assert.Single(f.Db.AuditEvents);
Assert.Equal("ConceptAppraisal", audit.EntityType);
Assert.Equal("Purge", audit.Action);
Assert.Contains(tag, audit.Detail);
Assert.DoesNotContain(tag, audit.Detail);
Assert.DoesNotContain("tag", audit.Detail);
Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId);
Assert.Contains("from=" + t0.ToString("O"), audit.Detail);
Assert.Contains("count=1", audit.Detail);
Assert.DoesNotContain("device", audit.Detail);

View file

@ -1,4 +1,9 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
@ -143,4 +148,51 @@ public sealed class MemberConsoleTests : IClassFixture<TestWebApplicationFactory
Assert.Contains("Draft motion implementation PR", html);
Assert.Contains("nibu", html);
}
[Fact]
public async Task Member_console_activity_shows_a_purge_without_its_network_tag()
{
using (var warmupClient = _factory.CreateClient())
{
_ = await warmupClient.GetAsync("/App");
}
string tag;
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var admin = await dbContext.UserAccounts
.Include(x => x.Membership)
.SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN");
var now = DateTimeOffset.UtcNow;
dbContext.ConceptSubjects.Add(new ConceptSubject
{
Collection = "aetheria-concept-mill",
Key = "ship-a",
Title = "ship-a",
RegisteredAtUtc = now
});
await dbContext.SaveChangesAsync();
var service = scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<ConceptVoteOptions>>().Value;
var client = System.Net.IPAddress.Parse("203.0.113.7");
tag = NetworkPrefix.Tag(client, options.IPv4PrefixLength, options.IPv6PrefixLength, options.NetworkTagSecret);
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "member-view-device")!;
await service.CastAsync("aetheria-concept-mill", "ship-a", voter, ConceptStance.Up, client, default);
var removed = await service.PurgeAsync(
new CurrentBifrostActor(true, admin), tag, now.AddHours(-1), now.AddHours(1), default);
Assert.Equal(1, removed);
}
using var memberClient = _factory.CreateClient();
using var response = await memberClient.GetAsync("/App");
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("Purge", html);
Assert.Contains("count", html);
Assert.DoesNotContain(tag, html);
}
}