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:
parent
67072b2892
commit
3c135b8e67
11 changed files with 929 additions and 4 deletions
17
src/Bifrost.Web/Configuration/ConceptVoteOptions.cs
Normal file
17
src/Bifrost.Web/Configuration/ConceptVoteOptions.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
src/Bifrost.Web/Domain/ConceptVoteModels.cs
Normal file
50
src/Bifrost.Web/Domain/ConceptVoteModels.cs
Normal 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; }
|
||||
}
|
||||
270
src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs
Normal file
270
src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs
Normal 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);
|
||||
}
|
||||
40
src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs
Normal file
40
src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
28
src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs
Normal file
28
src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
411
tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs
Normal file
411
tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
54
tests/Bifrost.Web.Tests/NetworkPrefixTests.cs
Normal file
54
tests/Bifrost.Web.Tests/NetworkPrefixTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue