Concept votes: subjects, appraisals, voter identity, network tag and their one writer

ConceptAppraisalService is the only appraisal writer; tallies are derived from
rows. VoterIdentity.For decides voter key and class; NetworkPrefix is the only
prefix computation (HMAC tag). NetworkTagSecret is required in production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 06:31:43 +02:00
commit 3c135b8e67
11 changed files with 929 additions and 4 deletions

View file

@ -0,0 +1,17 @@
namespace Bifrost.Web.Configuration;
public sealed class ConceptVoteOptions
{
public const string SectionName = "ConceptVotes";
// Keys the network tag. Required outside Development and Testing (StartupConfigurationValidator).
public string NetworkTagSecret { get; init; } = string.Empty;
// The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a
// coarser IPv4 partition than the address itself, so the default is the exact address.
public int IPv4PrefixLength { get; init; } = 32;
// /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner
// default weighed in the note, not a standard. Tune on observed abuse.
public int IPv6PrefixLength { get; init; } = 56;
}

View file

@ -63,6 +63,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
public DbSet<ConceptSubject> ConceptSubjects => Set<ConceptSubject>();
public DbSet<ConceptAppraisal> ConceptAppraisals => Set<ConceptAppraisal>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
@ -523,5 +527,31 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.HasForeignKey(x => x.ActorUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ConceptSubject>(entity =>
{
entity.Property(x => x.Collection).HasMaxLength(80);
entity.Property(x => x.Key).HasMaxLength(80);
entity.Property(x => x.Title).HasMaxLength(240);
entity.HasIndex(x => new { x.Collection, x.Key }).IsUnique();
});
modelBuilder.Entity<ConceptAppraisal>(entity =>
{
entity.Property(x => x.VoterKey).HasMaxLength(120);
entity.Property(x => x.NetworkTag).HasMaxLength(64);
entity.Property(x => x.Stance).HasConversion<string>().HasMaxLength(16);
entity.Property(x => x.VoterClass).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique();
entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc });
entity.HasOne(x => x.Subject)
.WithMany()
.HasForeignKey(x => x.SubjectId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<UserAccount>()
.WithMany()
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}

View file

@ -0,0 +1,50 @@
namespace Bifrost.Web.Domain;
public enum ConceptStance
{
Up,
Down
}
public enum VoterClass
{
Anonymous,
Account,
Member
}
public sealed class ConceptSubject
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Collection { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTimeOffset RegisteredAtUtc { get; set; }
public DateTimeOffset? RetiredAtUtc { get; set; }
}
public sealed class ConceptAppraisal
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SubjectId { get; set; }
public string VoterKey { get; set; } = string.Empty;
public ConceptStance Stance { get; set; }
public VoterClass VoterClass { get; set; }
public Guid? UserAccountId { get; set; }
public string NetworkTag { get; set; } = string.Empty;
public DateTimeOffset CastAtUtc { get; set; }
public ConceptSubject? Subject { get; set; }
}

View file

@ -0,0 +1,270 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Bifrost.Web.Features.ConceptVotes;
public enum CastResult
{
Recorded,
Changed,
Unchanged,
UnknownSubject,
RetiredSubject,
NoVoter
}
public sealed record StanceCounts(int Up, int Down);
public sealed record ConceptTally(int Up, int Down, StanceCounts Members);
public sealed record ConceptSubjectView(
string Collection,
string Key,
string Title,
ConceptTally Tally,
ConceptStance? YourStance);
// The only writer of ConceptAppraisal rows. Tallies are derived from rows on every read.
public sealed class ConceptAppraisalService(
BifrostDbContext dbContext,
AuditTrailService auditTrail,
TimeProvider timeProvider,
IOptions<ConceptVoteOptions> options)
{
private const string UniqueViolation = "23505";
private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant);
public async Task<CastResult> CastAsync(
string collection,
string key,
VoterIdentity? voter,
ConceptStance stance,
IPAddress? client,
CancellationToken cancellationToken)
{
if (voter is null)
{
return CastResult.NoVoter;
}
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null)
{
return CastResult.UnknownSubject;
}
if (subject.RetiredAtUtc is not null)
{
return CastResult.RetiredSubject;
}
var opts = options.Value;
var tag = client is null
? string.Empty
: NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (existing is not null)
{
return await ApplyStanceAsync(existing, stance, tag, cancellationToken);
}
var row = new ConceptAppraisal
{
SubjectId = subject.Id,
VoterKey = voter.Key,
Stance = stance,
VoterClass = voter.Class,
UserAccountId = voter.UserAccountId,
NetworkTag = tag,
CastAtUtc = timeProvider.GetUtcNow()
};
dbContext.ConceptAppraisals.Add(row);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return CastResult.Recorded;
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation })
{
// A concurrent cast by the same voter won the insert: apply this stance to their row, once.
dbContext.Entry(row).State = EntityState.Detached;
var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (winner is null)
{
throw;
}
return await ApplyStanceAsync(winner, stance, tag, cancellationToken);
}
}
public async Task<bool> RetractAsync(
string collection,
string key,
VoterIdentity? voter,
CancellationToken cancellationToken)
{
if (voter is null)
{
return false;
}
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null)
{
return false;
}
var row = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (row is null)
{
return false;
}
dbContext.ConceptAppraisals.Remove(row);
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<int> PurgeAsync(
CurrentBifrostActor actor,
string networkTag,
DateTimeOffset fromUtc,
DateTimeOffset toUtc,
CancellationToken cancellationToken)
{
if (!actor.CanManageMembership || actor.UserAccount is null)
{
throw new UnauthorizedAccessException("Only a platform administrator can purge appraisals.");
}
if (string.IsNullOrEmpty(networkTag))
{
return 0;
}
var rows = await dbContext.ConceptAppraisals
.Where(x => x.NetworkTag == networkTag && x.CastAtUtc >= fromUtc && x.CastAtUtc < toUtc)
.ToListAsync(cancellationToken);
dbContext.ConceptAppraisals.RemoveRange(rows);
// The audit write saves the removals in the same SaveChanges. Tag, window and count only; no voter keys.
await auditTrail.RecordAsync(
actor.UserAccount.Id,
nameof(ConceptAppraisal),
Guid.Empty,
"Purge",
$"tag={networkTag} from={fromUtc:O} to={toUtc:O} count={rows.Count}",
cancellationToken);
return rows.Count;
}
public async Task<IReadOnlyDictionary<string, ConceptTally>> GetTalliesAsync(
string collection,
CancellationToken cancellationToken)
{
var live = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
.Select(x => new { x.Id, x.Key })
.ToListAsync(cancellationToken);
var ids = live.Select(x => x.Id).ToList();
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId))
.GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
return live.ToDictionary(
s => s.Key,
s => Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count))),
StringComparer.Ordinal);
}
public async Task<ConceptSubjectView?> GetSubjectAsync(
string collection,
string key,
VoterIdentity? voter,
CancellationToken cancellationToken)
{
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null || subject.RetiredAtUtc is not null)
{
return null;
}
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => x.SubjectId == subject.Id)
.GroupBy(x => new { x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
var yours = voter is null
? null
: (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken))?.Stance;
return new ConceptSubjectView(
subject.Collection,
subject.Key,
subject.Title,
Tally(groups.Select(g => (g.Stance, g.IsMember, g.Count))),
yours);
}
private static ConceptTally Tally(IEnumerable<(ConceptStance Stance, bool IsMember, int Count)> groups)
{
int up = 0, down = 0, memberUp = 0, memberDown = 0;
foreach (var (stance, isMember, count) in groups)
{
if (stance == ConceptStance.Up)
{
up += count;
memberUp += isMember ? count : 0;
}
else
{
down += count;
memberDown += isMember ? count : 0;
}
}
return new ConceptTally(up, down, new StanceCounts(memberUp, memberDown));
}
private async Task<CastResult> ApplyStanceAsync(
ConceptAppraisal row,
ConceptStance stance,
string tag,
CancellationToken cancellationToken)
{
if (row.Stance == stance)
{
return CastResult.Unchanged;
}
row.Stance = stance;
row.NetworkTag = tag;
row.CastAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
return CastResult.Changed;
}
private Task<ConceptSubject?> FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) =>
Slug.IsMatch(collection) && Slug.IsMatch(key)
? dbContext.ConceptSubjects
.SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken)
: Task.FromResult<ConceptSubject?>(null);
private Task<ConceptAppraisal?> FindAppraisalAsync(Guid subjectId, string voterKey, CancellationToken cancellationToken) =>
dbContext.ConceptAppraisals
.SingleOrDefaultAsync(x => x.SubjectId == subjectId && x.VoterKey == voterKey, cancellationToken);
}

View file

@ -0,0 +1,40 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
namespace Bifrost.Web.Features.ConceptVotes;
public static class NetworkPrefix
{
// The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text.
public static string Of(IPAddress address, int ipv4Length, int ipv6Length)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
var bytes = address.GetAddressBytes();
var length = bytes.Length == 4 ? ipv4Length : ipv6Length;
if (length < 0 || length > bytes.Length * 8)
{
throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family.");
}
for (var i = 0; i < bytes.Length; i++)
{
var kept = Math.Clamp(length - i * 8, 0, 8);
bytes[i] &= (byte)(0xFF << (8 - kept));
}
return new IPAddress(bytes) + "/" + length;
}
// Lowercase hex HMAC-SHA256 over the prefix text. Never reversible to an address; audit and purge only.
public static string Tag(IPAddress address, int ipv4Length, int ipv6Length, string secret)
{
var prefix = Of(address, ipv4Length, ipv6Length);
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(prefix));
return Convert.ToHexString(mac).ToLowerInvariant();
}
}

View file

@ -0,0 +1,28 @@
using System.Text.RegularExpressions;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Features.ConceptVotes;
public sealed record VoterIdentity(string Key, VoterClass Class, Guid? UserAccountId)
{
public const int MaxKeyLength = 120;
private static readonly Regex DeviceIdShape = new("^[A-Za-z0-9_-]{1,100}$", RegexOptions.CultureInvariant);
// The one place a voter key and class are decided. Never reads vote weight.
public static VoterIdentity? For(CurrentBifrostActor actor, string? deviceId)
{
if (actor.IsAuthenticated && actor.UserAccount is { } account)
{
return new VoterIdentity(
"account:" + account.Id.ToString("N"),
actor.IsActiveMember ? VoterClass.Member : VoterClass.Account,
account.Id);
}
return deviceId is not null && DeviceIdShape.IsMatch(deviceId)
? new VoterIdentity("device:" + deviceId, VoterClass.Anonymous, null)
: null;
}
}

View file

