From aaa7bf827163237349c8117aea4a27aae5f7b443 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 12:20:22 +0000 Subject: [PATCH] Prefix lengths: host-level startup tests in Development, Testing and Production, boundary casts from client addresses Co-Authored-By: Claude Sonnet 5.5 --- .../ConceptVoteRateLimitTests.cs | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index d334541..7510ed8 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -516,6 +516,96 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture(() => factory.CreateClient()); + + Assert.Contains(key, Messages(failure)); + } + + // Boundary lengths start in Development and Testing and a cast from a real client address works. Production is + // not started here: it additionally demands a database, keys path and secrets this factory does not supply. + [Theory] + [InlineData("Development", 0, 0)] + [InlineData("Development", 32, 128)] + [InlineData("Testing", 0, 0)] + [InlineData("Testing", 32, 128)] + public async Task Boundary_prefix_lengths_start_and_a_cast_from_a_client_address_succeeds( + string environment, int v4, int v6) + { + using var factory = WithPrefixes(environment, v4, v6); + var collection = await NewSubject(factory); + + using var v4Cast = await new Client(factory, "203.0.113.200").Post(collection, "Up"); + using var v6Cast = await new Client(factory, "2001:db8:5::1").Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, v4Cast.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, v6Cast.StatusCode); + } + + [Fact] + public async Task A_zero_length_prefix_puts_every_address_of_the_family_in_one_bucket() + { + using var factory = WithPrefixes("Development", 0, 0); + var collection = await NewSubject(factory); + // One device (one cookie, one mint) arriving from changing addresses: only the cast bucket can refuse it. + var device = new Client(factory, "198.51.100.1"); + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", $"198.51.100.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", "192.0.2.77"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + private WebApplicationFactory WithPrefixes(string environment, int v4, int v6) => + _factory.WithWebHostBuilder(b => + { + b.UseEnvironment(environment); + b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:IPv4PrefixLength"] = v4.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["ConceptVotes:IPv6PrefixLength"] = v6.ToString(System.Globalization.CultureInfo.InvariantCulture) + })); + }); + + private static string Messages(Exception failure) + { + var lines = new List(); + for (var e = failure; e is not null; e = e.InnerException!) + { + lines.Add(e.Message); + if (e.InnerException is null) + { + break; + } + } + + return string.Join("\n", lines); + } + private WebApplicationFactory Capped(int maxTrackedKeys) => _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary @@ -531,10 +621,12 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture x.StartsWith(CookieName + "=", StringComparison.Ordinal)) : null; - private async Task NewSubject() + private Task NewSubject() => NewSubject(_factory); + + private static async Task NewSubject(WebApplicationFactory factory) { var collection = "c" + Guid.NewGuid().ToString("N"); - using var scope = _factory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.ConceptSubjects.Add(new ConceptSubject { -- 2.47.3