Sealed ballot: race-safe close, close sweep, endpoint tests, backup reader and auth findings in the doc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5a71eb639
commit
96da1b7a8c
7 changed files with 310 additions and 9 deletions
|
|
@ -12,8 +12,8 @@ publication at close, no mediator. Code: `Features/Motions/SealedBallotService.c
|
|||
3. **Reveal** from the commit deadline until the reveal deadline: the voter posts the ballot document. Bifrost recomputes the
|
||||
hash and stores the content only if it matches the commitment. Revealed content is not readable through any endpoint,
|
||||
page, audit event or export until close.
|
||||
4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline writes status `Closed` and a
|
||||
digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at
|
||||
4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline, or the once-a-minute
|
||||
`SealedBallotCloser` sweep if nobody reads, writes status `Closed` and a digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at
|
||||
once. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie.
|
||||
|
||||
Canonical document (`gamecult.sealed_ballot.v0`): `{schema, motionId, round, candidate, reason, salt}`. Commitment =
|
||||
|
|
@ -48,8 +48,13 @@ holding that Mind's plaintext from cast, which is the gap commit-reveal removes.
|
|||
- **Commit window:** nobody. The store holds hashes; the ballot exists only with its voter. Participation (who has committed,
|
||||
not what) is visible in the audit trail and to the voter's own receipt.
|
||||
- **Reveal window:** the **host and anyone with database access can read revealed ballots** that voters have already submitted.
|
||||
Application code does not expose them (enforced by tests below), but a database reader sees columns `RevealedCandidate`,
|
||||
`RevealedReason`, `Salt`. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot
|
||||
Application code does not expose them (enforced by tests), but a database reader sees columns `RevealedCandidate`,
|
||||
`RevealedReason`, `Salt`. **Backups are another reader:** any `pg_dump` taken in the reveal window contains the reveals
|
||||
already submitted. What this repo says about backups: `docs/deploy-main-to-live.md` (Precondition 2) takes a `pg_dump -Fc`
|
||||
inside a deploy window as in the gamecult-ops runbook section 8, and `docs/full-implementation-strategy.md` lists a
|
||||
backup/restore runbook as planned. This repo does not state a nightly schedule, where dumps are stored or who can read
|
||||
them; if a nightly dump exists it is defined in gamecult-ops, which I have not read, so I do not claim it or its reach.
|
||||
Dumps taken in the commit window hold hashes only. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot
|
||||
after seeing another, and the commitments published during the commit window let anyone detect an altered reveal. It is not
|
||||
closed. Closing it fully would need reveals encrypted to a threshold or time-lock key, which this change does not build.
|
||||
- A Mind that reveals late in the window exposes its ballot to the host for less time. A voter who never reveals exposes
|
||||
|
|
@ -61,3 +66,30 @@ holding that Mind's plaintext from cast, which is the gap commit-reveal removes.
|
|||
|
||||
Ballots go to Bifrost as typed documents over the endpoints above, never as room text (chat history is readable by the host
|
||||
and admins before close). A pinned motion announces only the `announcement` line: phase and times.
|
||||
|
||||
## Close is race-safe
|
||||
|
||||
`Motion.Status` is an EF concurrency token. Racing closers each compute the same result (it is a pure function of frozen
|
||||
data) but only one `SaveChanges` succeeds; the loser reloads the winner's row and writes no second `sealed.closed` audit
|
||||
event. Tests: a stale-copy conflict and four concurrent first reads each yield exactly one close; with the token removed the
|
||||
conflict test fails. In Postgres the token becomes `WHERE "Status" = <old>` on the update. Not exercised against Postgres.
|
||||
|
||||
## Can a Mind hold the member session these endpoints need?
|
||||
|
||||
Only through the existing member cookie, and awkwardly. Read from the code:
|
||||
|
||||
- Bifrost authenticates with one cookie scheme plus GitHub OAuth (`Program.cs:176`). There is no bearer or API-key scheme;
|
||||
the endpoints use the same `ActiveMember` policy as the console (`Program.cs:236`, `ActiveMemberAuthorizationHandler.cs:22`),
|
||||
which maps claims to an account by Bifrost identity, Heimdall account id or GitHub id (`CurrentBifrostActorAccessor.cs:33-38`).
|
||||
- A non-browser client can obtain a cookie: `POST /auth/bifrost/register` is anonymous and signs a cookie in
|
||||
(`Program.cs:358`), creating an account in status `Authenticated` (`BifrostIdentityService.cs:49`), not `Active`. It becomes
|
||||
able to vote only after membership approval.
|
||||
- The same route cannot sign an existing identity back in (`BifrostIdentityService.cs:26` refuses a registered identity), so a
|
||||
Mind that loses its cookie jar cannot re-authenticate natively. The other sign-in routes (`/auth/sign-in`,
|
||||
`/auth/heimdall/{provider}`, `Program.cs:390`) are browser redirect flows.
|
||||
- The bridge token (`BridgeOptions.cs:7`) is read by no server code path (the only matches in `src` are its own definition),
|
||||
so it opens nothing. `tools/*.mjs` bearer headers go to Reddit, Matrix and image hosts, not to Bifrost.
|
||||
|
||||
Answer: not cleanly. A Mind can hold a session if a human registers and approves it and the Mind keeps one cookie jar alive;
|
||||
Bifrost has no durable non-browser credential for a Mind. I invented no auth path. Whether to give Minds one (a Persona-signed
|
||||
or token intake like `tools/bifrost-signed-identity.mjs`) is a ruling for the stewards, not this PR.
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
|
||||
entity.Property(x => x.Kind).HasConversion<string>();
|
||||
entity.Property(x => x.Status).IsConcurrencyToken();
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.Motions)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
|
|
|
|||
|
|
@ -941,6 +941,7 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ using Bifrost.Web.Data;
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bifrost.Web.Features.Motions;
|
||||
|
||||
|
|
@ -244,6 +247,10 @@ public sealed class SealedBallotService(
|
|||
: now >= motion.CommitClosesAtUtc ? SealedPhase.Reveal
|
||||
: SealedPhase.Commit;
|
||||
|
||||
/// <summary>
|
||||
/// Closes the motion once. Motion.Status is a concurrency token, so of two racing closers exactly one SaveChanges wins;
|
||||
/// the loser reloads the winner's result and writes nothing (no second audit event).
|
||||
/// </summary>
|
||||
private async Task FinalizeAsync(Motion motion, List<SealedBallot> ballots, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var lines = ballots.OrderBy(x => x.UserAccountId)
|
||||
|
|
@ -252,13 +259,42 @@ public sealed class SealedBallotService(
|
|||
motion.Status = MotionStatus.Closed;
|
||||
motion.ResolvedAtUtc = now;
|
||||
motion.ResolutionNote = $"Sealed ballots published: {ballots.Count(x => x.RevealedAtUtc != null)} revealed, {ballots.Count(x => x.RevealedAtUtc == null)} committed but unrevealed.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException error)
|
||||
{
|
||||
foreach (var entry in error.Entries)
|
||||
{
|
||||
await entry.ReloadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
null, nameof(Motion), motion.Id, "sealed.closed",
|
||||
$"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes.</summary>
|
||||
public async Task<int> FinalizeDueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var due = await dbContext.Motions
|
||||
.Where(x => x.Kind == MotionKind.SealedBallot && x.Status == MotionStatus.Open && x.ClosesAtUtc <= now)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var motion in due)
|
||||
{
|
||||
var ballots = await dbContext.SealedBallots.Where(x => x.MotionId == motion.Id).ToListAsync(cancellationToken);
|
||||
await FinalizeAsync(motion, ballots, now, cancellationToken);
|
||||
}
|
||||
|
||||
return due.Count;
|
||||
}
|
||||
|
||||
private SealedMotionState BuildState(Motion motion, DateTimeOffset now, SealedBallotReceipt? own, Guid voterId)
|
||||
{
|
||||
var phase = PhaseOf(motion, now);
|
||||
|
|
@ -337,3 +373,25 @@ public sealed record SealedMotionResult(
|
|||
IReadOnlyList<RevealedBallot> Ballots, IReadOnlyList<AbstainedMember> Unrevealed, IReadOnlyList<AbstainedMember> NonVoters,
|
||||
string CommitmentDigest);
|
||||
public sealed record SealedCommitRequest(string Commitment);
|
||||
|
||||
/// <summary>Sweeps due sealed-ballot motions once a minute. Reads also close on demand; this covers a motion nobody reads.</summary>
|
||||
public sealed class SealedBallotCloser(IServiceScopeFactory scopes, ILogger<SealedBallotCloser> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<SealedBallotService>().FinalizeDueAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(error, "Sealed-ballot close sweep failed; retrying next tick.");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ builder.Services.AddScoped<BifrostIdentityService>();
|
|||
builder.Services.AddScoped<GitHubWebhookService>();
|
||||
builder.Services.AddScoped<MotionGovernanceService>();
|
||||
builder.Services.AddScoped<SealedBallotService>();
|
||||
builder.Services.AddHostedService<SealedBallotCloser>();
|
||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
|
|
@ -547,16 +548,17 @@ eveGovernance.MapGet("/surface", async (
|
|||
var sealedMotions = app.MapGroup("/api/sealed-motions")
|
||||
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
||||
|
||||
static async Task<IResult> SealedCall(Func<Task<object>> call)
|
||||
async Task<IResult> SealedCall(Func<Task<object>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Ok(await call());
|
||||
return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json");
|
||||
}
|
||||
catch (SealedBallotException error)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = error.Message },
|
||||
return Results.Text(
|
||||
JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions),
|
||||
"application/json",
|
||||
statusCode: error.Failure switch
|
||||
{
|
||||
SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden,
|
||||
|
|
|
|||
152
tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs
Normal file
152
tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Motions;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class SealedBallotEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string Salt = "00112233445566778899aabbccddeeff";
|
||||
private const string Reason = "Distinctive-endpoint-reason-plugh.";
|
||||
|
||||
private static int _next = 1000;
|
||||
|
||||
private sealed record Seeded(Guid RevealMotion, Guid CommitMotion, Guid ClosedMotion, Guid VoterId, string Ana, string Bo, string Outsider);
|
||||
|
||||
private async Task<Seeded> SeedAsync()
|
||||
{
|
||||
using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); }
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var n = Interlocked.Add(ref _next, 10);
|
||||
UserAccount Account(long gh, string name, MembershipStatus status)
|
||||
{
|
||||
var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name };
|
||||
u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now };
|
||||
u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = status, CreatedAtUtc = now };
|
||||
if (status == MembershipStatus.Active)
|
||||
{
|
||||
Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed");
|
||||
}
|
||||
|
||||
db.UserAccounts.Add(u);
|
||||
return u;
|
||||
}
|
||||
|
||||
var ana = Account(n + 1, $"ana{n}", MembershipStatus.Active);
|
||||
Account(n + 2, $"bo{n}", MembershipStatus.Active);
|
||||
Account(n + 3, $"outsider{n}", MembershipStatus.Authenticated);
|
||||
Motion Make(DateTimeOffset commitCloses, DateTimeOffset closes)
|
||||
{
|
||||
var m = new Motion
|
||||
{
|
||||
CreatedByUserAccountId = ana.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s",
|
||||
Candidates = "Ana\nBo", ApprovalThreshold = 0m, OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses,
|
||||
ClosesAtUtc = closes, CreatedAtUtc = now.AddHours(-3)
|
||||
};
|
||||
db.Motions.Add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
var reveal = Make(now.AddHours(-1), now.AddHours(1));
|
||||
var commit = Make(now.AddHours(1), now.AddHours(2));
|
||||
var closed = Make(now.AddHours(-2), now.AddHours(-1));
|
||||
db.SealedBallots.Add(new SealedBallot
|
||||
{
|
||||
MotionId = reveal.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2),
|
||||
Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, reveal.Id, 1, "Bo", Reason, Salt), ana.Id)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new Seeded(reveal.Id, commit.Id, closed.Id, ana.Id, (n + 1).ToString(), (n + 2).ToString(), (n + 3).ToString());
|
||||
}
|
||||
|
||||
private HttpClient As(string ghId, string login, bool anonymous = false)
|
||||
{
|
||||
var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
if (anonymous) c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true");
|
||||
else { c.DefaultRequestHeaders.Add("X-Test-GitHub-Id", ghId); c.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login); }
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthenticated_and_non_member_callers_are_refused_on_every_sealed_endpoint()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
foreach (var client in new[] { As("", "", anonymous: true), As(s.Outsider, "outsider") })
|
||||
{
|
||||
var id = s.RevealMotion;
|
||||
var responses = new[]
|
||||
{
|
||||
await client.GetAsync($"/api/sealed-motions/{id}"),
|
||||
await client.GetAsync($"/api/sealed-motions/{id}/result"),
|
||||
await client.PostAsJsonAsync($"/api/sealed-motions/{id}/commit", new { commitment = new string('a', 64) }),
|
||||
await client.PostAsJsonAsync($"/api/sealed-motions/{id}/reveal", new SealedBallotDocument(SealedBallotDocument.SchemaId, id, 1, "Bo", Reason, Salt))
|
||||
};
|
||||
foreach (var r in responses)
|
||||
{
|
||||
Assert.Contains(r.StatusCode, new[] { HttpStatusCode.Redirect, HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized });
|
||||
Assert.DoesNotContain(Reason, await r.Content.ReadAsStringAsync());
|
||||
}
|
||||
}
|
||||
|
||||
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.All(db.SealedBallots.Where(x => x.MotionId == s.RevealMotion), b => Assert.Null(b.RevealedReason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_commits_in_the_commit_window_and_reveals_in_the_reveal_window_without_any_response_leaking_it()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var ana = As(s.Ana, "ana");
|
||||
var bo = As(s.Bo, "bo");
|
||||
|
||||
var commit = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/commit", new { commitment = new string('a', 64) });
|
||||
Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/reveal",
|
||||
new SealedBallotDocument(SealedBallotDocument.SchemaId, s.CommitMotion, 1, "Bo", Reason, Salt))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/commit",
|
||||
new { commitment = new string('b', 64) })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/commit", new { commitment = "nope" })).StatusCode);
|
||||
|
||||
var wrong = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/reveal",
|
||||
new SealedBallotDocument(SealedBallotDocument.SchemaId, s.RevealMotion, 1, "Ana", Reason, Salt));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
|
||||
var ok = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/reveal",
|
||||
new SealedBallotDocument(SealedBallotDocument.SchemaId, s.RevealMotion, 1, "Bo", Reason, Salt));
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
|
||||
// After the reveal is stored, no sealed endpoint answers any member with the ballot.
|
||||
foreach (var client in new[] { ana, bo })
|
||||
{
|
||||
foreach (var response in new[]
|
||||
{
|
||||
await client.GetAsync($"/api/sealed-motions/{s.RevealMotion}"),
|
||||
await client.GetAsync($"/api/sealed-motions/{s.RevealMotion}/result"),
|
||||
ok
|
||||
})
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain(Reason, body);
|
||||
Assert.DoesNotContain(Salt, body);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await bo.GetAsync($"/api/sealed-motions/{s.RevealMotion}/result")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_motion_past_its_deadline_closes_on_the_first_read_and_publishes()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var response = await As(s.Bo, "bo").GetAsync($"/api/sealed-motions/{s.ClosedMotion}/result");
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
|
||||
Assert.Contains("no ballots", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
}
|
||||
|
|
@ -213,4 +213,59 @@ public sealed class SealedBallotTests
|
|||
Assert.Empty(rig.Db.Votes);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => standard.CloseMotionAsync(rig.Moderator, motion.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Racing_closers_close_once_and_the_loser_adopts_the_winners_result()
|
||||
{
|
||||
var clock = new Clock(T0);
|
||||
var name = $"sealed-race-{Guid.NewGuid():N}";
|
||||
BifrostDbContext NewDb() => new(new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(name).Options);
|
||||
SealedBallotService NewService(BifrostDbContext db) => new(db, new AuditTrailService(db, clock), clock);
|
||||
|
||||
await using var seed = NewDb();
|
||||
var user = new UserAccount { DisplayName = "Ana", GitHubLogin = "ana", BifrostIdentity = "ana", NormalizedBifrostIdentity = "ANA", NormalizedGitHubLogin = "ANA" };
|
||||
user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = true };
|
||||
seed.UserAccounts.Add(user);
|
||||
await seed.SaveChangesAsync();
|
||||
var actor = new CurrentBifrostActor(true, user);
|
||||
var motion = await NewService(seed).OpenAsync(actor,
|
||||
new OpenSealedMotionCommand("t", "s", ["Ana", "Bo"], T0.AddHours(1), T0.AddHours(2)), default);
|
||||
clock.Now = T0.AddHours(3);
|
||||
|
||||
await using var first = NewDb();
|
||||
await using var second = NewDb();
|
||||
_ = await second.Motions.ToListAsync(); // second now holds a stale, still-Open copy
|
||||
|
||||
var winner = await NewService(first).GetResultAsync(actor, motion.Id, default);
|
||||
var loser = await NewService(second).GetResultAsync(actor, motion.Id, default); // would finalize again; the token refuses it
|
||||
|
||||
Assert.Equal(winner.CommitmentDigest, loser.CommitmentDigest);
|
||||
await using var check = NewDb();
|
||||
Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "sealed.closed"));
|
||||
|
||||
// Genuinely concurrent first reads on fresh contexts also close exactly once.
|
||||
var other = await NewService(check).OpenAsync(actor,
|
||||
new OpenSealedMotionCommand("t2", "s", ["Ana", "Bo"], T0.AddHours(4), T0.AddHours(5)), default);
|
||||
clock.Now = T0.AddHours(6);
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(async _ =>
|
||||
{
|
||||
await using var db = NewDb();
|
||||
await NewService(db).GetResultAsync(actor, other.Id, default);
|
||||
}));
|
||||
await using var after = NewDb();
|
||||
Assert.Equal(1, await after.AuditEvents.CountAsync(x => x.Action == "sealed.closed" && x.EntityId == other.Id.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_sweep_closes_a_due_motion_nobody_read()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
Assert.Equal(0, await rig.Service.FinalizeDueAsync(default));
|
||||
rig.Clock.Now = T0.AddHours(3);
|
||||
Assert.Equal(1, await rig.Service.FinalizeDueAsync(default));
|
||||
Assert.Equal(MotionStatus.Closed, (await rig.Db.Motions.SingleAsync(x => x.Id == motion.Id)).Status);
|
||||
Assert.Equal(0, await rig.Service.FinalizeDueAsync(default));
|
||||
Assert.Equal(1, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.closed"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue