From 162d7b1f0d9c25818c9e18a9cba647aeb1798fff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:56:08 +0200 Subject: [PATCH] Purge audit: Detail carries window and count, never the network tag the member dashboard would render Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptAppraisalService.cs | 5 +- .../ConceptAppraisalServiceTests.cs | 5 +- tests/Bifrost.Web.Tests/MemberConsoleTests.cs | 52 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 398937a..11893ad 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -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; } diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 83e5df1..51df879 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -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); diff --git a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index a3addd0..e4e1804 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -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(); + 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(); + var options = scope.ServiceProvider.GetRequiredService>().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); + } }