From cf7401919488bc3af3bc3033c5843a948d02b769 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 20:23:59 +0200 Subject: [PATCH] Mill votes: the very-busy refusal offers sign-in to a voter who is not signed in Accounts have their own table, so signing in works while the cast or mint table is full. A signed-in voter is not told to sign in. Pinned through the full pipeline at a small cap, for both audiences. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 15 +++++++---- .../ConceptVoteRateLimitTests.cs | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index f843f0a..dd87f6d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -82,7 +82,7 @@ public sealed class VoteModel( return NotFound(); } - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled) { return throttled; } @@ -105,14 +105,14 @@ public sealed class VoteModel( return NotFound(); } - if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) + if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled) { return throttled; } if (voter is null) { - if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) + if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled) { return mintThrottled; } @@ -143,14 +143,19 @@ public sealed class VoteModel( private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; - private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal) + // Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full. + private const string BusyRefusalSignIn = + "Voting is very busy right now. You can sign in to vote now, or try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter) { if (denial is not { } refused) { return null; } - var refusal = refused.TableFull ? BusyRefusal : spentRefusal; + var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal; + var refusal = refused.TableFull ? busy : spentRefusal; Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); return new ContentResult { diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 1cedf97..04cdff4 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -381,12 +381,36 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture string.Join(",", h.Value)))); Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); } + [Fact] + public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() {