@ -71,6 +71,11 @@ public sealed class StartupConfigurationValidator(
failures.Add("DataProtection:KeysPath must be configured for production.");
}
if (string.IsNullOrWhiteSpace(configuration["ConceptVotes:NetworkTagSecret"]))
{
failures.Add("ConceptVotes:NetworkTagSecret must be configured for production.");
}
if (failures.Count > 0)
{
throw new InvalidOperationException(

View file

@ -6,6 +6,7 @@ using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
@ -53,6 +54,10 @@ builder.Services
.AddOptions<BridgeOptions>()
.BindConfiguration(BridgeOptions.SectionName);
builder.Services
.AddOptions<ConceptVoteOptions>()
.BindConfiguration(ConceptVoteOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
@ -97,6 +102,7 @@ builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();

View file

@ -0,0 +1,411 @@
using System.Net;
using System.Reflection;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Bifrost.Web.Tests;
public sealed class ConceptAppraisalServiceTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7");
[Fact]
public async Task Anonymous_device_voter_can_cast_and_is_counted()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
Assert.Equal(CastResult.Recorded, result);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally);
}
[Fact]
public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon"));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Voter_class_is_stamped_at_cast()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass);
Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId);
var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id);
var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id);
Assert.Equal(VoterClass.Account, accountRow.VoterClass);
Assert.Equal(VoterClass.Member, memberRow.VoterClass);
// Membership ends; the same account votes again with the opposite stance. The row keeps its stamp.
member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated;
var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(CastResult.Changed, recast);
Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass);
}
[Fact]
public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up));
Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down));
Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down));
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public async Task Member_weight_does_not_weight_counts()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var member = f.Account(MembershipStatus.Active);
member.UserAccount!.Membership!.TierSnapshots.Add(new TierSnapshot { Weight = 5m, IsCurrent = true });
Assert.Equal(5m, member.EffectiveVotingWeight);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(1, tally.Up);
Assert.Equal(1, tally.Members.Up);
}
[Fact]
public async Task Member_tally_is_a_filter_over_the_same_rows()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down);
Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally);
}
[Fact]
public async Task Tally_carries_no_voter_identity()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(
new[] { "Down", "Members", "Up" },
typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
Assert.Equal(
new[] { "Down", "Up" },
typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default));
var view = JsonSerializer.Serialize(
await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default));
foreach (var text in new[] { json, view })
{
Assert.DoesNotContain("device:", text);
Assert.DoesNotContain("account:", text);
Assert.DoesNotContain("device-secret-id", text);
foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id })
{
Assert.DoesNotContain(id.ToString("D"), text);
Assert.DoesNotContain(id.ToString("N"), text);
}
foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct())
{
Assert.DoesNotContain(tag, text);
}
}
}
[Fact]
public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-old", retired: true);
var subjectsBefore = f.Db.ConceptSubjects.Count();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up));
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up));
Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count());
Assert.Empty(f.Db.ConceptAppraisals);
Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
}
[Fact]
public async Task Retract_removes_the_row_and_the_count()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up);
Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up);
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Network_tag_is_an_hmac_never_a_raw_address()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = f.Db.ConceptAppraisals.Single().NetworkTag;
Assert.Matches("^[0-9a-f]{64}$", tag);
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag);
}
[Fact]
public async Task Unique_violation_at_insert_becomes_a_change()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName);
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down);
Assert.Equal(CastResult.Changed, result);
Assert.Equal(1, racer.Fired);
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public void Unique_indexes_are_declared_in_model_and_migration()
{
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseNpgsql("Host=unused").Options;
using var db = new BifrostDbContext(options);
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"]));
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"]));
var script = db.Database.GenerateCreateScript();
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script);
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script);
}
[Fact]
public async Task Purge_removes_only_rows_in_tag_and_window_and_audits()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var admin = f.Admin();
var t0 = f.Clock.GetUtcNow();
var other = IPAddress.Parse("198.51.100.9");
await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other);
f.Clock.Advance(TimeSpan.FromHours(2));
await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default);
Assert.Equal(1, removed);
Assert.Equal(
["device:device-late", "device:device-other-net"],
f.Db.ConceptAppraisals.Select(x => x.VoterKey).Order().ToArray());
var audit = Assert.Single(f.Db.AuditEvents);
Assert.Equal("ConceptAppraisal", audit.EntityType);
Assert.Equal("Purge", audit.Action);
Assert.Contains(tag, audit.Detail);
Assert.Contains("count=1", audit.Detail);
Assert.DoesNotContain("device", audit.Detail);
var refused = await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default));
Assert.DoesNotContain(tag, refused.Message);
Assert.Equal(2, f.Db.ConceptAppraisals.Count());
Assert.Single(f.Db.AuditEvents);
}
private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor
{
public ConceptSubject? Subject { get; set; }
public int Fired { get; private set; }
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var adding = eventData.Context!.ChangeTracker.Entries<ConceptAppraisal>()
.Any(e => e.State == EntityState.Added);
if (Subject is null || !adding || Fired > 0)
{
return result;
}
Fired++;
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(dbName).Options;
await using (var other = new BifrostDbContext(options))
{
other.ConceptAppraisals.Add(new ConceptAppraisal
{
SubjectId = Subject.Id,
VoterKey = "device:device-one",
Stance = ConceptStance.Up,
VoterClass = VoterClass.Anonymous,
CastAtUtc = DateTimeOffset.UtcNow
});
await other.SaveChangesAsync(cancellationToken);
}
throw new DbUpdateException(
"conflict",
new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
}
private sealed class Fixture : IAsyncDisposable
{
public const string Secret = "test-tag-secret";
public Fixture(string? dbName = null, IInterceptor? interceptor = null)
{
var builder = new DbContextOptionsBuilder<BifrostDbContext>()
.UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}");
if (interceptor is not null)
{
builder.AddInterceptors(interceptor);
}
Db = new BifrostDbContext(builder.Options);
Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
Service = new ConceptAppraisalService(
Db,
new AuditTrailService(Db, Clock),
Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret }));
}
public BifrostDbContext Db { get; }
public ManualClock Clock { get; }
public ConceptAppraisalService Service { get; }
public async Task AddSubject(string key, bool retired = false)
{
Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = Collection,
Key = key,
Title = key,
RegisteredAtUtc = Clock.GetUtcNow(),
RetiredAtUtc = retired ? Clock.GetUtcNow() : null
});
await Db.SaveChangesAsync();
}
public Task<CastResult> Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) =>
Service.CastAsync(Collection, key, voter, stance, client ?? Client, default);
public VoterIdentity Anonymous(string deviceId) =>
VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!;
public CurrentBifrostActor Account(MembershipStatus status)
{
var user = new UserAccount
{
DisplayName = "voter",
GitHubLogin = "voter",
NormalizedGitHubLogin = "voter",
CreatedAtUtc = Clock.GetUtcNow(),
LastSeenAtUtc = Clock.GetUtcNow()
};
user.Membership = new Membership
{
UserAccountId = user.Id,
Status = status,
CreatedAtUtc = Clock.GetUtcNow()
};
Db.UserAccounts.Add(user);
Db.SaveChanges();
return new CurrentBifrostActor(true, user);
}
public CurrentBifrostActor Admin()
{
var actor = Account(MembershipStatus.Active);
actor.UserAccount!.Membership!.IsPlatformAdmin = true;
return actor;
}
public ValueTask DisposeAsync() => Db.DisposeAsync();
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}

View file

@ -146,9 +146,21 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
}
[Fact]
public void Development_does_not_require_keys_path()
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
{
Validate(keysPath: null, environment: Environments.Development);
var canary = "canary-keys-path-77aa";
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: canary, tagSecret: " "));
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
Assert.DoesNotContain(canary, refusal.Message);
Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message);
}
[Fact]
public void Development_does_not_require_keys_path_or_tag_secret()
{
Validate(keysPath: null, environment: Environments.Development, tagSecret: null);
}
private async Task<HttpContext> RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto)
@ -199,13 +211,15 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
private static void Validate(
string? keysPath,
string environment = "Production",
string? connectionString = "Host=db")
string? connectionString = "Host=db",
string? tagSecret = "tag-secret")
{
var values = new Dictionary<string, string?>
{
["AllowedHosts"] = "bifrost.example",
["ConnectionStrings:Bifrost"] = connectionString,
["DataProtection:KeysPath"] = keysPath
["DataProtection:KeysPath"] = keysPath,
["ConceptVotes:NetworkTagSecret"] = tagSecret
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
var hostEnvironment = new StubHostEnvironment(environment);

View file

@ -0,0 +1,54 @@
using System.Net;
using Bifrost.Web.Features.ConceptVotes;
namespace Bifrost.Web.Tests;
public sealed class NetworkPrefixTests
{
private const string Secret = "prefix-secret";
private static string Tag(string address, int v4 = 24, int v6 = 56, string secret = Secret) =>
NetworkPrefix.Tag(IPAddress.Parse(address), v4, v6, secret);
[Fact]
public void Two_addresses_inside_one_prefix_share_a_tag_and_other_prefixes_do_not()
{
Assert.Equal(Tag("203.0.113.7"), Tag("203.0.113.200"));
Assert.NotEqual(Tag("203.0.113.7"), Tag("203.0.114.7"));
Assert.Equal(Tag("2001:db8:1:ff::1"), Tag("2001:db8:1:ff:aaaa::9"));
Assert.NotEqual(Tag("2001:db8:1:ff::1"), Tag("2001:db8:2:ff::1"));
Assert.Equal("203.0.113.0/24", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 24, 56));
Assert.Equal("2001:db8:1:ff00::/56", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ffee::1"), 24, 56));
}
[Fact]
public void Prefix_length_is_the_configured_one()
{
Assert.NotEqual(Tag("203.0.113.7", v4: 32), Tag("203.0.113.8", v4: 32));
Assert.Equal(Tag("2001:db8:1:ff::1", v6: 48), Tag("2001:db8:1:aa::1", v6: 48));
}
[Fact]
public void IPv4_mapped_IPv6_equals_the_IPv4_address()
{
Assert.Equal(Tag("203.0.113.7"), Tag("::ffff:203.0.113.7"));
}
[Fact]
public void A_different_secret_gives_a_different_tag_and_the_tag_is_lowercase_hex()
{
var tag = Tag("203.0.113.7");
Assert.NotEqual(tag, Tag("203.0.113.7", secret: "other-secret"));
Assert.Matches("^[0-9a-f]{64}$", tag);
}
[Fact]
public void A_length_outside_the_family_is_refused_without_echoing_the_address()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(
() => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56));
Assert.DoesNotContain("203.0.113.7", ex.Message);
}
}