Sealed-ballot motions: commit-reveal over gamecult.sealed_ballot.v0 #21
13 changed files with 1503 additions and 2 deletions
101
docs/sealed-ballot-motion.md
Normal file
101
docs/sealed-ballot-motion.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Sealed-ballot motions
|
||||
|
||||
A blind choice among named candidates (first use: which Mind writes the swarm's story). Commit-reveal, one atomic
|
||||
publication at close, no mediator. Code: `Features/Motions/SealedBallotService.cs`. Ballot shape: the JSON record `SealedBallotDocument` carrying the string constant `gamecult.sealed_ballot.v0`.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. **Open** (moderator only): title, candidates (2+), commit deadline, reveal deadline, optional `round` and `priorMotionId`.
|
||||
2. **Commit** until the commit deadline: the voter sends `SHA-256` of the canonical ballot document (below). Bifrost stores
|
||||
only that hash and returns a receipt (commitment, revision, time). A later commit replaces the earlier one (ballot change,
|
||||
`revision` counts it); every commit is an audit event carrying the hash.
|
||||
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, 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. The close freezes the reveal set: `Motion.RevealSeq` (bumped by every accepted reveal) and `Status` are concurrency
|
||||
tokens, so a reveal racing the close either lands before it (the close re-reads and its digest covers that reveal) or is
|
||||
refused with 409 ("the motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times, then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:<RevealSeq>` plus every commitment's revealed/unrevealed state. A reveal after
|
||||
the deadline or after close is refused. 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 =
|
||||
`sha256(utf8("gamecult.sealed_ballot.v0\n{motionId}\n{voterUserAccountId}\n{round}\n{candidate}\n{reason}\n{salt}"))`, lowercase
|
||||
hex. `salt` is 32 to 128 hex characters (128 bits at least) the voter generates and keeps; without it a four-candidate hash is
|
||||
guessable. The voter's account id is in the hash, so a commitment cannot be replayed by another member. `gamecult.sealed_ballot.v0` is only a string
|
||||
constant on a JSON record (`SealedBallotDocument`); it is **not** a CultCache or CultNet schema, and nothing here registers
|
||||
one. A second Delphi round is modelled as another motion (`Round` and `PriorMotionId` columns) whose ballots have the same JSON
|
||||
shape. A real typed document schema, with CultCache/CultNet registration so a round-two ballot is the same registered type, is
|
||||
a follow-up (Bifrost issue linked in the PR).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Cast surface | `POST /api/sealed-motions/{id}/commit` `{commitment}` and `POST .../reveal` (the document). `GET .../{id}` returns phase, times, candidates, your voter id, your own commitment status and an announcement line (open state and close time, no ballots). `GET .../result` refuses until close. All require an active member session. |
|
||||
| Who opens / closes | A moderator opens. Nobody closes: the deadline does. |
|
||||
| Deadlines | Set at open, immutable. Commit deadline < reveal deadline. |
|
||||
| Who may vote | Every active member at close, Minds included. Self-votes are allowed: a candidate is a name, not tied to the voter. One-sentence reason: required, non-empty, one line, at most 280 characters (length and line count are the check; "one sentence" is not parsed). |
|
||||
| Non-voters | No commitment, or a commitment never revealed: shown at close as abstentions (unrevealed ones with their commitment). Never counted for anyone. No quorum. |
|
||||
| Ballot change | Re-commit during the commit window; the latest commitment stands. No change once the window ends. |
|
||||
| Ties | Reported as `tie` with all leaders. Nobody breaks it; a runoff is a new motion over the tied candidates. |
|
||||
| Tally | Plurality of valid revealed ballots. |
|
||||
|
||||
## Why commit-reveal, not encryption to a close-time key
|
||||
|
||||
Encryption to a key released at close needs someone to hold that key until then, which is a mediator and trusts Bifrost with
|
||||
every plaintext from the first cast. Commit-reveal stores nothing but hashes during the commit window, so before the reveal
|
||||
step there is no ballot in Bifrost to read, and after it nobody can change what they committed. Price: a second step per
|
||||
voter, and a Mind that skips it is shown as an abstention. No custody opt-in is offered; offering one would mean Bifrost
|
||||
holding that Mind's plaintext from cast, which is the gap commit-reveal removes.
|
||||
|
||||
## Who can read the sealed store before close (the honest account)
|
||||
|
||||
- **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), 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
|
||||
nothing, at the price of being an abstention.
|
||||
- The host can also withhold or alter the *result* at close (it computes the tally). The published commitment digest and the
|
||||
per-ballot recomputation make a forged ballot detectable by voters, not a withheld one.
|
||||
|
||||
## Where ballots must never travel
|
||||
|
||||
Ballots go to Bifrost as JSON 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.
|
||||
|
|
@ -41,6 +41,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<Vote> Votes => Set<Vote>();
|
||||
|
||||
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
|
||||
|
||||
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
|
||||
|
||||
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
|
||||
|
|
@ -275,6 +277,9 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.Property(x => x.Category).HasConversion<string>();
|
||||
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.RevealSeq).IsConcurrencyToken();
|
||||
entity.Property(x => x.Status).IsConcurrencyToken();
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.Motions)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
|
|
@ -299,6 +304,18 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SealedBallot>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique();
|
||||
entity.HasOne(x => x.Motion)
|
||||
.WithMany(x => x.SealedBallots)
|
||||
.HasForeignKey(x => x.MotionId);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.EntryType).HasConversion<string>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written: no dotnet-ef on the authoring host. The snapshot was edited to match; BuildTargetModel is left to the base no-op.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261109200000_AddSealedBallotMotions")]
|
||||
public partial class AddSealedBallotMotions : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(name: "Candidates", table: "Motions", type: "text", nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "CommitClosesAtUtc", table: "Motions", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard");
|
||||
migrationBuilder.AddColumn<Guid>(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<int>(name: "RevealSeq", table: "Motions", type: "integer", nullable: false, defaultValue: 0);
|
||||
migrationBuilder.AddColumn<int>(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SealedBallots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MotionId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Commitment = table.Column<string>(type: "text", nullable: false),
|
||||
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||
CommittedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RevealedCandidate = table.Column<string>(type: "text", nullable: true),
|
||||
RevealedReason = table.Column<string>(type: "text", nullable: true),
|
||||
Salt = table.Column<string>(type: "text", nullable: true),
|
||||
RevealedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SealedBallots", x => x.Id);
|
||||
table.ForeignKey("FK_SealedBallots_Motions_MotionId", x => x.MotionId, "Motions", "Id", onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey("FK_SealedBallots_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(name: "IX_SealedBallots_MotionId_UserAccountId", table: "SealedBallots", columns: new[] { "MotionId", "UserAccountId" }, unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_SealedBallots_UserAccountId", table: "SealedBallots", column: "UserAccountId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "SealedBallots");
|
||||
foreach (var column in new[] { "Candidates", "CommitClosesAtUtc", "Kind", "PriorMotionId", "ResultDigest", "RevealSeq", "Round" })
|
||||
{
|
||||
migrationBuilder.DropColumn(name: column, table: "Motions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -896,15 +896,29 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<DateTimeOffset>("ClosesAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Candidates")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("CommitClosesAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("OpensAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PriorMotionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
@ -915,11 +929,23 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<DateTimeOffset?>("ResolvedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("RevealSeq")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultDigest")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Round")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -1414,6 +1440,50 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("VolunteerClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Commitment")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CommittedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("MotionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RevealedCandidate")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RevealedReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevealedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Salt")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserAccountId");
|
||||
|
||||
b.HasIndex("MotionId", "UserAccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SealedBallots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -2209,6 +2279,25 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("WorkItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.Motion", "Motion")
|
||||
.WithMany("SealedBallots")
|
||||
.HasForeignKey("MotionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Motion");
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.Motion", "Motion")
|
||||
|
|
@ -2331,6 +2420,8 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Motion", b =>
|
||||
{
|
||||
b.Navigation("SealedBallots");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -692,11 +692,68 @@ public sealed class Motion
|
|||
|
||||
public string ResolutionNote { get; set; } = string.Empty;
|
||||
|
||||
public MotionKind Kind { get; set; } = MotionKind.Standard;
|
||||
|
||||
// Sealed-ballot motions only: commitments are accepted until this instant, reveals from then until ClosesAtUtc.
|
||||
public DateTimeOffset? CommitClosesAtUtc { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: candidate names, one per line.
|
||||
public string Candidates { get; set; } = string.Empty;
|
||||
|
||||
// Sealed-ballot motions only: Delphi round number and the motion whose result this round answers.
|
||||
public int Round { get; set; } = 1;
|
||||
|
||||
public Guid? PriorMotionId { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: bumped by every accepted reveal. A concurrency token, so a reveal and the close cannot both
|
||||
// succeed on the same row version: the close freezes exactly the reveals it counted.
|
||||
public int RevealSeq { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: SHA-256 over the sorted commitments, written once at finalization.
|
||||
public string ResultDigest { get; set; } = string.Empty;
|
||||
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public UserAccount CreatedByUserAccount { get; set; } = null!;
|
||||
|
||||
public ICollection<Vote> Votes { get; set; } = [];
|
||||
|
||||
public ICollection<SealedBallot> SealedBallots { get; set; } = [];
|
||||
}
|
||||
|
||||
public enum MotionKind
|
||||
{
|
||||
Standard,
|
||||
SealedBallot
|
||||
}
|
||||
|
||||
public sealed class SealedBallot
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MotionId { get; set; }
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
// Hex SHA-256 of the canonical ballot document; the only ballot-derived value stored before the reveal step.
|
||||
public string Commitment { get; set; } = string.Empty;
|
||||
|
||||
public int Revision { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset CommittedAtUtc { get; set; }
|
||||
|
||||
// Reveal step. Held unread by every code path until the motion is Closed.
|
||||
public string? RevealedCandidate { get; set; }
|
||||
|
||||
public string? RevealedReason { get; set; }
|
||||
|
||||
public string? Salt { get; set; }
|
||||
|
||||
public DateTimeOffset? RevealedAtUtc { get; set; }
|
||||
|
||||
public Motion Motion { get; set; } = null!;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class Vote
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public sealed class MotionGovernanceService(
|
|||
.Include(x => x.Votes)
|
||||
.SingleAsync(x => x.Id == motionId, cancellationToken);
|
||||
|
||||
if (motion.Status != MotionStatus.Open)
|
||||
if (motion.Status != MotionStatus.Open || motion.Kind != MotionKind.Standard)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -102,6 +102,11 @@ public sealed class MotionGovernanceService(
|
|||
.Include(x => x.Votes)
|
||||
.SingleAsync(x => x.Id == motionId, cancellationToken);
|
||||
|
||||
if (motion.Kind != MotionKind.Standard)
|
||||
{
|
||||
throw new InvalidOperationException("Sealed-ballot motions close only at their deadline.");
|
||||
}
|
||||
|
||||
if (!actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id)
|
||||
{
|
||||
throw new UnauthorizedAccessException("Only the motion creator or a moderator can close this motion.");
|
||||
|
|
@ -140,6 +145,7 @@ public sealed class MotionGovernanceService(
|
|||
.AsNoTracking()
|
||||
.Include(x => x.Project)
|
||||
.Include(x => x.Votes)
|
||||
.Where(x => x.Kind == MotionKind.Standard)
|
||||
.OrderBy(x => x.ClosesAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
|
|
|||
438
src/Bifrost.Web/Features/Motions/SealedBallotService.cs
Normal file
438
src/Bifrost.Web/Features/Motions/SealedBallotService.cs
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-ballot motions: commit, then reveal, then one atomic publication at close.
|
||||
/// This is the only code that reads <see cref="SealedBallot.RevealedCandidate"/>, <see cref="SealedBallot.RevealedReason"/>
|
||||
/// or <see cref="SealedBallot.Salt"/>; a test fails if any other source file mentions them.
|
||||
/// </summary>
|
||||
public sealed class SealedBallotService(
|
||||
BifrostDbContext dbContext,
|
||||
AuditTrailService auditTrailService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public const int MaxReasonLength = 280;
|
||||
private static readonly Regex HexSha256 = new(@"^[0-9a-f]{64}\z", RegexOptions.Compiled);
|
||||
private static readonly Regex HexSalt = new(@"^[0-9a-f]{32,128}\z", RegexOptions.Compiled);
|
||||
|
||||
public async Task<SealedMotionState> OpenAsync(
|
||||
CurrentBifrostActor actor,
|
||||
OpenSealedMotionCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = RequireMember(actor);
|
||||
if (!actor.CanModerateMotions)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator opens a sealed-ballot motion.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var candidates = command.Candidates.Select(x => x.Trim()).Where(x => x.Length > 0).ToList();
|
||||
if (candidates.Count < 2 || candidates.Distinct(StringComparer.Ordinal).Count() != candidates.Count ||
|
||||
candidates.Any(x => x.Contains('\n') || x.Contains('\r')))
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Invalid, "Two or more distinct single-line candidates are required.");
|
||||
}
|
||||
|
||||
var commitCloses = command.CommitClosesAtUtc.ToUniversalTime();
|
||||
var closes = command.ClosesAtUtc.ToUniversalTime();
|
||||
if (commitCloses <= now || closes <= commitCloses)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Invalid, "The commit window must end in the future and the reveal window must end after it.");
|
||||
}
|
||||
|
||||
var motion = new Motion
|
||||
{
|
||||
CreatedByUserAccountId = user.Id,
|
||||
Scope = MotionScope.Management,
|
||||
Category = MotionCategory.Features,
|
||||
Kind = MotionKind.SealedBallot,
|
||||
Title = command.Title.Trim(),
|
||||
Summary = command.Summary.Trim(),
|
||||
Candidates = string.Join('\n', candidates),
|
||||
Round = Math.Max(1, command.Round),
|
||||
PriorMotionId = command.PriorMotionId,
|
||||
ApprovalThreshold = 0m,
|
||||
OpensAtUtc = now,
|
||||
CommitClosesAtUtc = commitCloses,
|
||||
ClosesAtUtc = closes,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
|
||||
dbContext.Motions.Add(motion);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
user.Id, nameof(Motion), motion.Id, "sealed.opened",
|
||||
$"Opened sealed-ballot motion {motion.Title} (round {motion.Round}, {candidates.Count} candidates).",
|
||||
cancellationToken);
|
||||
|
||||
return BuildState(motion, now, null, user.Id);
|
||||
}
|
||||
|
||||
public async Task<SealedBallotReceipt> CommitAsync(
|
||||
CurrentBifrostActor actor,
|
||||
Guid motionId,
|
||||
string commitment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = RequireMember(actor);
|
||||
var motion = await LoadAsync(motionId, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (PhaseOf(motion, now) != SealedPhase.Commit)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The commit window is not open.");
|
||||
}
|
||||
|
||||
commitment = (commitment ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!HexSha256.IsMatch(commitment))
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256).");
|
||||
}
|
||||
|
||||
var ballot = await dbContext.SealedBallots
|
||||
.SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken);
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id };
|
||||
dbContext.SealedBallots.Add(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
ballot.Revision++;
|
||||
}
|
||||
|
||||
ballot.Commitment = commitment;
|
||||
ballot.CommittedAtUtc = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
user.Id, nameof(Motion), motionId, "sealed.committed",
|
||||
$"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.",
|
||||
cancellationToken);
|
||||
|
||||
return new SealedBallotReceipt(motionId, user.Id, commitment, ballot.Revision, now, Revealed: false);
|
||||
}
|
||||
|
||||
public async Task<SealedBallotReceipt> RevealAsync(
|
||||
CurrentBifrostActor actor,
|
||||
Guid motionId,
|
||||
SealedBallotDocument document,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = RequireMember(actor);
|
||||
var motion = await LoadAsync(motionId, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (PhaseOf(motion, now) != SealedPhase.Reveal)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal window is not open.");
|
||||
}
|
||||
|
||||
var ballot = await dbContext.SealedBallots
|
||||
.SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken)
|
||||
?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No commitment from this member on this motion.");
|
||||
|
||||
var reason = (document.Reason ?? string.Empty).Trim();
|
||||
var candidate = (document.Candidate ?? string.Empty).Trim();
|
||||
if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || document.Round != motion.Round ||
|
||||
!CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) ||
|
||||
reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') ||
|
||||
!HexSalt.IsMatch(document.Salt ?? string.Empty))
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot document does not fit this motion (schema, motion, round, candidate, one-line reason, 128-bit hex salt).");
|
||||
}
|
||||
|
||||
var recomputed = SealedBallotDocument.Commit(document with { Candidate = candidate, Reason = reason }, user.Id);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(recomputed), Encoding.ASCII.GetBytes(ballot.Commitment)))
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot does not match the stored commitment.");
|
||||
}
|
||||
|
||||
// RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or
|
||||
// another voter's reveal landed first while the window is still open (re-read the row and write again).
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
ballot.RevealedCandidate = candidate;
|
||||
ballot.RevealedReason = reason;
|
||||
ballot.Salt = document.Salt;
|
||||
ballot.RevealedAtUtc = now;
|
||||
motion.RevealSeq++;
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException error)
|
||||
{
|
||||
foreach (var entry in error.Entries)
|
||||
{
|
||||
await entry.ReloadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (PhaseOf(motion, now) != SealedPhase.Reveal)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over.");
|
||||
}
|
||||
|
||||
if (attempt >= 4)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
user.Id, nameof(Motion), motionId, "sealed.revealed",
|
||||
$"{actor.DisplayName} submitted a reveal for commitment {ballot.Commitment}; content stays sealed until close.",
|
||||
cancellationToken);
|
||||
|
||||
return new SealedBallotReceipt(motionId, user.Id, ballot.Commitment, ballot.Revision, ballot.CommittedAtUtc, Revealed: true);
|
||||
}
|
||||
|
||||
/// <summary>Phase, times and the caller's own commitment status. Never another member's ballot, never revealed content.</summary>
|
||||
public async Task<SealedMotionState> GetStateAsync(
|
||||
CurrentBifrostActor actor,
|
||||
Guid motionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = RequireMember(actor);
|
||||
var motion = await LoadAsync(motionId, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var own = await dbContext.SealedBallots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.MotionId == motionId && x.UserAccountId == user.Id)
|
||||
.Select(x => new SealedBallotReceipt(x.MotionId, x.UserAccountId, x.Commitment, x.Revision, x.CommittedAtUtc, x.RevealedAtUtc != null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return BuildState(motion, now, own, user.Id);
|
||||
}
|
||||
|
||||
/// <summary>Finalizes the motion if its deadline has passed, then publishes every ballot at once. Before close it refuses.</summary>
|
||||
public async Task<SealedMotionResult> GetResultAsync(
|
||||
CurrentBifrostActor actor,
|
||||
Guid motionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequireMember(actor);
|
||||
var motion = await LoadAsync(motionId, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (PhaseOf(motion, now) != SealedPhase.Closed)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "Ballots stay sealed until the motion closes.");
|
||||
}
|
||||
|
||||
// Freeze: the close counts the reveals it read and succeeds only if no reveal landed since (RevealSeq is a concurrency
|
||||
// token); a conflict re-reads and retries, so the digest always covers exactly the set that is published.
|
||||
for (var attempt = 0; motion.Status == MotionStatus.Open && attempt < 5; attempt++)
|
||||
{
|
||||
var frozen = await dbContext.SealedBallots.AsNoTracking().Where(x => x.MotionId == motionId).ToListAsync(cancellationToken);
|
||||
if (await FinalizeAsync(motion, frozen, now, cancellationToken))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (motion.Status == MotionStatus.Open)
|
||||
{
|
||||
throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The close is contended; read again.");
|
||||
}
|
||||
|
||||
var ballots = await dbContext.SealedBallots.AsNoTracking()
|
||||
.Include(x => x.UserAccount).ThenInclude(x => x.MemberProfile)
|
||||
.Where(x => x.MotionId == motionId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var revealed = ballots.Where(x => x.RevealedAtUtc != null).OrderBy(x => x.Id).ToList();
|
||||
var tally = CandidatesOf(motion)
|
||||
.Select(c => new SealedTally(c, revealed.Count(x => x.RevealedCandidate == c)))
|
||||
.ToList();
|
||||
var top = tally.Max(x => x.Votes);
|
||||
var leaders = top == 0 ? [] : tally.Where(x => x.Votes == top).Select(x => x.Candidate).ToList();
|
||||
|
||||
var voterIds = ballots.Select(x => x.UserAccountId).ToHashSet();
|
||||
var nonVoters = await dbContext.Memberships
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Status == MembershipStatus.Active && !voterIds.Contains(x.UserAccountId))
|
||||
.Select(x => x.UserAccount.MemberProfile != null ? x.UserAccount.MemberProfile.Nickname : x.UserAccount.DisplayName)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new SealedMotionResult(
|
||||
motion.Id,
|
||||
motion.Title,
|
||||
motion.Round,
|
||||
motion.PriorMotionId,
|
||||
SealedBallotDocument.SchemaId,
|
||||
tally,
|
||||
leaders,
|
||||
leaders.Count > 1 ? "tie" : leaders.Count == 1 ? "winner" : "no ballots",
|
||||
revealed.Select(x => new RevealedBallot(
|
||||
NameOf(x.UserAccount), x.UserAccountId, x.RevealedCandidate!, x.RevealedReason!, x.Salt!, x.Commitment)).ToList(),
|
||||
ballots.Where(x => x.RevealedAtUtc == null)
|
||||
.Select(x => new AbstainedMember(NameOf(x.UserAccount), "committed but did not reveal", x.Commitment)).ToList(),
|
||||
nonVoters.Select(n => new AbstainedMember(n, "no commitment", null)).ToList(),
|
||||
motion.ResultDigest);
|
||||
}
|
||||
|
||||
public static SealedPhase PhaseOf(Motion motion, DateTimeOffset now) =>
|
||||
motion.Status != MotionStatus.Open || now >= motion.ClosesAtUtc ? SealedPhase.Closed
|
||||
: 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<bool> FinalizeAsync(Motion motion, List<SealedBallot> ballots, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var lines = ballots.OrderBy(x => x.UserAccountId)
|
||||
.Select(x => $"{x.UserAccountId:D}:{x.Commitment}:{(x.RevealedAtUtc != null ? "revealed" : "unrevealed")}")
|
||||
.Prepend($"reveals:{motion.RevealSeq}");
|
||||
motion.ResultDigest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', lines)))).ToLowerInvariant();
|
||||
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.";
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException error)
|
||||
{
|
||||
foreach (var entry in error.Entries)
|
||||
{
|
||||
await entry.ReloadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return motion.Status != MotionStatus.Open;
|
||||
}
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
null, nameof(Motion), motion.Id, "sealed.closed",
|
||||
$"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.",
|
||||
cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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.AsNoTracking().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);
|
||||
var announcement = phase switch
|
||||
{
|
||||
SealedPhase.Commit => $"Sealed ballot open: {motion.Title}. Commit by {motion.CommitClosesAtUtc:u}; reveal until {motion.ClosesAtUtc:u}.",
|
||||
SealedPhase.Reveal => $"Sealed ballot in reveal: {motion.Title}. Reveal by {motion.ClosesAtUtc:u}.",
|
||||
_ => $"Sealed ballot closed: {motion.Title}."
|
||||
};
|
||||
return new SealedMotionState(
|
||||
motion.Id, motion.Title, motion.Summary, motion.Round, motion.PriorMotionId, SealedBallotDocument.SchemaId,
|
||||
phase.ToString(), CandidatesOf(motion), motion.CommitClosesAtUtc!.Value, motion.ClosesAtUtc, voterId, own, announcement);
|
||||
}
|
||||
|
||||
private async Task<Motion> LoadAsync(Guid motionId, CancellationToken cancellationToken) =>
|
||||
await dbContext.Motions.SingleOrDefaultAsync(x => x.Id == motionId && x.Kind == MotionKind.SealedBallot, cancellationToken)
|
||||
?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such sealed-ballot motion.");
|
||||
|
||||
private static IReadOnlyList<string> CandidatesOf(Motion motion) => motion.Candidates.Split('\n');
|
||||
|
||||
private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName;
|
||||
|
||||
private static UserAccount RequireMember(CurrentBifrostActor actor) =>
|
||||
actor is { IsActiveMember: true, UserAccount: { } user }
|
||||
? user
|
||||
: throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required.");
|
||||
}
|
||||
|
||||
public enum SealedPhase { Commit, Reveal, Closed }
|
||||
|
||||
public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid }
|
||||
|
||||
public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message)
|
||||
{
|
||||
public SealedBallotFailure Failure { get; } = failure;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The typed ballot document, schema <c>gamecult.sealed_ballot.v0</c>. A Delphi second round is another motion carrying this
|
||||
/// same type with a higher Round. The commitment binds the voter's account id, so a commitment cannot be replayed by another member.
|
||||
/// </summary>
|
||||
public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt)
|
||||
{
|
||||
public const string SchemaId = "gamecult.sealed_ballot.v0";
|
||||
|
||||
public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
|
||||
$"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public sealed record OpenSealedMotionCommand(
|
||||
string Title,
|
||||
string Summary,
|
||||
IReadOnlyList<string> Candidates,
|
||||
DateTimeOffset CommitClosesAtUtc,
|
||||
DateTimeOffset ClosesAtUtc,
|
||||
int Round = 1,
|
||||
Guid? PriorMotionId = null);
|
||||
|
||||
public sealed record SealedBallotReceipt(Guid MotionId, Guid VoterUserAccountId, string Commitment, int Revision, DateTimeOffset CommittedAtUtc, bool Revealed);
|
||||
|
||||
public sealed record SealedMotionState(
|
||||
Guid Id, string Title, string Summary, int Round, Guid? PriorMotionId, string BallotSchema, string Phase,
|
||||
IReadOnlyList<string> Candidates, DateTimeOffset CommitClosesAtUtc, DateTimeOffset ClosesAtUtc,
|
||||
Guid YourVoterId, SealedBallotReceipt? YourCommitment, string Announcement);
|
||||
|
||||
public sealed record SealedTally(string Candidate, int Votes);
|
||||
|
||||
public sealed record RevealedBallot(string Voter, Guid VoterUserAccountId, string Candidate, string Reason, string Salt, string Commitment);
|
||||
|
||||
public sealed record AbstainedMember(string Member, string Why, string? Commitment);
|
||||
|
||||
public sealed record SealedMotionResult(
|
||||
Guid Id, string Title, int Round, Guid? PriorMotionId, string BallotSchema,
|
||||
IReadOnlyList<SealedTally> Tally, IReadOnlyList<string> Leaders, string Outcome,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
var openMotions = await dbContext.Motions
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Votes)
|
||||
.Where(x => x.Kind == MotionKind.Standard)
|
||||
.OrderBy(x => x.ClosesAtUtc)
|
||||
.Take(5)
|
||||
.Select(x => new DashboardMotion(
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ builder.Services.AddScoped<MembershipSynchronizationService>();
|
|||
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>();
|
||||
|
|
@ -541,6 +543,43 @@ eveGovernance.MapGet("/surface", async (
|
|||
"application/json");
|
||||
});
|
||||
|
||||
// Sealed-ballot intake. Typed JSON documents posted directly by the voter; no ballot content ever rides in a room message,
|
||||
// and no response before close carries another member's ballot.
|
||||
var sealedMotions = app.MapGroup("/api/sealed-motions")
|
||||
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
||||
|
||||
async Task<IResult> SealedCall(Func<Task<object>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json");
|
||||
}
|
||||
catch (SealedBallotException error)
|
||||
{
|
||||
return Results.Text(
|
||||
JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions),
|
||||
"application/json",
|
||||
statusCode: error.Failure switch
|
||||
{
|
||||
SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden,
|
||||
SealedBallotFailure.NotFound => StatusCodes.Status404NotFound,
|
||||
SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sealedMotions.MapPost("", (ICurrentBifrostActorAccessor actors, SealedBallotService service, OpenSealedMotionCommand command, CancellationToken ct) =>
|
||||
SealedCall(async () => await service.OpenAsync(await actors.GetAsync(ct), command, ct)));
|
||||
sealedMotions.MapGet("/{id:guid}", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) =>
|
||||
SealedCall(async () => await service.GetStateAsync(await actors.GetAsync(ct), id, ct)));
|
||||
sealedMotions.MapPost("/{id:guid}/commit", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedCommitRequest body, CancellationToken ct) =>
|
||||
SealedCall(async () => await service.CommitAsync(await actors.GetAsync(ct), id, body.Commitment, ct)));
|
||||
sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedBallotDocument body, CancellationToken ct) =>
|
||||
SealedCall(async () => await service.RevealAsync(await actors.GetAsync(ct), id, body, ct)));
|
||||
sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) =>
|
||||
SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct)));
|
||||
|
||||
app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) =>
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public sealed class MigrationDiscoveryTests
|
|||
"20260705190000_AddPatronSupportProjectAttribution",
|
||||
"20261010043937_AddConceptVotes",
|
||||
"20261022163159_AddOpenIddict",
|
||||
"20261109200000_AddSealedBallotMotions",
|
||||
];
|
||||
|
||||
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
|
||||
|
|
@ -51,7 +52,7 @@ public sealed class MigrationDiscoveryTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void All_fourteen_migrations_are_listed_in_order()
|
||||
public void All_fifteen_migrations_are_listed_in_order()
|
||||
{
|
||||
Assert.Equal(ExpectedMigrations, Discovered());
|
||||
}
|
||||
|
|
|
|||
138
tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs
Normal file
138
tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class SealedBallotCloseHttpTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string Salt = "00112233445566778899aabbccddeeff";
|
||||
private const string Reason = "Distinctive-close-reason-plugh.";
|
||||
private static int _next = 7000;
|
||||
|
||||
private sealed record W(Guid RevealA, Guid RevealB, Guid LateMotion, Guid ClosedWithBallot, string Ana, string Bo, string Cy);
|
||||
|
||||
private async Task<W> SeedAsync()
|
||||
{
|
||||
using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); }
|
||||
var n = Interlocked.Add(ref _next, 10);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
UserAccount Make(long gh, string name)
|
||||
{
|
||||
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 = MembershipStatus.Active, CreatedAtUtc = now };
|
||||
Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed");
|
||||
db.UserAccounts.Add(u);
|
||||
return u;
|
||||
}
|
||||
|
||||
var ana = Make(n + 1, $"ana{n}");
|
||||
Make(n + 2, $"bo{n}");
|
||||
Make(n + 3, $"cy{n}");
|
||||
Motion M(DateTimeOffset commitCloses, DateTimeOffset closes)
|
||||
{
|
||||
var m = new Motion
|
||||
{
|
||||
CreatedByUserAccountId = ana.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s",
|
||||
Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now
|
||||
};
|
||||
db.Motions.Add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
var a = M(now.AddHours(-1), now.AddHours(1)); // reveal phase: Ana's real ballot
|
||||
var b = M(now.AddHours(-1), now.AddHours(1)); // reveal phase: Ana's commitment from A replayed here
|
||||
var late = M(now.AddHours(-2), now.AddHours(-1)); // past deadline, Ana committed and never revealed
|
||||
var done = M(now.AddHours(-2), now.AddHours(-1)); // past deadline, Ana's reveal is in
|
||||
string Commit(Guid motion) => SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, motion, 1, "Bo", Reason, Salt), ana.Id);
|
||||
db.SealedBallots.Add(new SealedBallot { MotionId = a.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2), Commitment = Commit(a.Id) });
|
||||
db.SealedBallots.Add(new SealedBallot { MotionId = b.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2), Commitment = Commit(a.Id) });
|
||||
db.SealedBallots.Add(new SealedBallot { MotionId = late.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-3), Commitment = Commit(late.Id) });
|
||||
db.SealedBallots.Add(new SealedBallot
|
||||
{
|
||||
MotionId = done.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-3), Commitment = Commit(done.Id),
|
||||
RevealedCandidate = "Bo", RevealedReason = Reason, Salt = Salt, RevealedAtUtc = now.AddHours(-1.5)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new W(a.Id, b.Id, late.Id, done.Id, (n + 1).ToString(), (n + 2).ToString(), (n + 3).ToString());
|
||||
}
|
||||
|
||||
private HttpClient As(string id, string login)
|
||||
{
|
||||
var c = factory.CreateClient();
|
||||
c.DefaultRequestHeaders.Add("X-Test-GitHub-Id", id);
|
||||
c.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static SealedBallotDocument Doc(Guid motion) => new(SealedBallotDocument.SchemaId, motion, 1, "Bo", Reason, Salt);
|
||||
|
||||
private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected)
|
||||
{
|
||||
var body = await r.Content.ReadAsStringAsync();
|
||||
Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}");
|
||||
Assert.DoesNotContain(Reason, body);
|
||||
Assert.DoesNotContain(Salt, body);
|
||||
Assert.DoesNotContain("\"Bo\"", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_commit_or_reveal_replayed_into_another_motion_is_refused()
|
||||
{
|
||||
var w = await SeedAsync();
|
||||
var ana = As(w.Ana, "ana");
|
||||
// A's document aimed at B: wrong motion id inside, and B's stored commitment is A's hash, which no B-bound document reproduces.
|
||||
await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/reveal", Doc(w.RevealA)), HttpStatusCode.BadRequest);
|
||||
await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/reveal", Doc(w.RevealB)), HttpStatusCode.BadRequest);
|
||||
// A's commit hash sent into a motion that is no longer in its commit window.
|
||||
await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/commit", new { commitment = new string('a', 64) }), HttpStatusCode.Conflict);
|
||||
Assert.Equal(HttpStatusCode.OK, (await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealA}/reveal", Doc(w.RevealA))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_voter_cannot_reveal()
|
||||
{
|
||||
var w = await SeedAsync();
|
||||
await Refused(await As(w.Cy, "cy").PostAsJsonAsync($"/api/sealed-motions/{w.RevealA}/reveal", Doc(w.RevealA)), HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reveal_after_the_deadline_is_refused_and_the_voter_is_shown_unrevealed()
|
||||
{
|
||||
var w = await SeedAsync();
|
||||
var ana = As(w.Ana, "ana");
|
||||
await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.LateMotion}/reveal", Doc(w.LateMotion)), HttpStatusCode.Conflict);
|
||||
var result = await As(w.Bo, "bo").GetAsync($"/api/sealed-motions/{w.LateMotion}/result");
|
||||
var body = await result.Content.ReadAsStringAsync();
|
||||
Assert.True(result.StatusCode == HttpStatusCode.OK, body);
|
||||
Assert.Contains("committed but did not reveal", body);
|
||||
Assert.DoesNotContain(Reason, body);
|
||||
// Still refused after the close has run, and the published result did not change.
|
||||
await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.LateMotion}/reveal", Doc(w.LateMotion)), HttpStatusCode.Conflict);
|
||||
Assert.Equal(body, await (await As(w.Cy, "cy").GetAsync($"/api/sealed-motions/{w.LateMotion}/result")).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_with_ballots_publishes_them_once_with_a_stable_digest()
|
||||
{
|
||||
var w = await SeedAsync();
|
||||
var first = await (await As(w.Bo, "bo").GetAsync($"/api/sealed-motions/{w.ClosedWithBallot}/result")).Content.ReadAsStringAsync();
|
||||
Assert.Contains(Reason, first);
|
||||
Assert.Contains(Salt, first);
|
||||
Assert.Contains("\"outcome\":\"winner\"", first);
|
||||
var second = await (await As(w.Cy, "cy").GetAsync($"/api/sealed-motions/{w.ClosedWithBallot}/result")).Content.ReadAsStringAsync();
|
||||
Assert.Equal(first, second);
|
||||
|
||||
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Equal(1, await db.AuditEvents.CountAsync(x => x.Action == "sealed.closed" && x.EntityId == w.ClosedWithBallot.ToString()));
|
||||
Assert.Equal(MotionStatus.Closed, (await db.Motions.SingleAsync(x => x.Id == w.ClosedWithBallot)).Status);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
400
tests/Bifrost.Web.Tests/SealedBallotTests.cs
Normal file
400
tests/Bifrost.Web.Tests/SealedBallotTests.cs
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
using System.Text.Json;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Motions;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class SealedBallotTests
|
||||
{
|
||||
private const string Salt = "00112233445566778899aabbccddeeff";
|
||||
private static readonly DateTimeOffset T0 = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class Clock(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = start;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public readonly Clock Clock = new(T0);
|
||||
public readonly BifrostDbContext Db = new(new DbContextOptionsBuilder<BifrostDbContext>()
|
||||
.UseInMemoryDatabase($"sealed-{Guid.NewGuid():N}").Options);
|
||||
public readonly SealedBallotService Service;
|
||||
public readonly CurrentBifrostActor Moderator;
|
||||
|
||||
public Rig()
|
||||
{
|
||||
Service = Make(Db);
|
||||
Moderator = Member("Moderator", moderator: true);
|
||||
}
|
||||
|
||||
private SealedBallotService Make(BifrostDbContext db) => new(db, new AuditTrailService(db, Clock), Clock);
|
||||
|
||||
public CurrentBifrostActor Member(string name, bool moderator = false)
|
||||
{
|
||||
var user = new UserAccount { DisplayName = name, GitHubLogin = name, BifrostIdentity = name, NormalizedBifrostIdentity = name.ToUpperInvariant(), NormalizedGitHubLogin = name.ToUpperInvariant() };
|
||||
user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = moderator };
|
||||
Db.UserAccounts.Add(user);
|
||||
Db.SaveChanges();
|
||||
return new CurrentBifrostActor(true, user);
|
||||
}
|
||||
|
||||
public async Task<SealedMotionState> OpenAsync(params string[] candidates) =>
|
||||
await Service.OpenAsync(Moderator,
|
||||
new OpenSealedMotionCommand("Who writes the swarm's story", "Blind vote.", candidates, T0.AddHours(1), T0.AddHours(2)),
|
||||
default);
|
||||
|
||||
public async Task Cast(CurrentBifrostActor who, Guid motionId, string candidate, string reason, string salt = Salt)
|
||||
{
|
||||
var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motionId, 1, candidate, reason, salt);
|
||||
await Service.CommitAsync(who, motionId, SealedBallotDocument.Commit(doc, who.UserAccount!.Id), default);
|
||||
}
|
||||
|
||||
public Task Reveal(CurrentBifrostActor who, Guid motionId, string candidate, string reason, string salt = Salt) =>
|
||||
Service.RevealAsync(who, motionId, new SealedBallotDocument(SealedBallotDocument.SchemaId, motionId, 1, candidate, reason, salt), default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_reveal_close_publishes_every_ballot_and_each_verifies_against_its_commitment()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (a, b) = (rig.Member("Ana"), rig.Member("Bo"));
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
await rig.Cast(a, motion.Id, "Ana", "I love my own prose.");
|
||||
await rig.Cast(b, motion.Id, "Ana", "Ana writes best.");
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await rig.Reveal(a, motion.Id, "Ana", "I love my own prose.");
|
||||
await rig.Reveal(b, motion.Id, "Ana", "Ana writes best.");
|
||||
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Service.GetResultAsync(a, motion.Id, default));
|
||||
|
||||
rig.Clock.Now = T0.AddHours(3);
|
||||
var result = await rig.Service.GetResultAsync(a, motion.Id, default);
|
||||
Assert.Equal("winner", result.Outcome);
|
||||
Assert.Equal(["Ana"], result.Leaders);
|
||||
Assert.Equal(2, result.Ballots.Count);
|
||||
foreach (var ballot in result.Ballots)
|
||||
{
|
||||
var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, ballot.Candidate, ballot.Reason, ballot.Salt);
|
||||
Assert.Equal(ballot.Commitment, SealedBallotDocument.Commit(doc, ballot.VoterUserAccountId));
|
||||
}
|
||||
|
||||
var again = await rig.Service.GetResultAsync(b, motion.Id, default);
|
||||
Assert.Equal(result.CommitmentDigest, again.CommitmentDigest);
|
||||
Assert.Equal(MotionStatus.Closed, (await rig.Db.Motions.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_readable_before_close_carries_a_ballot()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var a = rig.Member("Ana");
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
const string reason = "Distinctive-reason-xyzzy.";
|
||||
await rig.Cast(a, motion.Id, "Bo", reason);
|
||||
|
||||
// Commit phase: the store holds the hash and nothing else.
|
||||
var row = await rig.Db.SealedBallots.AsNoTracking().SingleAsync();
|
||||
Assert.Null(row.RevealedCandidate);
|
||||
Assert.Null(row.RevealedReason);
|
||||
Assert.Null(row.Salt);
|
||||
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await rig.Reveal(a, motion.Id, "Bo", reason);
|
||||
|
||||
// Reveal phase, content now in the store: every read surface still withholds it.
|
||||
var surfaces = new List<string>
|
||||
{
|
||||
JsonSerializer.Serialize(await rig.Service.GetStateAsync(a, motion.Id, default)),
|
||||
JsonSerializer.Serialize(await rig.Service.GetStateAsync(rig.Moderator, motion.Id, default)),
|
||||
JsonSerializer.Serialize(await rig.Db.AuditEvents.AsNoTracking().ToListAsync()),
|
||||
JsonSerializer.Serialize(await new MotionGovernanceService(rig.Db, new AuditTrailService(rig.Db, rig.Clock), rig.Clock).GetStateAsync(a, default)),
|
||||
JsonSerializer.Serialize(await new DashboardSnapshotService(rig.Db, rig.Clock).GetAsync(a.UserAccount!.Id, default))
|
||||
};
|
||||
foreach (var surface in surfaces)
|
||||
{
|
||||
Assert.DoesNotContain(reason, surface);
|
||||
Assert.DoesNotContain(Salt, surface);
|
||||
}
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SealedBallotException>(() => rig.Service.GetResultAsync(rig.Moderator, motion.Id, default));
|
||||
Assert.Equal(SealedBallotFailure.WrongPhase, ex.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_sealed_ballot_service_reads_sealed_content()
|
||||
{
|
||||
var root = AppContext.BaseDirectory;
|
||||
while (!Directory.Exists(Path.Combine(root, "src", "Bifrost.Web"))) root = Path.GetDirectoryName(root)!;
|
||||
var offenders = Directory.EnumerateFiles(Path.Combine(root, "src", "Bifrost.Web"), "*.cs", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"))
|
||||
.Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("BifrostModels.cs"))
|
||||
.Where(f => File.ReadAllText(f) is var s && (s.Contains("RevealedCandidate") || s.Contains("RevealedReason") || s.Contains(".Salt")))
|
||||
.ToList();
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_ballot_each_and_a_recommit_replaces_the_first()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var a = rig.Member("Ana");
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
await rig.Cast(a, motion.Id, "Ana", "First thought.");
|
||||
await rig.Cast(a, motion.Id, "Bo", "Second thought.");
|
||||
var row = await rig.Db.SealedBallots.SingleAsync();
|
||||
Assert.Equal(2, row.Revision);
|
||||
Assert.Equal(2, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.committed"));
|
||||
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "First thought."));
|
||||
await rig.Reveal(a, motion.Id, "Bo", "Second thought.");
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Cast(a, motion.Id, "Ana", "Late change."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unrevealed_and_absent_members_are_shown_as_abstentions_not_votes()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (a, b, c) = (rig.Member("Ana"), rig.Member("Bo"), rig.Member("Cy"));
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
await rig.Cast(a, motion.Id, "Ana", "Mine.");
|
||||
await rig.Cast(b, motion.Id, "Bo", "Mine too.");
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await rig.Reveal(a, motion.Id, "Ana", "Mine.");
|
||||
rig.Clock.Now = T0.AddHours(3);
|
||||
|
||||
var result = await rig.Service.GetResultAsync(c, motion.Id, default);
|
||||
Assert.Equal(["Ana"], result.Leaders);
|
||||
Assert.Equal(1, result.Tally.Sum(t => t.Votes));
|
||||
Assert.Contains(result.Unrevealed, x => x.Member == "Bo" && x.Commitment is not null);
|
||||
Assert.Contains(result.NonVoters, x => x.Member == "Cy");
|
||||
Assert.DoesNotContain(result.NonVoters, x => x.Member == "Bo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ties_are_reported_and_nobody_breaks_them()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (a, b) = (rig.Member("Ana"), rig.Member("Bo"));
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
await rig.Cast(a, motion.Id, "Ana", "Self vote.");
|
||||
await rig.Cast(b, motion.Id, "Bo", "Self vote.");
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await rig.Reveal(a, motion.Id, "Ana", "Self vote.");
|
||||
await rig.Reveal(b, motion.Id, "Bo", "Self vote.");
|
||||
rig.Clock.Now = T0.AddHours(3);
|
||||
var result = await rig.Service.GetResultAsync(a, motion.Id, default);
|
||||
Assert.Equal("tie", result.Outcome);
|
||||
Assert.Equal(2, result.Leaders.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phase_gates_roles_and_the_old_vote_path_hold()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var a = rig.Member("Ana");
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Service.OpenAsync(a,
|
||||
new OpenSealedMotionCommand("x", "y", ["A", "B"], T0.AddHours(1), T0.AddHours(2)), default));
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Too early."));
|
||||
await rig.Cast(a, motion.Id, "Ana", "Fine.");
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Wrong text."));
|
||||
await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Cy", "Fine."));
|
||||
|
||||
var standard = new MotionGovernanceService(rig.Db, new AuditTrailService(rig.Db, rig.Clock), rig.Clock);
|
||||
await standard.CastVoteAsync(a, motion.Id, VoteChoice.For, "sneak", default);
|
||||
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"));
|
||||
}
|
||||
|
||||
// Runs a callback once, just before the first save that closes a motion: the window between the close reading its ballots and writing.
|
||||
private sealed class BeforeCloseSave(Func<Task> action) : SaveChangesInterceptor
|
||||
{
|
||||
private bool _done;
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_done && eventData.Context!.ChangeTracker.Entries<Motion>().Any(e => e.State == EntityState.Modified && e.Entity.Status == MotionStatus.Closed))
|
||||
{
|
||||
_done = true;
|
||||
await action();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static (BifrostDbContext Db, Clock Clock, SealedBallotService Service) Peer(string name, DateTimeOffset at, IInterceptor? interceptor = null)
|
||||
{
|
||||
var clock = new Clock(at);
|
||||
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(name);
|
||||
if (interceptor is not null) options.AddInterceptors(interceptor);
|
||||
var db = new BifrostDbContext(options.Options);
|
||||
return (db, clock, new SealedBallotService(db, new AuditTrailService(db, clock), clock));
|
||||
}
|
||||
|
||||
private static async Task<(CurrentBifrostActor Actor, SealedMotionState Motion)> SeedPeerWorld(BifrostDbContext db, SealedBallotService service)
|
||||
{
|
||||
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 };
|
||||
db.UserAccounts.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
var actor = new CurrentBifrostActor(true, user);
|
||||
var motion = await service.OpenAsync(actor, new OpenSealedMotionCommand("t", "s", ["Ana", "Bo"], T0.AddHours(1), T0.AddHours(2)), default);
|
||||
var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt);
|
||||
await service.CommitAsync(actor, motion.Id, SealedBallotDocument.Commit(doc, user.Id), default);
|
||||
return (actor, motion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reveal_that_loses_the_race_to_the_close_is_refused_and_is_not_in_the_frozen_set()
|
||||
{
|
||||
var name = $"sealed-freeze-a-{Guid.NewGuid():N}";
|
||||
var closer = Peer(name, T0);
|
||||
var (actor, motion) = await SeedPeerWorld(closer.Db, closer.Service);
|
||||
closer.Clock.Now = T0.AddMinutes(90);
|
||||
var revealer = Peer(name, T0.AddMinutes(90)); // its clock still says "reveal window"
|
||||
_ = await revealer.Db.Motions.ToListAsync(); // and it holds a stale, Open copy of the motion
|
||||
|
||||
closer.Clock.Now = T0.AddHours(3);
|
||||
var result = await closer.Service.GetResultAsync(actor, motion.Id, default);
|
||||
Assert.Empty(result.Ballots);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SealedBallotException>(() =>
|
||||
revealer.Service.RevealAsync(actor, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default));
|
||||
Assert.Equal(SealedBallotFailure.WrongPhase, ex.Failure);
|
||||
|
||||
await using var check = Peer(name, T0.AddHours(4)).Db;
|
||||
Assert.Null((await check.SealedBallots.AsNoTracking().SingleAsync()).RevealedAtUtc);
|
||||
Assert.Equal(result.CommitmentDigest, (await check.Motions.AsNoTracking().SingleAsync()).ResultDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_close_that_read_before_a_reveal_landed_retries_and_the_digest_covers_that_reveal()
|
||||
{
|
||||
var name = $"sealed-freeze-b-{Guid.NewGuid():N}";
|
||||
var revealer = Peer(name, T0);
|
||||
var (actor, motion) = await SeedPeerWorld(revealer.Db, revealer.Service);
|
||||
revealer.Clock.Now = T0.AddMinutes(90);
|
||||
var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt);
|
||||
// The reveal lands after the close read its ballots and before the close wrote.
|
||||
var closer = Peer(name, T0.AddHours(3), new BeforeCloseSave(() => revealer.Service.RevealAsync(actor, motion.Id, doc, default)));
|
||||
|
||||
var result = await closer.Service.GetResultAsync(actor, motion.Id, default);
|
||||
|
||||
Assert.Single(result.Ballots);
|
||||
Assert.Empty(result.Unrevealed);
|
||||
await using var check = Peer(name, T0.AddHours(4)).Db;
|
||||
var stored = await check.Motions.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(1, stored.RevealSeq);
|
||||
var ballot = await check.SealedBallots.AsNoTracking().SingleAsync();
|
||||
var expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"reveals:1\n{ballot.UserAccountId:D}:{ballot.Commitment}:revealed"))).ToLowerInvariant();
|
||||
Assert.Equal(expected, stored.ResultDigest); // the digest covers the reveal that raced the close
|
||||
Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "sealed.closed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_simultaneous_reveals_both_land_while_the_window_is_open()
|
||||
{
|
||||
var name = $"sealed-two-reveals-{Guid.NewGuid():N}";
|
||||
var first = Peer(name, T0);
|
||||
var (ana, motion) = await SeedPeerWorld(first.Db, first.Service);
|
||||
var bo = new UserAccount { DisplayName = "Bo", GitHubLogin = "bo", BifrostIdentity = "bo", NormalizedBifrostIdentity = "BO", NormalizedGitHubLogin = "BO" };
|
||||
bo.Membership = new Membership { UserAccountId = bo.Id, Status = MembershipStatus.Active };
|
||||
first.Db.UserAccounts.Add(bo);
|
||||
await first.Db.SaveChangesAsync();
|
||||
var boActor = new CurrentBifrostActor(true, bo);
|
||||
var boDoc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Bo", "Mine too.", Salt);
|
||||
await first.Service.CommitAsync(boActor, motion.Id, SealedBallotDocument.Commit(boDoc, bo.Id), default);
|
||||
first.Clock.Now = T0.AddMinutes(90);
|
||||
|
||||
var second = Peer(name, T0.AddMinutes(90));
|
||||
_ = await second.Db.Motions.ToListAsync(); // stale RevealSeq 0, window open
|
||||
|
||||
await first.Service.RevealAsync(ana, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default);
|
||||
var receipt = await second.Service.RevealAsync(boActor, motion.Id, boDoc, default); // conflicts on RevealSeq, window still open: must retry, not claim a close
|
||||
|
||||
Assert.True(receipt.Revealed);
|
||||
await using var check = Peer(name, T0.AddHours(4)).Db;
|
||||
Assert.Equal(2, await check.SealedBallots.CountAsync(x => x.RevealedAtUtc != null));
|
||||
Assert.Equal(2, (await check.Motions.AsNoTracking().SingleAsync()).RevealSeq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_salt_with_a_trailing_newline_is_not_hex()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var a = rig.Member("Ana");
|
||||
var motion = await rig.OpenAsync("Ana", "Bo");
|
||||
await rig.Cast(a, motion.Id, "Ana", "Mine.", Salt + "\n");
|
||||
rig.Clock.Now = T0.AddMinutes(90);
|
||||
var ex = await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Mine.", Salt + "\n"));
|
||||
Assert.Equal(SealedBallotFailure.Invalid, ex.Failure);
|
||||
Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue