Merge hands/mill-votes-cut-2: appraisal votes, voter identity and network tag

Held by concept-mill-votes:verdict:cut-2.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 19:53:50 +02:00
commit 95ac1f84e6
16 changed files with 3643 additions and 15 deletions

View file

@ -0,0 +1,22 @@
namespace Bifrost.Web.Configuration;
public sealed class ConceptVoteOptions
{
public const string SectionName = "ConceptVotes";
// HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter
// secret makes the tag brute-forceable over the IPv4 space if the table leaks.
public const int MinNetworkTagSecretLength = 32;
// Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength
// characters (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);
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddConceptVotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConceptSubjects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Collection = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
RegisteredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RetiredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConceptSubjects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ConceptAppraisals",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubjectId = table.Column<Guid>(type: "uuid", nullable: false),
VoterKey = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Stance = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
VoterClass = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
NetworkTag = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CastAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConceptAppraisals", x => x.Id);
table.ForeignKey(
name: "FK_ConceptAppraisals_ConceptSubjects_SubjectId",
column: x => x.SubjectId,
principalTable: "ConceptSubjects",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ConceptAppraisals_UserAccounts_UserAccountId",
column: x => x.UserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_NetworkTag_CastAtUtc",
table: "ConceptAppraisals",
columns: new[] { "NetworkTag", "CastAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_SubjectId_VoterKey",
table: "ConceptAppraisals",
columns: new[] { "SubjectId", "VoterKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_UserAccountId",
table: "ConceptAppraisals",
column: "UserAccountId");
migrationBuilder.CreateIndex(
name: "IX_ConceptSubjects_Collection_Key",
table: "ConceptSubjects",
columns: new[] { "Collection", "Key" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ConceptAppraisals");
migrationBuilder.DropTable(
name: "ConceptSubjects");
}
}
}

View file

@ -367,15 +367,15 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TargetSurface")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TargetSurfaceName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TargetSurface")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
@ -412,6 +412,88 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("BridgeActions");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CastAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("NetworkTag")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Stance")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<Guid?>("UserAccountId")
.HasColumnType("uuid");
b.Property<string>("VoterClass")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("VoterKey")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("UserAccountId");
b.HasIndex("NetworkTag", "CastAtUtc");
b.HasIndex("SubjectId", "VoterKey")
.IsUnique();
b.ToTable("ConceptAppraisals");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Collection")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("RegisteredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RetiredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.HasKey("Id");
b.HasIndex("Collection", "Key")
.IsUnique();
b.ToTable("ConceptSubjects");
});
modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b =>
{
b.Property<Guid>("Id")
@ -889,6 +971,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
@ -908,9 +993,6 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
@ -922,12 +1004,12 @@ namespace Bifrost.Web.Data.Migrations
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("Provider", "ProviderEventId")
.IsUnique()
.HasFilter("\"ProviderEventId\" <> ''");
b.HasIndex("ProjectId");
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.ToTable("PatronSupportEvents");
@ -1615,6 +1697,22 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b =>
{
b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject")
.WithMany()
.HasForeignKey("SubjectId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Bifrost.Web.Domain.UserAccount", null)
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Subject");
});
modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b =>
{
b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem")

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,271 @@
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. 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",
$"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,45 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
namespace Bifrost.Web.Features.ConceptVotes;
public static class NetworkPrefix
{
public const int IPv4Bits = 32;
public const int IPv6Bits = 128;
public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits;
// 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 (!IsValidLength(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}\z", 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

@ -1,4 +1,5 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
@ -10,7 +11,8 @@ public sealed class StartupConfigurationValidator(
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions)
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions)
{
public void Validate()
{
@ -71,6 +73,24 @@ public sealed class StartupConfigurationValidator(
failures.Add("DataProtection:KeysPath must be configured for production.");
}
var conceptVotes = conceptVoteOptions.Value;
if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) ||
conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength)
{
failures.Add(
$"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production.");
}
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
{
failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}.");
}
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits))
{
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
}
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,600 @@
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.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Theory]
[InlineData("abc\n")]
[InlineData("abc\r\n")]
[InlineData("abc ")]
[InlineData("abc\t")]
[InlineData("abc\0")]
public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged)
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context()
{
var dbName = $"concept-{Guid.NewGuid():N}";
await using var first = new Fixture(dbName);
await first.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up));
var other = IPAddress.Parse("198.51.100.9");
// Production scopes one DbContext per request: the recast gets a context that has never tracked the row.
await using (var second = new Fixture(dbName))
{
second.Clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(
CastResult.Changed,
await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other));
}
await using var reader = new Fixture(dbName);
var row = Assert.Single(reader.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc);
Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]);
}
[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, weight: 5m);
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).ToList().Order().ToArray());
var audit = Assert.Single(f.Db.AuditEvents);
Assert.Equal("ConceptAppraisal", audit.EntityType);
Assert.Equal("Purge", audit.Action);
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);
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("Only a platform administrator can purge appraisals.", refused.Message);
Assert.Equal(2, f.Db.ConceptAppraisals.Count());
Assert.Single(f.Db.AuditEvents);
}
[Fact]
public async Task Unique_violation_with_no_surviving_row_is_not_swallowed()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName) { InsertRival = false };
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
await Assert.ThrowsAsync<DbUpdateException>(
() => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up));
}
[Fact]
public async Task A_changed_cast_restamps_tag_and_time()
{
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, IPAddress.Parse("198.51.100.9"));
var first = f.Db.ConceptAppraisals.Single().CastAtUtc;
f.Clock.Advance(TimeSpan.FromMinutes(5));
await f.Cast("ship-a", voter, ConceptStance.Down);
var row = f.Db.ConceptAppraisals.Single();
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(first.AddMinutes(5), row.CastAtUtc);
}
[Fact]
public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default);
Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag);
var t0 = f.Clock.GetUtcNow();
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default));
Assert.Single(f.Db.ConceptAppraisals);
Assert.Empty(f.Db.AuditEvents);
}
[Fact]
public async Task Purge_window_excludes_its_end_and_includes_its_start()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var t0 = f.Clock.GetUtcNow();
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default));
Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default));
}
[Fact]
public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op()
{
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);
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default));
Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-b");
await f.AddSubject("ship-old", retired: true);
var mine = f.Anonymous("device-one");
await f.Cast("ship-a", mine, ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up);
var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default);
var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default);
var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default);
Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally);
Assert.Equal(ConceptStance.Down, view.YourStance);
Assert.Null(stranger!.YourStance);
Assert.Null(anon!.YourStance);
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default));
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default));
}
[Fact]
public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists()
{
await using var f = new Fixture();
await f.AddSubject("Ship_A");
f.Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = "Bad_Collection",
Key = "ship-a",
Title = "t",
RegisteredAtUtc = f.Clock.GetUtcNow()
});
await f.Db.SaveChangesAsync();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(
CastResult.UnknownSubject,
await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Voter_keys_have_their_documented_shape()
{
await using var f = new Fixture();
var account = f.Account(MembershipStatus.Authenticated);
Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key);
Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key);
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101)));
Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100)));
}
private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor
{
public bool InsertRival { get; init; } = true;
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++;
if (!InsertRival)
{
throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
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, decimal weight = 0m, bool admin = false)
{
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,
IsPlatformAdmin = admin,
CreatedAtUtc = Clock.GetUtcNow()
};
if (weight > 0m)
{
user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true });
}
Db.UserAccounts.Add(user);
Db.SaveChanges();
return new CurrentBifrostActor(true, user);
}
public CurrentBifrostActor Admin()
{
return Account(MembershipStatus.Active, admin: true);
}
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,49 @@ 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);
}
[Theory]
[InlineData("short-secret")]
[InlineData("0123456789012345678901234567890")]
public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", tagSecret: secret));
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
Assert.DoesNotContain(secret, refusal.Message);
Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901");
}
[Theory]
[InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")]
[InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")]
public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6));
Assert.Contains(key, refusal.Message);
Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128);
Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0);
}
[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)
@ -196,16 +236,21 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
}
}
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
private static void Validate(
string? keysPath,
string environment = "Production",
string? connectionString = "Host=db")
string? connectionString = "Host=db",
string? tagSecret = ValidTagSecret,
int ipv4Length = 32,
int ipv6Length = 56)
{
var values = new Dictionary<string, string?>
{
["AllowedHosts"] = "bifrost.example",
["ConnectionStrings:Bifrost"] = connectionString,
["DataProtection:KeysPath"] = keysPath
["DataProtection:KeysPath"] = keysPath,
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
var hostEnvironment = new StubHostEnvironment(environment);
@ -221,6 +266,12 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
{
PublicBaseUrl = "https://bifrost.example",
ExpectedHost = "bifrost.example"
}),
Options.Create(new ConceptVoteOptions
{
NetworkTagSecret = tagSecret ?? string.Empty,
IPv4PrefixLength = ipv4Length,
IPv6PrefixLength = ipv6Length
})).Validate();
}

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);
}
}

View file

@ -20,6 +20,7 @@ public sealed class MigrationDiscoveryTests
"20260622165000_AddBifrostNativeIdentity",
"20260622202000_AddBridgeActionTargetSurfaceName",
"20260705190000_AddPatronSupportProjectAttribution",
"20261010043937_AddConceptVotes",
];
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
@ -49,7 +50,7 @@ public sealed class MigrationDiscoveryTests
}
[Fact]
public void All_twelve_migrations_are_listed_in_order()
public void All_thirteen_migrations_are_listed_in_order()
{
Assert.Equal(ExpectedMigrations, Discovered());
}

View file

@ -0,0 +1,64 @@
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);
Assert.Contains("Prefix length", ex.Message);
}
[Fact]
public void Masking_is_exact_inside_a_byte_and_a_zero_length_is_the_whole_family()
{
Assert.Equal("203.0.112.0/20", NetworkPrefix.Of(IPAddress.Parse("203.0.127.200"), 20, 56));
Assert.Equal("2001:db8:1:f0::/60", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ff::1"), 24, 60));
Assert.Equal("0.0.0.0/0", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 0, 56));
Assert.Equal("203.0.113.7/32", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 32, 56));
}
}