diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md new file mode 100644 index 0000000..35ce1e8 --- /dev/null +++ b/docs/sealed-ballot-motion.md @@ -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:` 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" = ` 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. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 5596594..fa4d2ae 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -41,6 +41,8 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet Votes => Set(); + public DbSet SealedBallots => Set(); + public DbSet LedgerEntries => Set(); public DbSet PayoutProposalBatches => Set(); @@ -275,6 +277,9 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.Category).HasConversion(); entity.Property(x => x.Status).HasConversion(); entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3); + entity.Property(x => x.Kind).HasConversion(); + 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 options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(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(entity => { entity.Property(x => x.EntryType).HasConversion(); diff --git a/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs new file mode 100644 index 0000000..2629190 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs @@ -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(name: "Candidates", table: "Motions", type: "text", nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "CommitClosesAtUtc", table: "Motions", type: "timestamp with time zone", nullable: true); + migrationBuilder.AddColumn(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard"); + migrationBuilder.AddColumn(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true); + migrationBuilder.AddColumn(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "RevealSeq", table: "Motions", type: "integer", nullable: false, defaultValue: 0); + migrationBuilder.AddColumn(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1); + + migrationBuilder.CreateTable( + name: "SealedBallots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MotionId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Commitment = table.Column(type: "text", nullable: false), + Revision = table.Column(type: "integer", nullable: false), + CommittedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevealedCandidate = table.Column(type: "text", nullable: true), + RevealedReason = table.Column(type: "text", nullable: true), + Salt = table.Column(type: "text", nullable: true), + RevealedAtUtc = table.Column(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"); + } + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 8c47ed8..289201d 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -896,15 +896,29 @@ namespace Bifrost.Web.Data.Migrations b.Property("ClosesAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("Candidates") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommitClosesAtUtc") + .HasColumnType("timestamp with time zone"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("CreatedByUserAccountId") .HasColumnType("uuid"); + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + b.Property("OpensAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("PriorMotionId") + .HasColumnType("uuid"); + b.Property("ProjectId") .HasColumnType("uuid"); @@ -915,11 +929,23 @@ namespace Bifrost.Web.Data.Migrations b.Property("ResolvedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("RevealSeq") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("ResultDigest") + .IsRequired() + .HasColumnType("text"); + + b.Property("Round") + .HasColumnType("integer"); + b.Property("Scope") .IsRequired() .HasColumnType("text"); b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Commitment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("RevealedCandidate") + .HasColumnType("text"); + + b.Property("RevealedReason") + .HasColumnType("text"); + + b.Property("RevealedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("Salt") + .HasColumnType("text"); + + b.Property("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("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"); }); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 668af3d..5d6aab6 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -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 Votes { get; set; } = []; + + public ICollection 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 diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 8c10dcf..983526c 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -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); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs new file mode 100644 index 0000000..4517d55 --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -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; + +/// +/// Sealed-ballot motions: commit, then reveal, then one atomic publication at close. +/// This is the only code that reads , +/// or ; a test fails if any other source file mentions them. +/// +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 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 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 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); + } + + /// Phase, times and the caller's own commitment status. Never another member's ballot, never revealed content. + public async Task 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); + } + + /// Finalizes the motion if its deadline has passed, then publishes every ballot at once. Before close it refuses. + public async Task 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; + + /// + /// 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). + /// + private async Task FinalizeAsync(Motion motion, List 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; + } + + /// Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes. + public async Task 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 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 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; +} + +/// +/// The typed ballot document, schema gamecult.sealed_ballot.v0. 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. +/// +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 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 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 Tally, IReadOnlyList Leaders, string Outcome, + IReadOnlyList Ballots, IReadOnlyList Unrevealed, IReadOnlyList NonVoters, + string CommitmentDigest); +public sealed record SealedCommitRequest(string Commitment); + +/// Sweeps due sealed-ballot motions once a minute. Reads also close on demand; this covers a motion nobody reads. +public sealed class SealedBallotCloser(IServiceScopeFactory scopes, ILogger 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().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)); + } +} diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index 9f07cf9..3a31269 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -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( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c8a524a..4993e43 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -121,6 +121,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -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 SealedCall(Func> 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"; diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 04401c1..47fc76f 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -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()); } diff --git a/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs b/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs new file mode 100644 index 0000000..012d319 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs @@ -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 +{ + 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 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(); + 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(); + 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); + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs b/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs new file mode 100644 index 0000000..fb517bb --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs @@ -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 +{ + 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 SeedAsync() + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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(); + 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()); + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs new file mode 100644 index 0000000..1c91cb2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -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() + .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 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(() => 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 + { + 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(() => 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(() => rig.Reveal(a, motion.Id, "Ana", "First thought.")); + await rig.Reveal(a, motion.Id, "Bo", "Second thought."); + await Assert.ThrowsAsync(() => 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(() => 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(() => 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(() => rig.Reveal(a, motion.Id, "Ana", "Wrong text.")); + await Assert.ThrowsAsync(() => 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(() => 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().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 action) : SaveChangesInterceptor + { + private bool _done; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (!_done && eventData.Context!.ChangeTracker.Entries().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().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(() => + 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(() => rig.Reveal(a, motion.Id, "Ana", "Mine.", Salt + "\n")); + Assert.Equal(SealedBallotFailure.Invalid, ex.Failure); + Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc); + } +}