diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs new file mode 100644 index 0000000..2b73824 --- /dev/null +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -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; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 290aa08..9863cfd 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -63,6 +63,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet AuditEvents => Set(); + public DbSet ConceptSubjects => Set(); + + public DbSet ConceptAppraisals => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => @@ -523,5 +527,31 @@ public sealed class BifrostDbContext(DbContextOptions options) .HasForeignKey(x => x.ActorUserAccountId) .OnDelete(DeleteBehavior.SetNull); }); + + modelBuilder.Entity(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(entity => + { + entity.Property(x => x.VoterKey).HasMaxLength(120); + entity.Property(x => x.NetworkTag).HasMaxLength(64); + entity.Property(x => x.Stance).HasConversion().HasMaxLength(16); + entity.Property(x => x.VoterClass).HasConversion().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() + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/src/Bifrost.Web/Domain/ConceptVoteModels.cs b/src/Bifrost.Web/Domain/ConceptVoteModels.cs new file mode 100644 index 0000000..b86e051 --- /dev/null +++ b/src/Bifrost.Web/Domain/ConceptVoteModels.cs @@ -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; } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs new file mode 100644 index 0000000..398937a --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -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 options) +{ + private const string UniqueViolation = "23505"; + + private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant); + + public async Task 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 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 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> 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 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 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 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(null); + + private Task FindAppraisalAsync(Guid subjectId, string voterKey, CancellationToken cancellationToken) => + dbContext.ConceptAppraisals + .SingleOrDefaultAsync(x => x.SubjectId == subjectId && x.VoterKey == voterKey, cancellationToken); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs new file mode 100644 index 0000000..3c88f47 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs @@ -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(); + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs new file mode 100644 index 0000000..5d60944 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs @@ -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; + } +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 0ba0e10..84bcda7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -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( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 218a7ff..bec0271 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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() .BindConfiguration(BridgeOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(ConceptVoteOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -97,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs new file mode 100644 index 0000000..9a667bd --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -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().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( + () => 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> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + var adding = eventData.Context!.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added); + if (Subject is null || !adding || Fired > 0) + { + return result; + } + + Fired++; + var options = new DbContextOptionsBuilder().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() + .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 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; + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 78f9057..7824f49 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -146,9 +146,21 @@ public sealed class HostTrustTests : IClassFixture } [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( + () => 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 RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto) @@ -199,13 +211,15 @@ public sealed class HostTrustTests : IClassFixture 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 { ["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); diff --git a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs new file mode 100644 index 0000000..8e0dc6c --- /dev/null +++ b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs @@ -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( + () => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56)); + + Assert.DoesNotContain("203.0.113.7", ex.Message); + } +}