diff --git a/.claude/rules/match-writes.md b/.claude/rules/match-writes.md index e2a9a6297..d7d594620 100644 --- a/.claude/rules/match-writes.md +++ b/.claude/rules/match-writes.md @@ -24,19 +24,19 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more. - **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge. - **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). -- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. +- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared. An equal stamp that changes nothing (every group the write changes, `effectiveChangedGroups`, equal to the stored value once read as the write would land it) is the finish already recorded, sent again because its answer was lost: `engine.ExactScoreReplay` (the score counterpart of `exactDecisionReplay`) lets it past the reason check, and the engine answers it as recorded (`answerExactScoreReplay`, in `RecordMatchResultWithIneligibilityTx` after the older-revision hold and before the engi dispatch, so before every refusal): the stored match, with a bracket match's court and schedule filled in, nothing written, and one history line whose groups all read unchanged (none for an anonymous self-run participant's resend, `ForceOptions.NoReplayHistory`, so looping it cannot grow the history file). A same-stamp write that differs still needs a reason. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. - **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. - **Phase 3, the remaining drop sites, each with one owner:** - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. + - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). An entry that settles (sent, or refused for good) leaves browser storage at once (`leaveQueue` in `_flushQueue`), not at the end of the pass, so a page that goes away while a later entry is still out does not send it again on the next load. The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). - **A rename is not a side mismatch.** `reconcileSides` accepts a payload side whose id equals the stored id whatever name it carries, keeping the stored (current) name on the side, on a winner named by the old name, and on every bout row field that names the side by it (`adoptCurrentSideName`: the representative-bout row, and a fixed-order or id-less kachinuki row whose winner is the team, which `state.SubBoutWinnerSide` would otherwise credit to nobody); only an id disagreement, or a name disagreement no id can settle, is `side_mismatch`. Only an id the CLIENT sent proves a rename: the handler's hantei/winner-id backfill (`backfillMatchIdentityForHantei`) fills a side id only beside the stored name it belongs to, so a payload naming someone else with no ids is still `side_mismatch`. -Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. +Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged), and the organiser's exact finish replay likewise (every group unchanged); an anonymous self-run participant's adds none. A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. ## Write refusal and the clock frame (bc-lww1 / bc-cse) @@ -79,7 +79,7 @@ The public editor matches: `POST .../bulk-score` and `PUT .../quick-score` write without the participant's checks, so they are main-gated in self-run; bulk-score does judge each entry's representative picks on what the entry lands, in the entry's own transaction (a refused entry stores nothing and reports `reason: team_member_not_in_team`), and quick-score builds its rows server-side. -**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because the write guard is last-write-wins and an equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. +**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because an exact resend of a recorded finish or decision is answered as recorded (`engine.ExactScoreReplay`, `exactDecisionReplay`), with no correction reason asked; a running write's equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. **The persisted outbox is shared by every tab.** `_persistQueue` merges into what storage holds, keeping the newer copy (`enqueuedAt`) where two tabs hold the same ENTRY (the same key; two writes about one match are two entries since bc-mrgc phase 3, and both are kept), and every removal goes through `_dequeue`, which records it so the merge prunes it from storage. A bare `_writeQueue.delete` leaves the stored copy for the merge to keep, and the write is replayed on the next load. Each tab also follows the others' writes (`_onStoredQueueChanged`, on the `storage` event): it takes up an entry it does not hold, or holds an older copy of, after the rehydrate's checks (`_storedEntryProblem`), and drops its copy of one another tab took out of storage, so a write queued in a tab that then closed is still sent. A write two tabs hold can be sent by both, which is harmless (last-write-wins). A revoked password is scrubbed from every stored entry, another tab's included (`_parkStoredEntries`). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d48cf1ece..9f6ce01dc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -60,6 +60,7 @@ read the diff. See CONTRIBUTING and CLAUDE.md. - [ ] `make go/test` passes (lint + security scan + tests) - [ ] New/updated unit tests cover the change - [ ] Manual browser verification (for `web-mobile/` or `web/` changes): describe what you exercised +- [ ] `make e2e` green locally (any `web-mobile/` change) - [ ] Screenshots added above (REQUIRED for any UI-affecting change) - [ ] Docs updated under `docs/` for any new or changed user-facing feature, flag, command, or behavior (`make docs/build` passes) - [ ] Docs screenshots and videos recaptured for any change to what a user sees (`make docs/media SINCE=main`; the changed ones reviewed and copied into `docs/`, or "none changed") diff --git a/.gitignore b/.gitignore index 52b65374c..6fbd16e96 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,8 @@ Thumbs.db !web/package-lock.json !scripts/screenshots/package.json !scripts/screenshots/package-lock.json +!scripts/e2e/package.json +!scripts/e2e/package-lock.json # Go testdata fixtures. These are test INPUTS, not build output: the blanket # *.json above would silently drop them, and a golden table that never lands # fails CI on a checkout that works locally. /**/ matches zero or more @@ -154,3 +156,7 @@ mp-*/ # Docs capture harness: staged output, compared by eye before it replaces a committed file. scripts/screenshots/out/ + +# E2E journeys (make e2e): per-run screenshots and Playwright's own artifacts. +# Its node_modules/ is covered by the bare node_modules/ rule above. +scripts/e2e/output/ diff --git a/AGENTS.md b/AGENTS.md index 56cbb0e22..5c904c1ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Detailed domain rulings (match results and marks, match writes and the offline q - **API Documentation:** The OpenAPI specification for the web API is located in `specs/openapi.yaml` and is fully synchronized with the backend implementation. - **Mobile App (`mobile-app` command):** A real-time tournament management server serving a Preact/JSX UI from `web-mobile/`. State is file-backed: `tournament-data/tournament.md` (YAML) and `tournament-data/competitions//config.md` + `participants.csv`. Backend in `internal/mobileapp/` (Gin handlers) and `internal/state/` (store). Real-time updates via SSE. Admin actions require `X-Tournament-Password` header. Run with `make run-mobile`. - **Real-Time Updates:** Real-time updates via SSE. Admin actions require `X-Tournament-Password` header. Run with `make run-mobile`. -- **Admin Auth Modes:** Two startup-selected modes (`internal/mobileapp/auth_source.go`). **File mode** (default) stores the password plaintext in `tournament.md`; a public `POST /api/tournament/reset` resets it without the old password. **Locked mode** (`--lock-password` + `TOURNAMENT_PASSWORD_HASH` env var) compares the header against a bcrypt hash and disables the reset API (`POST /api/tournament/reset` returns 404; the SPA `/reset` page still loads but shows an operator-disabled message). The public `GET /api/auth-config` reports the active mode so the SPA can hide reset UI when locked. Generate hashes with `bracket-creator hash-password`. +- **Admin Auth Modes:** Two startup-selected modes (`internal/mobileapp/auth_source.go`). **File mode** (default) stores the password plaintext in `tournament.md`; `POST /api/tournament/reset` resets it without the old password, for loopback callers only (403 `reset_local_only` otherwise). **Locked mode** (`--lock-password` + `TOURNAMENT_PASSWORD_HASH` env var) compares the header against a bcrypt hash and disables the reset API (`POST /api/tournament/reset` returns 404; the SPA `/reset` page still loads but shows an operator-disabled message). The public `GET /api/auth-config` reports the active mode so the SPA can hide reset UI when locked. Generate hashes with `bracket-creator hash-password`. - **Pool Scoring Rules:** - **Individual:** 1. Fights Won, 2. Fights Lost, 3. Hikiwake, 4. Points Scored, 5. Points Lost. - **Team:** 1. Team W, 2. Team L, 3. Team T, 4. Individual Winners (IV), 5. Individual Losses (IL), 6. Individual Ties (IT), 7. Points Won (PW), 8. Points Lost (PL). diff --git a/CLAUDE.md b/CLAUDE.md index 9ec5cb31d..860f2285c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ go test -cover ./internal/helper/... - **`internal/excel/`**: Excel file lifecycle (`Client`), the from-scratch workbook template (`NewFileFromScratch`), style definitions. - **`internal/service/`**: Service layer abstraction over helper logic. - **`internal/resources/`**: Embedded file management. Resources flow: `main.go` embeds → `resources.NewResources()` → `cmd.ExecuteWithResources()`. -- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/fusensho/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`; `handleBothSidesBarredHikiwake` runs before the endpoint's own `Validate()` -- which otherwise 400s `hikiwake` outright with "use /score for fought/hikiwake" -- as the one carve-out that accepts `decision: "hikiwake"`, no `decisionBy` needed, when both sides of a SCHEDULED pool or league match (`engine.IsPoolMatchID`, league reuses the same `"Pool "`-prefixed ids; Swiss ids never match and are not covered) are already barred; the write records `Status: completed, Decision: "hikiwake"` with no new `CompetitorStatus`. The same both-barred knockout match instead gets `ineligible_competitor` naming both sides and pointing at the earlier withdrawal or the draw), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, public, for forgotten admin passwords; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). +- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`; self-run mode has no check-in, so the three check-in writes answer 409 `check_in_not_in_self_run` for every caller, and `state.Competition.CheckInApplies` is the one owner the draw, the Swiss field and the estimate ask, never `CheckInEnabled` itself), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/fusensho/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`; `handleBothSidesBarredHikiwake` runs before the endpoint's own `Validate()` -- which otherwise 400s `hikiwake` outright with "use /score for fought/hikiwake" -- as the one carve-out that accepts `decision: "hikiwake"`, no `decisionBy` needed, when both sides of a SCHEDULED pool or league match (`engine.IsPoolMatchID`, league reuses the same `"Pool "`-prefixed ids; Swiss ids never match and are not covered) are already barred; the write records `Status: completed, Decision: "hikiwake"` with no new `CompetitorStatus`. The same both-barred knockout match instead gets `ineligible_competitor` naming both sides and pointing at the earlier withdrawal or the draw), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, for forgotten admin passwords; in file mode it answers loopback callers only, judged on `RemoteAddr` and never on forwarded headers, 403 `reset_local_only` otherwise; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). - **`internal/state/`**: File-backed state store for the mobile app. Tournament and competition config lives in `tournament-data/tournament.md` and `tournament-data/competitions//config.md` (YAML front-matter). Participants are in `participants.csv` alongside each config. - **`internal/engine/`**: Thin adapter that drives `internal/helper` pool/bracket generation from a `state.Competition`. Called by the `POST /api/competitions/:id/start` handler. - **`web-mobile/`**: Preact/JSX frontend for the mobile app, served embedded in the binary. Entry point: `web-mobile/index.html`. JS modules in `web-mobile/js/` are grouped by prefix: `admin_*.jsx` (the operator console: setup, participants, pools, scoring, scheduling, lineups, etc.), `viewer_*.jsx` and `display_*.jsx` (public attendee/spectator surfaces), and shared infrastructure (`app.jsx`, `api_client.jsx`, `api_serializers.jsx`, `bracket.jsx`, `data.jsx`, `patch.jsx`, `router.jsx`, `ui.jsx`, `glossary.jsx`). Run `ls web-mobile/js/` for the current set rather than relying on an enumerated list here. Note `admin_participants.jsx` holds the `LinedTextarea` gutter participant paste box; check-in is NOT there, it is owned by `admin_registration_desk.jsx`. CSS in `web-mobile/css/styles.css`. Pre-compiled to `web-mobile/dist/` by esbuild (run automatically as part of `make go/build`). diff --git a/Makefile b/Makefile index aa206e84e..f372d102a 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ else endif # Define phony targets -.PHONY: default help clean local/deps hooks/install go/fmt go/generate go/test go/build go/lint go/sec go/sec-tests go/vuln go/security js/deps js/lint js/sec js/outdated js/security js/check-imports js/validate examples docker/build docker/run pre-commit docs/deps docs/serve docs/open docs/build docs/linkcheck docs/prose docs/clean docs/screenshots docs/videos docs/media run run-mobile esbuild-jsx goreleaser/test release version +.PHONY: default help clean local/deps hooks/install go/fmt go/generate go/test go/build go/lint go/sec go/sec-tests go/vuln go/security js/deps js/lint js/sec js/outdated js/security js/check-imports js/validate examples docker/build docker/run pre-commit docs/deps docs/serve docs/open docs/build docs/linkcheck docs/prose docs/clean docs/screenshots docs/videos docs/media e2e e2e/deps run run-mobile esbuild-jsx goreleaser/test release version default: help ## Show help information (default) @@ -377,6 +377,32 @@ docs/media: go/build $(SHOTS_BROWSER_STAMP) ## Regenerate every captured screens @# those would start node and a browser twice over two disjoint halves. @node $(SHOTS_DIR)/run.mjs $(if $(KIND),'KIND=$(KIND)',) $(SHOTS_ARGS) +# Local e2e journeys (scripts/e2e, see its README.md). Like the capture harness +# above, Playwright lives in its own package.json rather than web-mobile's, and +# both pin the same Playwright release so they share one downloaded Chromium +# under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in +# CI: a journey drives the real app through a browser, setup wizard and all. +E2E_DIR := scripts/e2e +E2E_DEPS_STAMP := $(E2E_DIR)/node_modules/.package-lock.json +E2E_BROWSER_STAMP := $(E2E_DIR)/node_modules/.chromium-installed + +$(E2E_DEPS_STAMP): $(E2E_DIR)/package.json $(E2E_DIR)/package-lock.json + @echo "Installing the e2e harness dependencies..." + @npm --prefix $(E2E_DIR) ci --no-audit --no-fund + @touch $@ + +$(E2E_BROWSER_STAMP): $(E2E_DEPS_STAMP) + @echo "Downloading the e2e browser..." + @cd $(E2E_DIR) && npx playwright install chromium + @touch $@ + +e2e/deps: $(E2E_BROWSER_STAMP) ## Install the e2e harness and its Chromium (once per worktree) + +# E2E_ARGS is handed to `playwright test` as is: a spec path, --grep, --headed, +# --project, ... e.g. make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' +e2e: go/build $(E2E_BROWSER_STAMP) ## Run the local e2e journeys (E2E_ARGS= passes a spec, --grep or --headed to playwright) + @cd $(E2E_DIR) && npx playwright test $(E2E_ARGS) + run: go/build ## Run the application locally @echo "Running $(BIN_NAME)..." $(BIN_PATH)/$(BIN_NAME) serve diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 065b52822..abbdfe3f2 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -18,8 +18,12 @@ Inspectability, repairability and diffability matter more here than normalisatio * **Repairable.** An organiser can correct a wrong cell by hand, and the app picks it up on reload. * **Diffable.** The whole tournament state can be committed to version control or copied to a USB stick as a backup. Two copies can then be compared line by line. - One exception: `tournament.md` holds the tournament and admin passwords in plain text, so - strip or change them before sharing a copy. + One exception: `tournament.md` holds the admin password in plain text, so strip or + change it before sharing a copy. A `tournament.md` that carries an `admin_password` field + may hold another password in plain text there. The app ignores that field and drops it the + next time it writes the file; remove it by hand before sharing a copy that still has it. + Refer to + [Upgrading from an earlier release](../user-guide/organisers/operating-modes.md#upgrading-from-an-earlier-release). The trade this makes is described honestly in [section 6](#6-how-the-model-maps-onto-rows). diff --git a/docs/architecture/infrastructure-architecture.md b/docs/architecture/infrastructure-architecture.md index 7f8226c7d..ded115cb8 100644 --- a/docs/architecture/infrastructure-architecture.md +++ b/docs/architecture/infrastructure-architecture.md @@ -137,7 +137,7 @@ flowchart TB | Device | What it runs | Notes | |---|---|---| -| Operator console (1 per court) | admin scoring SPA | tablet/desktop surface; authenticates with the tournament password; scores its own shiai-jo | +| Operator console (1 per court) | admin scoring SPA | tablet/desktop surface; authenticates with the admin password; scores its own shiai-jo | | Display screen (1 per court, optional) | public display / scoreboard view | a browser at a display URL; read-only, no auth. **Preferred: drive it from the operator console's own machine** via an HDMI cable to a TV or monitor, so the board survives a Wi-Fi outage (refer to [Keep the court scoreboard alive on the same machine](#keep-the-court-scoreboard-alive-on-the-same-machine-hdmi)). A standalone smart-TV browser or separate mini-PC also works but loses that offline path | | Spectator phones | public viewer (mobile-first) | can be on cellular; they don't need venue Wi-Fi when the app is cloud-hosted | diff --git a/docs/architecture/network-architecture.md b/docs/architecture/network-architecture.md index be6b373d8..60e64dbf2 100644 --- a/docs/architecture/network-architecture.md +++ b/docs/architecture/network-architecture.md @@ -152,8 +152,8 @@ Key client mechanisms (all in `web-mobile/js/api_client.jsx` + consumers): |---|---| | Half-open / stalled sockets | 12s write timeouts, 35s SSE silence watchdog (armed at connect, not only `onopen`) | | Reconnect storms | exponential backoff + jitter (vs. a fixed delay) | -| Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it takes the place of a queued save of the same lineup, because a lineup save restates the whole lineup and carries no time the server could order it by. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) | -| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision whose first send landed but whose answer was lost is answered as recorded when it is sent again | +| Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it joins a queued save of the same lineup, which then names every position either changed, with the later value where both changed one. A lineup save names only the positions it changed (`changed`), and the server lands only those, but it carries no time the server could order it by, so a save sent straight to the server could land before the queued one and lose to it on a position both changed. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) | +| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision or finish whose first send landed but whose answer was lost is answered as recorded when it is sent again: the same write under the same stamp changes nothing, so it is not asked for a correction reason and nothing is written to the match; the match's history gains a line saying the write arrived and changed nothing | | Edits not yet sent | a score editor holds each edit for a short autosave window (300ms) before it writes it. The sync pill reads "Syncing…" from the first tap, not "Synced". If the editor goes away inside that window (closed, Prev/Next, another match picked, a court switch), the edit is written at once. If the tab is hidden or unloaded inside it, the edit is written straight into the outbox, so a reload does not lose it. An autosave carries the time of the tap, not the time it is sent, so it never outranks a result another device recorded after the tap. A running write already sent but not yet answered is also kept when the page goes away, which cancels its request; while the request is still open, that copy is not sent | | Server errors (5xx / 429), and 403 | write stays queued and keeps retrying until it lands, holding back no other write; after 10 consecutive rejections the operator gets a notice and the sync pill shows "Not saving", and the match's score editor offers **Discard held result**, which discards that write and nothing else after a confirm. The held-writes count in the admin topbar opens a list of every held write (score updates, results, decisions, winners set by hand, lineups), where any write that keeps failing can be discarded the same way, one at a time; a write only waiting for the connection cannot be discarded there. On this server 403 is never a bad credential (that is 401): it means the tournament is not configured yet, or is missing its password. Only an admin fixing the server state clears it, so signing in again cannot help and the write keeps retrying instead of parking | | Invalid credential (401) | the one 4xx a retry can fix: the write is parked, not discarded, stops retrying, and shows "Sign in to save"; signing in again re-sends it with the new credential, together with every other queued write still carrying the refused password (one queued after it, before its own 401 came back), so one sign-in is enough. A write queued without a password (a competitor's in a self-run event) is never given one | @@ -178,7 +178,8 @@ flowchart TD ``` - **File mode** (default): plaintext compare against `tournament.md`. `POST /api/tournament/reset` - is available (for a forgotten admin password). + (for a forgotten admin password) answers only a caller on the machine running the server, + judged on the TCP peer address; any other caller gets 403 `reset_local_only`. - **Locked mode** (`--lock-password` / `LOCK_PASSWORD=true` + `TOURNAMENT_PASSWORD_HASH`): bcrypt compare; reset endpoint returns 404. `GET /api/auth-config` reports the mode to the SPA. diff --git a/docs/dev-guide/contributing.md b/docs/dev-guide/contributing.md index b36a9d303..77147457f 100644 --- a/docs/dev-guide/contributing.md +++ b/docs/dev-guide/contributing.md @@ -81,6 +81,21 @@ An explicit `--folder`, `--port`, or `--bind` flag still overrides the env var. Editing these frontend sources and refreshing the browser does **not** pick up changes. The browser still gets the embedded bundle from the last binary build. +### Run the end-to-end journeys + +The end-to-end journeys drive the tournament app in a real browser, the way a court operator and a spectator use it: an iPad-sized touch screen for the operator and a phone for the public pages. Run them before you open a pull request that changes `web-mobile/`; the pull request template asks for it. + +```sh +make e2e # every journey +make e2e E2E_ARGS='journeys/smoke.spec.mjs' # one journey file +make e2e E2E_ARGS='--grep "score on a shiaijo"' # one test by title +make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' # watch it run +``` + +`make e2e` builds the binary, then runs each journey file against its own server on a free port with an empty data folder. You need Go, `node` with `npm`, and a network connection: the first run installs the harness's packages and its Chromium, and every build downloads the frontend libraries. The journeys are not part of `make go/test` and do not run in CI. + +The harness's own guide, [`scripts/e2e/README.md`](https://github.com/gitrgoliveira/bracket-creator/blob/main/scripts/e2e/README.md), covers the rest: the devices, the console policy that fails a journey on a browser error, where screenshots and traces are written, how to add a journey, and what to do when a journey finds a bug. + ## Regenerate the documentation screenshots Every screenshot and video under `docs/screenshots/` and `docs/videos/` is captured from the running application by a script, so a picture cannot quietly drift from the product it shows. If your change alters anything a user sees, recapture what it reaches with `make docs/media SINCE=main` before you open the pull request. diff --git a/docs/user-guide/commands/mobile-app.md b/docs/user-guide/commands/mobile-app.md index 34b1e055e..2376b69db 100644 --- a/docs/user-guide/commands/mobile-app.md +++ b/docs/user-guide/commands/mobile-app.md @@ -25,6 +25,7 @@ Refer to the [Tournament app guide](../organisers/run-tournament.md) for a full | `BIND_ADDRESS` | `--bind` default | Initial value for `--bind` if the flag isn't set. | | `LOCK_PASSWORD` | `--lock-password` default | `true` enables locked mode without passing the flag. | | `TOURNAMENT_PASSWORD_HASH` | locked mode auth | Bcrypt hash of the admin password. Required when `--lock-password` is set. Generate with [`hash-password`](hash-password.md). | +| `TOURNAMENT_ADMIN_PASSWORD_HASH` | nothing | Ignored. When it is set, the server logs a line at startup saying so. The admin password above covers every action. Refer to [Upgrading from an earlier release](../organisers/operating-modes.md#upgrading-from-an-earlier-release). | ## Authentication @@ -34,7 +35,7 @@ The server has two authentication modes for the admin console; viewer routes are The admin password is stored plaintext in `tournament-data/tournament.md` and compared by exact-string match. Set the password during the in-app **Create tournament** flow, or edit `tournament.md` directly. -- `POST /api/tournament/reset` is enabled and unauthenticated. If you've forgotten the current password, browse to `http:///reset` from any device on the same network to set a new one. +- `POST /api/tournament/reset` needs no password but answers only a request from the machine running the server (a loopback address) that is addressed to a loopback name: `localhost`, `127.0.0.1` or `[::1]`. Any other request, from another machine or addressed by another name, gets `403` with the code `reset_local_only`. If you've forgotten the current password, browse to `http://localhost:/reset` on the machine running the server to set a new one. In a Docker container run with `-p`, a browser on the host reaches the server through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. - `GET /api/auth-config` returns `{"mode": "file", "resetEnabled": true}`. ### Locked mode (`--lock-password`) @@ -63,8 +64,8 @@ Recommended for any deployment reachable over the internet. The plaintext-in-fil #### Operational caveats -- **`POST /api/tournament/reset` has no rate limiting.** The endpoint is unauthenticated and the server does not throttle calls. For internet-exposed deployments, run with `--lock-password` (which 404s `POST /api/tournament/reset`) OR front the server with a reverse proxy that rate-limits that path. -- **Locked mode has no bcrypt brute-force protection.** The server runs a full bcrypt comparison (`bcrypt.DefaultCost` approx 50-100 ms) on every `X-Tournament-Password` header, but does not throttle repeated failed attempts. For internet-exposed locked-mode deployments, add a reverse proxy that rate-limits authenticated routes (for example, nginx's `limit_req`) in addition to `POST /api/tournament/reset`. +- **`POST /api/tournament/reset` is limited to the machine running the server, judged on the connection's peer address and on the request's `Host`, which must be a loopback name.** `X-Forwarded-For` and similar headers are not consulted. The `Host` rule means a web page whose domain was rebound to `127.0.0.1` in a browser on the server machine is refused. Behind a reverse proxy on the same machine, every request arrives from the proxy's loopback address, so the reset is reachable by whoever the proxy exposes it to; block that path at the proxy or run with `--lock-password` (which 404s `POST /api/tournament/reset`). +- **Locked mode has no bcrypt brute-force protection.** The server runs a full bcrypt comparison (`bcrypt.DefaultCost` approx 50-100 ms) on every `X-Tournament-Password` header, but does not throttle repeated failed attempts. For internet-exposed locked-mode deployments, add a reverse proxy that rate-limits authenticated routes (for example, nginx's `limit_req`). - **Mode switching preserves the stored password.** Switching from file mode to locked mode does NOT erase `tournament.md`'s `password` field; auth stops consulting it. A later switch back to file mode resurrects the original password. This is a deliberate rollback feature, but anyone with filesystem access can still read the value. To fully retire a file-mode credential before going locked, `POST /api/tournament/reset` it to a one-time throwaway first. ## Competition configuration diff --git a/docs/user-guide/competitors/self-run.md b/docs/user-guide/competitors/self-run.md index 579bb8005..61f6bca6b 100644 --- a/docs/user-guide/competitors/self-run.md +++ b/docs/user-guide/competitors/self-run.md @@ -18,15 +18,15 @@ When the tournament is self-run, a public self-registration page is open for ind If the form says someone with your name is already registered, your name and dojo are already on the list and you are done. A namesake from another dojo registers normally. If you are a different person with the same name at the same dojo, ask the organiser to add you. -## Check in on the day +## Check-in -If the competition uses check-in, mark yourself present before the organiser generates the draw. Check-in happens at the **Registration desk**, which you open from the dashboard: it gathers the competitors of every competition that uses check-in, so find your name there and mark yourself present. Self-run mode does not require a password for this. Competitors who have not checked in are excluded from the draw. +Self-run mode has no check-in. There is nothing to mark on the day: the draw includes everyone on the roster, so make sure your name is on it before the organiser generates the draw. ## Report your own score You can record your match result directly from the public viewer, without the admin password. A result you enter is tagged as self-reported; a result entered by an operator is tagged as admin. -Only everyday match outcomes are available to self-reporters. Decisions that require an official ruling remain with the operator, and so do destructive actions such as editing the roster. For a full list of outcomes and what they mean, refer to [Recording decisions](../court-operators/recording-decisions.md). +Only everyday match outcomes are available to self-reporters. Decisions that require an official ruling remain with the operator, and so do destructive actions such as deleting a competition or discarding a draw. For a full list of outcomes and what they mean, refer to [Recording decisions](../court-operators/recording-decisions.md). When a knockout team match finishes level, you can add the representative bout ([daihyosen](../court-operators/recording-decisions.md#daihyosen)) from the score sheet, score it like any other bout, and finish the match on the winner it decides. A kachinuki match has no representative bout: a tied pairing that must produce a result is settled by encho on that same bout (refer to [Scoring a kachinuki encounter](../organisers/team-tournaments.md#scoring-a-kachinuki-encounter)). A representative bout added by mistake can be removed while it is still unscored, until the match finishes; after that, only the organiser can add or remove one. The score sheet does not offer a judges' decision (hantei): that stays with the organiser, on the representative bout as on every other bout. When the organiser records one, the score sheet shows the representative bout as decided and you cannot change it, but you can still finish the match on that decision. diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index 53f28cbbc..7284dd6b5 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -11,7 +11,7 @@ Open a match from the Upcoming list to start scoring it; the score editor opens !!! tip In self-run events, competitors or table helpers can record their own scores without the admin password. Refer to the [Competitor self-run guide](../competitors/self-run.md) and [Operating modes and access control](../organisers/operating-modes.md) for how that works. -On a laptop you can score without the mouse. When scoring an individual match or a kachinuki bout, press **M**, **K**, **D**, **T**, or **H** to award that strike to Shiro (White). Hold **Shift** with the same key to award it to Aka (Red). **Left** and **Right** move to the previous or next match on the court, and **Esc** closes the editor. On a tablet, use the on-screen buttons. +On a laptop you can score without the mouse. When scoring an individual match or a kachinuki bout, press **M**, **K**, **D**, **T**, or **H** to award that strike to Shiro (White). Hold **Shift** with the same key to award it to Aka (Red). In the score editor opened from the **Scores** tab, **Left** and **Right** move to the previous or next match on the court. **Esc** closes a score editor that opens over the page; the editor on the court console has no **Esc**, **Left** or **Right** shortcut. On a tablet, use the on-screen buttons. Everything you enter on a running match is saved as you go, so closing the editor or moving to another match keeps it, even straight after a tap. Two things are only kept when you press **Finish** or save a correction: a **Mark draw** on a running match, and changes to a finished match. If you close the editor or move to another match with one of those not yet saved, the app asks first: **Discard changes** drops it, and **Cancel** keeps you on the match. diff --git a/docs/user-guide/install/hosting.md b/docs/user-guide/install/hosting.md index 15e66eac5..36fcf6a35 100644 --- a/docs/user-guide/install/hosting.md +++ b/docs/user-guide/install/hosting.md @@ -24,7 +24,7 @@ The Docker baseline is the reusable core: the GCP and Oracle Terraform modules r ## Security -Any deployment reachable over the internet should run in locked mode rather than the default file mode. Locked mode reads a bcrypt hash from an environment variable and disables the public password-reset endpoint. Refer to [Admin authentication](../organisers/operating-modes.md#admin-authentication-mode) for the setup steps. +Any deployment reachable over the internet should run in locked mode rather than the default file mode. Locked mode reads a bcrypt hash from an environment variable and turns the password-reset endpoint off (in file mode it answers only the machine running the server). Refer to [Admin authentication](../organisers/operating-modes.md#admin-authentication-mode) for the setup steps. ## What still needs printing diff --git a/docs/user-guide/organisers/operating-modes.md b/docs/user-guide/organisers/operating-modes.md index ef807b14f..f7157a446 100644 --- a/docs/user-guide/organisers/operating-modes.md +++ b/docs/user-guide/organisers/operating-modes.md @@ -8,13 +8,17 @@ Tournament mode controls who can score matches and manage competitions. You choo ### Officiated mode -Officiated mode is the default. Every action (scoring, check-in, starting, and completing competitions) requires the admin password. All recorded results carry the label "admin" to show they were entered by an operator. +Officiated mode is the default. Every action (scoring, check-in, starting, and completing competitions) requires the admin password. Once you are signed in, one action asks for the password again: **Delete competition** (in the competition's Settings) opens a dialog that names the competition and asks you to type the admin password. A wrong password leaves the dialog open with "Wrong password. The competition was not deleted." and you stay signed in. The server checks the password typed in the dialog, so a request that does not carry it is refused too. Every other action, discarding a draw and marking a competition invalid included, needs no second password. All recorded results carry the label "admin" to show they were entered by an operator. ### Self-run mode -In self-run mode, scoring, check-in, and starting competitions are open to anyone without a password, so competitors or table helpers can run and score their own matches. Completing a competition is irreversible, so it stays behind the destructive-ops password (refer to [Destructive-ops password](#destructive-ops-password)). A public self-registration page also becomes available for competitors to sign themselves up, for individual competitions only and only while the competition is still in setup, before the draw is generated. +In self-run mode, scoring and starting a competition are open to anyone without a password, so competitors or table helpers can run and score their own matches. Completing a competition needs no password either: in self-run mode the server accepts it from anyone. The **Complete competition** button is on the organiser's competition page, which you reach by signing in. A public self-registration page also becomes available for competitors to sign themselves up, for individual competitions only and only while the competition is still in setup, before the draw is generated. -Two kinds of action stay gated in self-run mode. Organiser setup (creating and editing competitions, tournament settings, seeds, scheduling, the **Lineups** page, match decisions such as kiken, and exports) still requires the admin password. Destructive actions (deleting a competition, discarding a draw, editing the roster, and completing a competition) require the destructive-ops password (refer to [Destructive-ops password](#destructive-ops-password)). +Organiser actions stay behind the admin password in self-run mode: creating and editing competitions, tournament settings, the tournament logo and sponsor images, seeds, scheduling, the **Lineups** page, match decisions such as kiken, result corrections (reopening a match, setting a winner by hand, overriding a pool rank), announcements, league tie-breaker decisions (adding or removing tie-breaker matches, and accepting shared ranks), exports, fighting-spirit awards, and every action that destroys data (deleting or invalidating a competition, discarding a draw, resetting manual overrides, and importing competitions). + +The roster is open to anyone in self-run mode: anyone can add and edit a competition's participants, without the password, before the competition starts. + +Self-run mode has no check-in. The **Check-in tracking** setting is not offered, the **Registration desk** is not offered, the server refuses every check-in request, and the draw includes every participant on the roster. A competition keeps the check-in setting it was saved with, but a self-run tournament ignores it. Competitors can name the fighters in a team match from its score sheet, without the password. A team member picked for a bout, or a typed name, is saved with that match, and a typed name fills in a team member who has no name yet or adds a new one. Two changes stay with the organiser: the lineup of a match that has finished, and the name of a team member who already has one. @@ -22,9 +26,6 @@ Competitors also run the representative bout (daihyosen) of a tied knockout team Results in self-run mode carry a provenance label. A score entered without a password is tagged "self-reported"; a score entered by an authenticated operator is tagged "admin". Officiated mode always produces "admin" results. -!!! note - In file mode, self-run requires a destructive-ops password. Without one, destructive actions would be unprotected, so the app refuses to create or save a self-run tournament until a destructive-ops password is set (you can set it in the same step). - Refer to the [Competitor self-run guide](../competitors/self-run.md) for the attendee-facing workflow. ## Admin authentication mode @@ -35,7 +36,7 @@ Authentication mode controls how the admin password is stored and verified. Ther In file mode, the admin password is stored in plain text in the `tournament.md` data file. You set it during tournament creation, or you can edit the file directly. -If you forget the password, browse to `http:///reset` from the same network and choose a new one. No old password is required. The reset path is intentionally unauthenticated, which is acceptable on a trusted private network but is a security risk on the public internet. +If you forget the password, browse to `http://localhost:/reset` in a browser on the machine running the server and choose a new one. No old password is required. In file mode the password can be reset only from that machine: a request from any other machine, or one addressed by a name other than `localhost`, `127.0.0.1` or `[::1]`, is refused with "Reset the password on the machine running the server." When the server runs in a Docker container started with `-p`, a browser on the host reaches it through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. !!! note Switching from file mode to locked mode does not erase the on-disk password. The app ignores it while locked mode is active. Switching back re-enables the stored value. @@ -67,50 +68,20 @@ In locked mode: Refer to the [`hash-password` command reference](../commands/hash-password.md) for full details. -## Destructive-ops password - -The destructive-ops password is a second, separately held credential that gates actions which are difficult to reverse. It lets table staff hold the main admin password for scoring, check-in, and match management, so they cannot accidentally or maliciously destroy data. - -Actions gated by the destructive-ops password include: - -- Deleting or invalidating a competition -- Discarding a generated draw -- Resetting manual overrides for a competition -- Adding, editing, or replacing participants -- Importing competitions -- Recording or editing fighting-spirit awards -- Completing a competition (completing is irreversible) - -The following are NOT gated by the destructive-ops password: scoring, match decisions, check-in, starting a competition, lineup management, or the `/reset` path. - -The admin UI prompts for the destructive-ops password each time you request a gated action. There is no cached session between prompts. - -### In file mode - -Set the destructive-ops password from **Admin > Edit details > Destructive-ops password**. The field is write-only and is never displayed. Changing it after it has been set requires supplying the current value first. While the field is unset, gated actions fall back to requiring the main admin password only. If you forget it, edit the `admin_password` field in `tournament.md` between rounds, as with any hand edit to the data folder. The main admin password is the `password` field in the same file. - -### In locked mode - -Supply the destructive-ops password as a bcrypt hash in the `TOURNAMENT_ADMIN_PASSWORD_HASH` environment variable. It cannot be changed through the UI. - -```bash -printf '%s' "$MY_DESTRUCTIVE_OPS_SECRET" | bracket-creator hash-password - -TOURNAMENT_PASSWORD_HASH='$2a$10$...main...' \ - TOURNAMENT_ADMIN_PASSWORD_HASH='$2a$10$...destructive...' \ - bracket-creator mobile-app --lock-password -f ./tournament-data -``` - -In locked mode, the destructive-ops gate is always active. If `TOURNAMENT_ADMIN_PASSWORD_HASH` is unset or malformed, the gated actions are refused regardless of the admin password supplied. A malformed hash does not prevent the server from starting, but no destructive action can be completed until a valid hash is provided. - ## Security notes -The admin and destructive-ops passwords provide privilege separation for shared-credential operation at an event. They are not a network-security boundary on their own. +The admin password is not a network-security boundary on its own. -Over plain HTTP, both passwords travel in clear text on every request. Anyone with filesystem access to `tournament.md` can read both passwords in file mode. +Over plain HTTP, the password travels in clear text on every request. Anyone with filesystem access to `tournament.md` can read it in file mode. For a real security boundary: - Run the server behind TLS. Refer to [Hosting](../install/hosting.md) for guidance. - Use locked mode for any deployment reachable from the internet. -- In file mode, the password reset is unauthenticated: anyone who can reach the server can set a new admin password without knowing the current one, which locks out the operator or takes over the tournament. Locked mode disables reset entirely, so any internet-exposed deployment should use locked mode. +- In file mode, the password reset needs no current password but is limited to the machine running the server, so someone who can open a browser on that machine can set a new admin password. Behind a reverse proxy on the same machine, requests arrive from the proxy's own address, so block `/reset` at the proxy. Locked mode turns reset off, so any internet-exposed deployment should use locked mode. + +### Upgrading from an earlier release + +The admin password is the only organiser password. Anyone who signs in with it can do every organiser action, including discarding a draw, and deleting a competition once they type the password again. If some staff should not be able to do this, give the tournament a new admin password and share it only with the people who should have it. + +A `tournament.md` that carries an `admin_password` field loads normally; the field is ignored. It may hold another password in plain text. The app drops the field the next time it writes `tournament.md`, for example when you change the tournament settings; until then, remove the line by hand before you share a copy of the data folder. The `TOURNAMENT_ADMIN_PASSWORD_HASH` environment variable is ignored: when it is set, the server logs a line at startup saying so, and you can remove it from your start-up scripts. diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 2c98a61f2..6e0b3b4c0 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -83,7 +83,7 @@ A court call, made with **Call to court** on the court console, is announced the ## Registration desk -Open **Registration desk** from the dashboard to access the check-in surface for the welcome table. Check-in exists only for competitions with **Check-in tracking** turned on in their Settings, and this desk is the one place to do it: it lists every competitor across those competitions so a registration helper can mark participants present as they arrive. A competition with the setting off does not appear here, and its Ordering & seeding panel carries no check-in controls either. Refer to the [Check-in workflow](#check-in-workflow). +Open **Registration desk** from the dashboard to access the check-in surface for the welcome table. Check-in exists only for competitions with **Check-in tracking** turned on in their Settings, and this desk is the one place to do it: it lists every competitor across those competitions so a registration helper can mark participants present as they arrive. A competition with the setting off does not appear here, and its Ordering & seeding panel carries no check-in controls either. A self-run tournament has no check-in, so the dashboard offers no Registration desk there. Refer to the [Check-in workflow](#check-in-workflow). ## Set up a competition @@ -197,7 +197,7 @@ Click the pencil icon on any row to open the edit modal for that competitor. You ### Check-in workflow -Enable **Check-in tracking** in **Settings** for the competition. Competitors are checked in at the [Registration desk](#registration-desk), which works across every competition; the Ordering & seeding panel carries no check-in controls. +Enable **Check-in tracking** in **Settings** for the competition. Competitors are checked in at the [Registration desk](#registration-desk), which works across every competition; the Ordering & seeding panel carries no check-in controls. Self-run mode has no check-in: the setting is not offered and the draw includes every participant on the roster ([operating modes](operating-modes.md)). The check-in rule is opt-in: when you click **Generate draw**, if at least one participant is checked in, only checked-in participants join the draw and unchecked participants are excluded (their seeds are dropped). If nobody is checked in, everyone is included. @@ -251,13 +251,15 @@ For naginata and Engi-kyogi divisions, refer to [Naginata](naginata.md). The public viewer shows a competition's podium when it finishes, and a provisional ranking while it is still in progress. +A knockout or pools-then-knockout competition finishes when you tap **Complete competition** on its page. The button appears once every bracket match, the bronze match included when there is one, has been played, and completing cannot be undone. A league finishes by itself after its last match and any tie-breakers. In a team league, when a tie left after every ranking criterion is consequential, you first run or decline the tie-breaker; any other tie is left as a shared rank with no decision (refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks)). A Swiss competition has no **Complete competition** button. After its last round, its organiser page shows a **Competition complete** notice and a link to the final standings, which the public viewer also shows. + The **Award two joint 3rd places** setting, on the competition's Settings tab, controls whether a competition awards two joint 3rd places or decides a single one with a bronze match. It is on by default (the standard kendo convention) and applies to knockout, pools-then-knockout, and league competitions. It is not shown for Swiss, which has no bracket and no bronze match. Naginata competitions default the setting off (a single 3rd, decided by a bronze match). You can turn it back on for a naginata competition too, and you can turn it off for a kendo competition that needs a single 3rd, for example a selection event with one bronze medal. Refer to [Naginata](naginata.md#third-place-match) for the bronze match itself. - **Knockout** (default: joint 3rds on): 1st place, 2nd place, and two equal 3rd places. There is no bronze match; both semi-final losers share third. - **Knockout with joint 3rds off**: a single 3rd place is decided by a bronze match. - **Pools + Knockout** (still in its pool phase): the viewer shows a provisional cross-pool ranking until the knockout decides the final places. -Operators see an all-competition winners view from the dashboard. You can also record optional **fighting-spirit** (敢闘賞) awards as free text; these appear on the viewer for all spectators. Saving awards requires the destructive-ops password in self-run mode; refer to [Operating modes](operating-modes.md#destructive-ops-password). +Operators see an all-competition winners view from the dashboard. You can also record optional **fighting-spirit** (敢闘賞) awards as free text; these appear on the viewer for all spectators. Saving awards is an organiser action, so it needs the admin password in self-run mode too; refer to [Operating modes](operating-modes.md#self-run-mode). **League competitions** derive the podium from final standings. In an individual league, any tie within the top three places triggers a short ippon-shobu tie-breaker automatically, so the competition never closes with an unearned tie. Engi kata competitions never hold supplementary bouts; they rank by wins, then accumulated flags (refer to [Naginata](naginata.md#standings)). The one exception is 3rd place: with **Award two joint 3rd places** enabled, competitors tied entirely for third share the position instead, with no decider. In a team league, the operator chooses whether to run a tie-breaker or accept a tie at any position; refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks). @@ -287,7 +289,7 @@ When the **Public URL** is set and competitors have assigned numbers, each print Tournament state is stored as plain files inside the data folder you specified with `--folder`. You can hand-edit these files between rounds when a correction is needed: -- `tournament.md`: YAML front-matter with the tournament name, date, venue, court count, and the admin password and destructive-ops password. +- `tournament.md`: YAML front-matter with the tournament name, date, venue, court count, and the admin password. - `competitions//config.md`: YAML front-matter with competition kind, format, pool settings, and courts. - `competitions//participants.csv`: one participant per line with name, optional display name (zekken), dojo, and optional dan grade. diff --git a/internal/engine/checkin_self_run_test.go b/internal/engine/checkin_self_run_test.go new file mode 100644 index 000000000..10ac867a9 --- /dev/null +++ b/internal/engine/checkin_self_run_test.go @@ -0,0 +1,126 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Operator ruling 2026-10-13: "Self run mode has no check-in." The draw, the +// Swiss field and the schedule estimate all ask Competition.CheckInApplies, so +// a competition whose stored CheckInEnabled is true still draws everyone in a +// self-run tournament, while an officiated one keeps filtering. + +func saveTournamentMode(t *testing.T, store *state.Store, mode string) { + t.Helper() + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "Cup", Courts: []string{"A"}, Mode: mode, + })) +} + +var checkInRoster = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"} +var checkInFour = map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true} + +func TestSelfRun_KnockoutDraw_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-knockout" + createTestCompetition(t, store, compID, "knockout", 0) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, checkInRoster, checkInFour) + + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + names := bracketLeafNames(bracket) + assert.Len(t, names, 5, "self-run draws the whole roster") + assert.Contains(t, names, "Eve", "a participant who is not checked in is still drawn") + + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.True(t, comp.CheckInEnabled, "the stored setting is not rewritten") +} + +func TestSelfRun_PoolDraw_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-pools" + createTestCompetition(t, store, compID, "league", 3) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + require.NoError(t, eng.StartCompetition(compID)) + + pools, err := store.LoadPools(compID) + require.NoError(t, err) + assert.Len(t, poolPlayerNames(pools), 6, "self-run draws the whole roster") +} + +func TestOfficiated_KnockoutDraw_StillFiltersByCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeOfficiated) + compID := "officiated-knockout" + createTestCompetition(t, store, compID, "knockout", 0) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, checkInRoster, checkInFour) + + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + names := bracketLeafNames(bracket) + assert.Len(t, names, 4) + assert.NotContains(t, names, "Eve") +} + +func TestSelfRun_SwissRoundOne_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-swiss" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Swiss", Kind: "individual", Format: state.CompFormatSwiss, + SwissRounds: 3, Courts: []string{"A"}, StartTime: "09:00", Status: "setup", + CheckInEnabled: true, + })) + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + matches, err := eng.GenerateSwissRound(compID, 1) + require.NoError(t, err) + + seen := map[string]bool{} + for _, m := range matches { + seen[m.SideA] = true + seen[m.SideB] = true + } + assert.Contains(t, seen, "Eve") + assert.Contains(t, seen, "Frank") +} + +func TestSelfRun_ScheduleEstimate_CountsWholeRoster(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-estimate" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatKnockout, Kind: "individual", + Courts: []string{"A"}, StartTime: "09:00", KnockoutMatchDurationSeconds: 300, + Status: state.CompStatusSetup, CheckInEnabled: true, + })) + // 8 registered, 4 checked in: the estimate must count all 8 (7 bouts). + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Hank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + est, err := eng.EstimateScheduleForCompetition(compID) + require.NoError(t, err) + + comp, _ := store.LoadCompetition(compID) + tourn, _ := store.LoadTournament() + assert.Equal(t, EstimateForCounts(0, 7, comp, tourn).TotalDurationMinutes, est.TotalDurationMinutes) +} diff --git a/internal/engine/competition.go b/internal/engine/competition.go index 77ff52b45..a6e2fc9ea 100644 --- a/internal/engine/competition.go +++ b/internal/engine/competition.go @@ -15,6 +15,36 @@ import ( // element-wise equal (used by StartCompetition's mid-pipeline // settings-drift check). nil and empty slices are treated as // equivalent, both mean "no courts" from the config's POV. +// PoolMatchesSettled reports whether a competition's pool-matches.csv rows +// (pool, league and Swiss matches alike) are all played: every match is +// completed, and a supplementary tie-break or representative bout also names +// a winner, since one decided without a winner (a hikiwake) leaves the tie it +// was played for unresolved. It is the rule a league is completed by +// (maybeAutoCompletePoolsRunning) and the one POST /complete asks of a +// competition with no knockout rounds to check. Vacuously true with no rows. +func PoolMatchesSettled(matches []state.MatchResult) bool { + for _, m := range matches { + if m.Status != state.MatchStatusCompleted { + return false + } + if (IsPoolDaihyosenMatchID(m.ID) || IsTiebreakerMatchID(m.ID)) && m.Winner == "" { + return false + } + } + return true +} + +// SwissRoundsToDraw reports how many of a Swiss competition's configured +// rounds have not been drawn yet: the inverse of the bound AdvanceSwissRound +// refuses a further round by. 0 for any other format, and for a Swiss +// competition with no configured round count. +func SwissRoundsToDraw(comp *state.Competition) int { + if comp == nil || comp.Format != state.CompFormatSwiss || comp.SwissRounds <= 0 || comp.SwissCurrentRound >= comp.SwissRounds { + return 0 + } + return comp.SwissRounds - comp.SwissCurrentRound +} + func courtsEqual(a, b []string) bool { if len(a) != len(b) { return false @@ -462,13 +492,8 @@ func (e *Engine) maybeAutoCompletePoolsRunning(compID string, comp *state.Compet if ferr != nil { return nil, ferr } - for _, m := range freshMatches { - if m.Status != state.MatchStatusCompleted { - return nil, nil - } - if (IsPoolDaihyosenMatchID(m.ID) || IsTiebreakerMatchID(m.ID)) && m.Winner == "" { - return nil, nil - } + if !PoolMatchesSettled(freshMatches) { + return nil, nil } comp.Status = state.CompStatusComplete return comp, nil @@ -1149,8 +1174,9 @@ func (e *Engine) runDrawPipeline(id string) error { if err != nil { return err } - // Exclude participants who have not checked in (mp-w7x), but ONLY when the - // competition has check-in tracking enabled (comp.CheckInEnabled). The rest + // Exclude participants who have not checked in (mp-w7x), but ONLY when + // check-in applies to the competition (comp.CheckInApplies: its setting is + // on and the tournament is not self-run, which has no check-in). The rest // of the stack masks checkedIn behind this flag (the viewer derives // checkedIn as `checkInEnabled && p.checkedIn`), so a stale/imported // checked_in marker on a competition that doesn't use check-in must not @@ -1170,7 +1196,7 @@ func (e *Engine) runDrawPipeline(id string) error { // same-named participants by dojo. var excludedByCheckIn map[string]bool fullRoster := players - if comp.CheckInEnabled { + if comp.CheckInApplies(tourn) { excludedByCheckIn = checkInExcludedKeys(players) players = filterCheckedIn(players) } diff --git a/internal/engine/estimate_schedule.go b/internal/engine/estimate_schedule.go index 2ab7a93d2..395fb65f2 100644 --- a/internal/engine/estimate_schedule.go +++ b/internal/engine/estimate_schedule.go @@ -26,7 +26,7 @@ func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate } // Step 3: derive participant count. - playerCount, err := e.estimateParticipantCount(comp) + playerCount, err := e.estimateParticipantCount(comp, tournament) if err != nil { return ScheduleEstimate{}, err } @@ -63,12 +63,12 @@ func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate // estimateParticipantCount returns the number of participants for a // competition, applying the check-in filter when enabled (mirroring // runDrawPipeline's filterCheckedIn opt-in semantics). -func (e *Engine) estimateParticipantCount(comp *state.Competition) (int, error) { +func (e *Engine) estimateParticipantCount(comp *state.Competition, tournament *state.Tournament) (int, error) { players, err := e.store.LoadParticipants(comp.ID, comp.EffectiveWithZekkenName()) if err != nil { return 0, err } - if comp.CheckInEnabled { + if comp.CheckInApplies(tournament) { players = filterCheckedIn(players) } return len(players), nil diff --git a/internal/engine/exact_score_replay_test.go b/internal/engine/exact_score_replay_test.go new file mode 100644 index 000000000..68f05845a --- /dev/null +++ b/internal/engine/exact_score_replay_test.go @@ -0,0 +1,68 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// bc-xrpl: ExactScoreReplay tells the resend of a recorded finish (its +// answer lost) from a correction made under the same stamp. +func TestExactScoreReplay(t *testing.T) { + const at = 1_700_000_000_000 + stored := func() *state.MatchResult { + return &state.MatchResult{ + ID: "Pool A-0", SideA: "Alice", SideAID: "a", SideB: "Bob", SideBID: "b", + Status: state.MatchStatusCompleted, Winner: "Alice", WinnerID: "a", + IpponsA: []string{"M", "K"}, IpponsB: []string{}, ModifiedAt: at, + } + } + resend := func() *state.MatchResult { + return &state.MatchResult{ + SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice", + IpponsA: []string{"M", "K"}, IpponsB: []string{}, ModifiedAt: at, + } + } + for _, tc := range []struct { + name string + change func(s, w *state.MatchResult) + want bool + }{ + {name: "the same write, ids filled in from the stored sides", change: func(_, _ *state.MatchResult) {}, want: true}, + {name: "naming only groups that are equal", change: func(_, w *state.MatchResult) { w.Changed = []string{state.GroupPoints, state.GroupResult} }, want: true}, + {name: "a stored correction reason the resend omits", change: func(s, _ *state.MatchResult) { s.CorrectionReason = "typo" }, want: true}, + {name: "another scoreline", change: func(_, w *state.MatchResult) { w.IpponsA = []string{"M", "D"} }}, + {name: "another winner", change: func(_, w *state.MatchResult) { w.Winner = "Bob" }}, + {name: "an older stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = at - 1 }}, + {name: "a newer stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = at + 1 }}, + {name: "no stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = 0 }}, + {name: "a running write", change: func(_, w *state.MatchResult) { w.Status = state.MatchStatusRunning }}, + {name: "a match not finished", change: func(s, _ *state.MatchResult) { s.Status = state.MatchStatusRunning }}, + {name: "another competitor", change: func(_, w *state.MatchResult) { w.SideB = "Carol" }}, + {name: "a winner id naming neither side", change: func(_, w *state.MatchResult) { w.WinnerID = "z" }}, + // R3: a point made after the finish landed; the resend's points are + // held by the merge (the stored ones are newer), so they are not its + // to differ in. + {name: "points the merge would hold, changed since", change: func(s, w *state.MatchResult) { + s.GroupStamps = map[string]int64{state.GroupPoints: at + 5_000, state.GroupResult: at} + s.ModifiedAt = at + 5_000 + w.IpponsA = []string{"M"} + }, want: true}, + {name: "points changed since, and another winner", change: func(s, w *state.MatchResult) { + s.GroupStamps = map[string]int64{state.GroupPoints: at + 5_000, state.GroupResult: at} + s.ModifiedAt = at + 5_000 + w.IpponsA, w.IpponsB, w.Winner = []string{}, []string{"M", "K"}, "Bob" + }}, + } { + t.Run(tc.name, func(t *testing.T) { + s, w := stored(), resend() + tc.change(s, w) + before := *w + assert.Equal(t, tc.want, ExactScoreReplay(s, w, nil, true)) + assert.Equal(t, before, *w, "the write is judged on a copy") + }) + } + assert.False(t, ExactScoreReplay(nil, resend(), nil, true)) +} diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index bda032785..7bf936619 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -704,6 +704,12 @@ type ForceOptions struct { // (HoldReasonOlderRevision): the ordering protection the rev guard gives, // without dropping anything. HoldReason string + // NoReplayHistory leaves out the history line an exact resend of a + // recorded finish would otherwise add (answerExactScoreReplay). The score + // handler sets it for an anonymous self-run participant, whose resend + // changes nothing and could otherwise be looped to grow the match's + // history file. The organiser's resend keeps its line. + NoReplayHistory bool } // HoldReasonOlderRevision is the history reason of a running write the same diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 9695a7828..276bd061a 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -52,6 +52,7 @@ import ( "errors" "fmt" "log" + "slices" "sort" "time" @@ -109,6 +110,12 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, if fo.HoldReason != "" { return nil, e.holdWriteTx(tx, compID, matchID, result, comp, fo.HoldReason) } + // An exact resend of a finish already recorded (a queued write whose + // first send landed but whose answer was lost) answers as recorded and + // writes nothing, before anything else judges it (bc-xrpl). + if replayed, err := e.answerExactScoreReplay(tx, compID, matchID, result, comp, fo.NoReplayHistory); err != nil || replayed { + return nil, err + } // Only a COMPLETING write goes through the engi recorder. Engi's flag-total // rule (odd, in {1,3,5}: a 3- or 5-referee panel cannot draw) can only be // satisfied by a real result, so routing every engi write through it @@ -210,88 +217,9 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // stores: a start's own representative-bout row is discarded above, and a // winner derived from it would stand beside a stored bout that says // otherwise. - deriveDaihyosenWinner(result) - - // The bout rows as the writer sent them, before the server's own changes - // below (the kachinuki merge, the default-win padding). A writer that - // named its groups did not name those: noteServerBoutChanges adds them, - // or the merge would put the stored rows back over them. - writerSubs := state.CloneSubResults(result.SubResults) - - // Kachinuki bout logs merge BY POSITION rather than replace wholesale - // (ACID: a client whose local log is behind the server must never - // destroy server-appended bouts). Applied here at the entry point, - // BEFORE the pool/bracket write primitives, so the rollback path - // below (which replays `prior` through those primitives) still - // restores the pre-write state exactly. A merge-time rejection (e.g. a - // kachinuki-exhaustion write ending on a tied bout, mp-gmcg review R2) - // returns BEFORE any write primitive, so nothing is persisted. - if merr := applyKachinukiMerge(comp, prior, result); merr != nil { - return nil, merr - } - - // Default-win bout padding (bc-tmfn follow-up): a non-kachinuki team - // match a default-win ruling (any kiken, fusenpai, or fusensho: - // domain.IsDefaultWinDecisionStr) closes before every numbered bout has - // a result of its own -- most commonly a kiken declared before the - // match's first bout was ever scored -- gets an empty SubMatchResult row - // (Position only) for every missing position 1..TeamSize, via - // state.NeedsDefaultWinBoutPadding / state.PadDefaultWinBoutPositions, the - // SAME predicate and function the legacy-load repair uses - // (state.EnsureLegacyUpgraded). Without a row for every position, - // state.DefaultWinCreditSide's readers (TeamResultFrom, - // accrueTeamSubResults, the Excel export) have nothing to range over for - // the positions nobody fought and silently credit them zero. - // - // Gated on PRIOR's sides and unreadable flag, never result's: a client - // score payload commonly omits SideA/SideB and relies on reconcileSides - // (inside applyPoolWrite/applyBracketResultIn, called later via - // writeToPoolOrBracket) to backfill them from the stored row -- reading - // result.SideA/SideB here would skip padding on exactly that common case - // (a bare kiken payload). Likewise a stored SubResults cell that failed - // to parse must never be padded over: NeedsDefaultWinBoutPadding's - // subResultsUnreadable check keeps this branch from turning an empty - // result.SubResults into a non-empty padded one, which would defeat - // applyPoolWrite's own raw-bytes preservation (it only keeps - // SubResultsRaw when the incoming SubResults is still empty) and destroy - // the only copy of the corrupt cell. prior is guaranteed non-nil here: - // the lookupExistingResult error above already returned on a miss. - // - // The decision tested is result's own, OR -- covering a CORRECTION that - // KEEPS a stored withdrawal ruling -- prior's: such a write's own - // Decision is either empty (a score sheet states no decision) or an - // explicit "hikiwake" (KeepsWithdrawalRuling's own two accepted shapes), - // and applyPoolWrite/applyBracketResultIn only reinstate the stored - // decision AFTER this point (preserveWithdrawalRuling), so testing - // result.Decision alone would miss it here -- and nothing downstream - // pads again once this function returns. A correction can legitimately - // send fewer SubResults rows than TeamSize (e.g. a bout-level edit that - // only touches the rows it corrects), so this is the one place that - // still needs to catch it up. - // - // Excluded: a bye (either side empty -- a bye never carries a - // default-win decision in practice, but the padding shape assumes two - // real teams either way) and a pool daihyosen/tiebreaker row - // (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), which is scored as a - // single individual representative bout, never the team's own numbered - // positions. Both checked inside NeedsDefaultWinBoutPadding. - if comp != nil && comp.TeamSize >= 2 && !comp.IsKachinuki() { - // KeepsWithdrawalRuling is checked UNCONDITIONALLY, not only when - // result.Decision == "": it is also true for an explicit incoming - // "hikiwake" over a stored default win (its own definition allows - // incoming.Decision to be "" OR "hikiwake"), and that write must - // still pad, since preserveWithdrawalRuling reinstates prior.Decision - // downstream regardless of which of the two the correction sent. - padDecision := result.Decision - if KeepsWithdrawalRuling(prior.Status, prior.Decision, result) { - padDecision = prior.Decision - } - if state.NeedsDefaultWinBoutPadding(result.Status, padDecision, prior.SideA, prior.SideB, matchID, - result.SubResults, prior.SubResultsUnreadable, comp.TeamSize) { - result.SubResults = state.PadDefaultWinBoutPositions(result.SubResults, comp.TeamSize) - } + if err := shapeScoreWrite(comp, prior, result, matchID); err != nil { + return nil, err } - noteServerBoutChanges(result, writerSubs, prior.SubResults, comp.IsKachinuki()) // K3 ahead of the write: a withdrawal whose loser a DIFFERENT match has // already made ineligible is refused before anything is written. The @@ -1220,6 +1148,235 @@ func exactDecisionReplay(prior *state.MatchResult, decision, decisionBy string, prior.GroupStamp(state.GroupResult) == stamp } +// shapeScoreWrite makes the server's own changes to a score write before the +// pool or bracket write merges it: the representative bout's winner, the +// kachinuki bout-log merge, the default-win bout padding, and the bout groups +// those changes add to a write that named its groups. One function, shared by +// RecordMatchResultWithIneligibilityTx and the exact-replay probe +// (exactScoreReplayProbe), so the probe reads a write exactly as it would land +// (bc-xrpl). prior is the stored match (never nil here); matchID the match's id. +func shapeScoreWrite(comp *state.Competition, prior, result *state.MatchResult, matchID string) error { + deriveDaihyosenWinner(result) + + // The bout rows as the writer sent them, before the server's own changes + // below (the kachinuki merge, the default-win padding). A writer that + // named its groups did not name those: noteServerBoutChanges adds them, + // or the merge would put the stored rows back over them. + writerSubs := state.CloneSubResults(result.SubResults) + + // Kachinuki bout logs merge BY POSITION rather than replace wholesale + // (ACID: a client whose local log is behind the server must never + // destroy server-appended bouts). Applied here at the entry point, + // BEFORE the pool/bracket write primitives, so the rollback path in + // RecordMatchResultWithIneligibilityTx (which replays `prior` through those primitives) still + // restores the pre-write state exactly. A merge-time rejection (e.g. a + // kachinuki-exhaustion write ending on a tied bout, mp-gmcg review R2) + // returns BEFORE any write primitive, so nothing is persisted. + if merr := applyKachinukiMerge(comp, prior, result); merr != nil { + return merr + } + + // Default-win bout padding (bc-tmfn follow-up): a non-kachinuki team + // match a default-win ruling (any kiken, fusenpai, or fusensho: + // domain.IsDefaultWinDecisionStr) closes before every numbered bout has + // a result of its own -- most commonly a kiken declared before the + // match's first bout was ever scored -- gets an empty SubMatchResult row + // (Position only) for every missing position 1..TeamSize, via + // state.NeedsDefaultWinBoutPadding / state.PadDefaultWinBoutPositions, the + // SAME predicate and function the legacy-load repair uses + // (state.EnsureLegacyUpgraded). Without a row for every position, + // state.DefaultWinCreditSide's readers (TeamResultFrom, + // accrueTeamSubResults, the Excel export) have nothing to range over for + // the positions nobody fought and silently credit them zero. + // + // Gated on PRIOR's sides and unreadable flag, never result's: a client + // score payload commonly omits SideA/SideB and relies on reconcileSides + // (inside applyPoolWrite/applyBracketResultIn, called later via + // writeToPoolOrBracket) to backfill them from the stored row -- reading + // result.SideA/SideB here would skip padding on exactly that common case + // (a bare kiken payload). Likewise a stored SubResults cell that failed + // to parse must never be padded over: NeedsDefaultWinBoutPadding's + // subResultsUnreadable check keeps this branch from turning an empty + // result.SubResults into a non-empty padded one, which would defeat + // applyPoolWrite's own raw-bytes preservation (it only keeps + // SubResultsRaw when the incoming SubResults is still empty) and destroy + // the only copy of the corrupt cell. prior is guaranteed non-nil here: + // the caller's lookupExistingResult error already returned on a miss. + // + // The decision tested is result's own, OR -- covering a CORRECTION that + // KEEPS a stored withdrawal ruling -- prior's: such a write's own + // Decision is either empty (a score sheet states no decision) or an + // explicit "hikiwake" (KeepsWithdrawalRuling's own two accepted shapes), + // and applyPoolWrite/applyBracketResultIn only reinstate the stored + // decision AFTER this point (preserveWithdrawalRuling), so testing + // result.Decision alone would miss it here -- and nothing downstream + // pads again once this function returns. A correction can legitimately + // send fewer SubResults rows than TeamSize (e.g. a bout-level edit that + // only touches the rows it corrects), so this is the one place that + // still needs to catch it up. + // + // Excluded: a bye (either side empty -- a bye never carries a + // default-win decision in practice, but the padding shape assumes two + // real teams either way) and a pool daihyosen/tiebreaker row + // (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), which is scored as a + // single individual representative bout, never the team's own numbered + // positions. Both checked inside NeedsDefaultWinBoutPadding. + if comp != nil && comp.TeamSize >= 2 && !comp.IsKachinuki() { + // KeepsWithdrawalRuling is checked UNCONDITIONALLY, not only when + // result.Decision == "": it is also true for an explicit incoming + // "hikiwake" over a stored default win (its own definition allows + // incoming.Decision to be "" OR "hikiwake"), and that write must + // still pad, since preserveWithdrawalRuling reinstates prior.Decision + // downstream regardless of which of the two the correction sent. + padDecision := result.Decision + if KeepsWithdrawalRuling(prior.Status, prior.Decision, result) { + padDecision = prior.Decision + } + if state.NeedsDefaultWinBoutPadding(result.Status, padDecision, prior.SideA, prior.SideB, matchID, + result.SubResults, prior.SubResultsUnreadable, comp.TeamSize) { + result.SubResults = state.PadDefaultWinBoutPositions(result.SubResults, comp.TeamSize) + } + } + noteServerBoutChanges(result, writerSubs, prior.SubResults, comp.IsKachinuki()) + return nil +} + +// ExactScoreReplay reports whether incoming is a completing score write the +// stored match has already recorded: the score counterpart of +// exactDecisionReplay (bc-xrpl). A Finish whose first send landed but whose +// answer was lost (the page went away, the connection dropped, the fetch +// timed out) is sent again by the offline queue with the stamp it was sent +// with; that resend is the same write, so it answers as recorded, with no +// correction reason asked and nothing written. Exact means: both the stored +// match and the write are completed, the write is stamped, its stamp is the +// stored result group's, and, with the write shaped as the write path shapes +// it (shapeScoreWrite and the side and id backfill; engiFinishProbe for an +// engi write), the merge owner (mergeMatchWrite) would land its verdict and +// every group it lands equals the stored value, as sent and as landed. A +// group the merge would hold because its stored stamp is newer (a later +// point from another board, R3 or S2) lands nothing and is not compared. A +// same-stamp write that differs where it would land is a correction and +// still needs its reason. comp names a kachinuki or engi competition (nil +// for neither); nilSubsClear is the branch's (true for a pool match, false +// for a knockout one, see mergeCtx). +func ExactScoreReplay(stored, incoming *state.MatchResult, comp *state.Competition, nilSubsClear bool) bool { + _, ok := exactScoreReplayProbe(stored, incoming, comp, nilSubsClear) + return ok +} + +// exactScoreReplayProbe is ExactScoreReplay's judgement, handing back the +// write as the merge would land it (a copy: incoming is not changed), its +// MergeReport on probe.Merge, which the replay's history entry records. +func exactScoreReplayProbe(stored, incoming *state.MatchResult, comp *state.Competition, nilSubsClear bool) (*state.MatchResult, bool) { + if stored == nil || incoming == nil || incoming.ModifiedAt <= 0 || + incoming.Status != state.MatchStatusCompleted || + stored.Status != state.MatchStatusCompleted || + incoming.ModifiedAt != stored.GroupStamp(state.GroupResult) { + return nil, false + } + probe := *incoming + // Copies that keep nil apart from empty (a nil list is a writer with + // nothing to say, an empty one says zero). + if incoming.IpponsA != nil { + probe.IpponsA = append(make([]string, 0, len(incoming.IpponsA)), incoming.IpponsA...) + } + if incoming.IpponsB != nil { + probe.IpponsB = append(make([]string, 0, len(incoming.IpponsB)), incoming.IpponsB...) + } + probe.SubResults = state.CloneSubResults(incoming.SubResults) + probe.GroupStamps = state.CloneGroupStamps(incoming.GroupStamps) + probe.Changed = slices.Clone(incoming.Changed) + if probe.CorrectionReason == "" { + probe.CorrectionReason = stored.CorrectionReason + } + if comp != nil && comp.Engi { + // An engi finish lands as the write the engi recorder builds on the + // stored match from the flags (engiFinishHeld merges this probe). + probe = engiFinishProbe(stored, &probe) + } else { + if applyHansokuIppons(&probe) != nil { + return nil, false + } + if shapeScoreWrite(comp, stored, &probe, stored.ID) != nil { + return nil, false + } + if reconcileSides(&probe, storedSides{A: stored.SideA, B: stored.SideB, AID: stored.SideAID, BID: stored.SideBID}) { + return nil, false + } + if backfillMatchIdentity(&probe, stored, matchWriteForward) != nil { + return nil, false + } + } + // The merge owner decides which groups of the write would land: a group + // whose stored stamp is newer is held and lands nothing (a later point + // from another board, R3 or S2), and every whole-write rule (R2, finish + // atomicity) applies as it would to the write. Exact means the verdict + // lands, and every group that lands equals the stored value both as the + // write sent it and as it would land (S2 can re-derive a verdict the + // write sent differently, which is still a correction). + sent := probe + sent.IpponsA, sent.IpponsB = slices.Clone(probe.IpponsA), slices.Clone(probe.IpponsB) + sent.SubResults = state.CloneSubResults(probe.SubResults) + rep := mergeMatchWrite(stored, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: !nilSubsClear, nilSubsClear: nilSubsClear}) + if rep == nil || !slices.Contains(rep.Applied, state.GroupResult) || len(rep.Displaced) > 0 { + return nil, false + } + for _, g := range rep.Applied { + if !slices.Contains(rep.Unchanged, g) || state.GroupDiffers(stored, &sent, g) { + return nil, false + } + } + return &probe, true +} + +// answerExactScoreReplay answers an exact resend of a recorded finish +// (ExactScoreReplay) as recorded: result becomes the stored match, exactly +// as a landed write leaves it, and nothing is written to the match file. Its +// history entry says the write arrived and changed nothing, as an exact +// decision replay's does (a group changed since it was made is listed as +// held, as the merge holds it). It runs before anything else judges the write, as +// the decision replay runs before the decision's refusals: a downstream or +// eligibility refusal would only misreport a result already recorded as one +// to resolve and send again. false, with result untouched, for any other +// write. +func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string, result *state.MatchResult, comp *state.Competition, noHistory bool) (bool, error) { + if result.Status != state.MatchStatusCompleted || result.ModifiedAt <= 0 { + return false, nil + } + prior, inPool, err := e.lookupExistingResultIn(tx, compID, matchID) + if err != nil { + return false, err + } + probe, ok := exactScoreReplayProbe(prior, result, comp, inPool) + if !ok { + return false, nil + } + // The probe's merge is the history entry's: what of the resend would + // land (echoes) and what would be held (groups changed since). + result.Merge = probe.Merge + if !noHistory { + e.recordWriteHistory(tx, compID, matchID, result) + } + // A bracket match's prior came through bracketMatchAsResult, which omits + // the court and the schedule a landed write echoes (the decision + // replay's fill, bc-cse finding 5). + if !inPool && (prior.Court == "" || prior.ScheduledAt == "") { + if bracket, berr := tx.LoadBracket(compID); berr == nil { + if court, scheduledAt, found := bracketMatchCourtAndSchedule(bracket, matchID); found { + if prior.Court == "" { + prior.Court = court + } + if prior.ScheduledAt == "" { + prior.ScheduledAt = scheduledAt + } + } + } + } + *result = *prior + result.ClearRequestFields() + return true, nil +} + // holdWriteTx keeps a write whole in the match's history without applying any // of it, for ForceOptions.HoldReason. The merge owner decides what the write // changes and the values it holds (mergeMatchWrite with holdAll), on a copy, diff --git a/internal/engine/swiss.go b/internal/engine/swiss.go index 6deadcec8..ca27579a6 100644 --- a/internal/engine/swiss.go +++ b/internal/engine/swiss.go @@ -169,6 +169,12 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat return nil, validationErrorf("round %d exceeds configured swissRounds %d", roundNumber, comp.SwissRounds) } + // Loaded first: whether check-in applies depends on the tournament's mode. + tournament, err := e.store.LoadTournament() + if err != nil { + return nil, err + } + participants, err := e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) if err != nil { return nil, err @@ -197,9 +203,10 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat // Determine the Swiss field for this round (mp-w7x; PR #199 review). // // Round 1 (the initial draw): exclude participants who have not checked - // in, but ONLY when check-in tracking is enabled (comp.CheckInEnabled), - // otherwise a stale/imported checked_in marker would shrink the field even - // though the competition doesn't use check-in (PR #199 review). When + // in, but ONLY when check-in applies (comp.CheckInApplies: the setting is on + // and the tournament is not self-run), otherwise a stale/imported + // checked_in marker would shrink the field even though the competition + // doesn't use check-in (PR #199 review). When // enabled, opt-in semantics still apply (see filterCheckedIn). // // Round N > 1: the field is FROZEN to whoever was part of the initial @@ -209,7 +216,7 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat // player into a later round nor silently drop one. Withdrawals are handled // separately by the eligibility (kiken/fusenpai) filter below. if roundNumber == 1 { - if comp.CheckInEnabled { + if comp.CheckInApplies(tournament) { participants = filterCheckedIn(participants) } } else { @@ -302,11 +309,8 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat matches := buildSwissMatches(pairings, byeKey, roundNumber, comp.Courts, keyToPlayer) // Schedule slot assignment (same per-court cursor logic as pool - // matches). Reload tournament for ceremony / multiplier tuning. - tournament, err := e.store.LoadTournament() - if err != nil { - return nil, err - } + // matches). The tournament loaded above also supplies the ceremony / + // multiplier tuning. // Apply the team-size default (same guard as competition.go StartCompetition). // GenerateSwissRound reloads comp from disk; if TeamSize was 0 in the stored // config, the default must be applied here so assignPoolMatchSlots uses the @@ -936,7 +940,7 @@ func (e *Engine) AdvanceSwissRound(compID string) ([]state.MatchResult, int, err if comp.Format != state.CompFormatSwiss { return nil, 0, validationErrorf("competition %s is not swiss format", compID) } - if comp.SwissRounds > 0 && comp.SwissCurrentRound >= comp.SwissRounds { + if comp.SwissRounds > 0 && SwissRoundsToDraw(comp) == 0 { return nil, 0, validationErrorf("all %d swiss rounds already completed", comp.SwissRounds) } completed, err := e.CurrentSwissRoundCompleted(compID) diff --git a/internal/mobileapp/auth_admin.go b/internal/mobileapp/auth_admin.go deleted file mode 100644 index f485b4974..000000000 --- a/internal/mobileapp/auth_admin.go +++ /dev/null @@ -1,192 +0,0 @@ -package mobileapp - -import ( - "errors" - "fmt" - - "github.com/gitrgoliveira/bracket-creator/internal/state" - "golang.org/x/crypto/bcrypt" -) - -// ElevatedVerifier gates the destructive-operation middleware -// (RequireElevatedPassword) for spec 004 / mp-e21. It is intentionally -// NARROWER than PasswordVerifier: there is no bootstrap branch, no reset -// semantics, and no stored-password redaction policy, those belong to the -// primary credential. The elevated password is a SECOND factor layered on -// top of the main-password AuthMiddleware, so by the time these methods run -// the request has already proven it holds the main password. -// -// Two orthogonal questions drive the middleware's three outcomes: -// GateActive() (should we enforce at all?) and Configured() (is there a -// credential to compare against?): -// -// GateActive=false -> pass through (file mode, no admin pw set; -// back-compat: main password alone suffices) -// GateActive=true, Configured=false -> 503 "admin password not configured" -// (locked mode, env hash unset; fail-closed) -// GateActive=true, Configured=true -> compare X-Admin-Password via Verify() -type ElevatedVerifier interface { - // GateActive reports whether the middleware should enforce at all. - // file mode: true only when an admin password has been set. - // locked mode: always true (fail-closed, a locked deployment that - // forgot the env var must NOT silently allow destructive - // ops with the main password alone). - GateActive() bool - - // Configured reports whether a credential exists to compare against. - // file mode: admin password non-empty (== GateActive there). - // locked mode: TOURNAMENT_ADMIN_PASSWORD_HASH was a valid bcrypt hash. - Configured() bool - - // Verify checks the presented X-Admin-Password value. Only consulted - // when GateActive() && Configured(); implementations may still be called - // defensively and must treat an empty presented value as a non-match. - Verify(presented string) (bool, error) - - // Mode mirrors PasswordVerifier.Mode for /auth-config ("file"|"locked"). - Mode() string -} - -// fileElevatedVerifier implements ElevatedVerifier for file mode. It reads -// Tournament.AdminPassword from the same store the rest of the app uses, so -// a rotation through PUT /api/auth/admin-password takes effect immediately -// on the next request (no process restart, unlike the bcrypt env-var path). -type fileElevatedVerifier struct { - store *state.Store -} - -// NewFileElevatedVerifier constructs the file-mode elevated verifier. -func NewFileElevatedVerifier(store *state.Store) ElevatedVerifier { - return &fileElevatedVerifier{store: store} -} - -// adminPassword loads the current on-disk elevated password, or "" if no -// tournament exists yet. Errors propagate so the middleware can 500 rather -// than fail open. -func (v *fileElevatedVerifier) adminPassword() (string, error) { - t, err := v.store.LoadTournament() - if err != nil { - return "", err - } - if t == nil { - return "", nil - } - return t.AdminPassword, nil -} - -// GateActive: in file mode the gate is opt-in, it activates only once the -// operator has set an admin password. A load error is treated as -// "active" so a transient store failure fails closed (the subsequent -// Verify will surface the error and the middleware 500s) rather than -// silently disabling the gate. -func (v *fileElevatedVerifier) GateActive() bool { - pw, err := v.adminPassword() - if err != nil { - return true - } - return pw != "" -} - -// Configured mirrors GateActive in file mode: the same on-disk field both -// activates the gate and is the credential. A load error reports false so -// the middleware's Configured() branch doesn't 503 on a transient error, -// the error is surfaced by Verify instead. -func (v *fileElevatedVerifier) Configured() bool { - pw, err := v.adminPassword() - if err != nil { - return false - } - return pw != "" -} - -// Verify does an exact-string compare against the stored admin password. -// An empty presented value never matches a non-empty stored password; the -// middleware's GateActive() short-circuit means this is never reached when -// the stored password is empty, so there is no empty-vs-empty vacuous match. -func (v *fileElevatedVerifier) Verify(presented string) (bool, error) { - stored, err := v.adminPassword() - if err != nil { - return false, err - } - if stored == "" { - return false, nil - } - return presented == stored, nil -} - -func (v *fileElevatedVerifier) Mode() string { return "file" } - -// bcryptElevatedVerifier implements ElevatedVerifier for locked mode when a -// valid TOURNAMENT_ADMIN_PASSWORD_HASH is present. It reuses the exact -// hardening proven in bcryptPasswordVerifier (auth_source.go): an empty or -// oversized presented value is a non-match (never a 500), and only genuine -// bcrypt mismatch/too-long errors are folded into "wrong credential". -type bcryptElevatedVerifier struct { - hash []byte -} - -// NewBcryptElevatedVerifier validates `hash` as a bcrypt-encoded password -// hash and returns a configured locked-mode verifier. An empty hash is -// rejected with a distinct error so the caller can fall back to the -// unconfigured verifier (which 503s on use) rather than failing startup, -// a locked deployment that never performs destructive ops shouldn't be -// forced to set this env var just to boot. -func NewBcryptElevatedVerifier(hash string) (ElevatedVerifier, error) { - if hash == "" { - return nil, errors.New("TOURNAMENT_ADMIN_PASSWORD_HASH is empty") - } - b := []byte(hash) - if _, err := bcrypt.Cost(b); err != nil { - return nil, fmt.Errorf("TOURNAMENT_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err) - } - return &bcryptElevatedVerifier{hash: b}, nil -} - -func (v *bcryptElevatedVerifier) GateActive() bool { return true } -func (v *bcryptElevatedVerifier) Configured() bool { return true } - -func (v *bcryptElevatedVerifier) Verify(presented string) (bool, error) { - if presented == "" { - return false, nil - } - // Pre-check length so an oversized header can't trip a 500 (and thus a - // differential-error oracle), identical rationale to - // bcryptPasswordVerifier.Verify, bcryptMaxInputBytes defined there. - if len(presented) > bcryptMaxInputBytes { - return false, nil - } - err := bcrypt.CompareHashAndPassword(v.hash, []byte(presented)) - if err == nil { - return true, nil - } - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) || errors.Is(err, bcrypt.ErrPasswordTooLong) { - return false, nil - } - return false, err -} - -func (v *bcryptElevatedVerifier) Mode() string { return "locked" } - -// lockedUnconfiguredElevatedVerifier represents locked mode with no (or an -// invalid) TOURNAMENT_ADMIN_PASSWORD_HASH. The gate is active (locked mode -// fails closed) but there is no credential, so the middleware returns 503 -// "admin password not configured" on any gated endpoint. Routine -// (non-gated) endpoints are unaffected. -type lockedUnconfiguredElevatedVerifier struct{} - -// NewLockedUnconfiguredElevatedVerifier constructs the fail-closed locked -// verifier used when the elevated env-var hash is absent or malformed. -func NewLockedUnconfiguredElevatedVerifier() ElevatedVerifier { - return &lockedUnconfiguredElevatedVerifier{} -} - -func (v *lockedUnconfiguredElevatedVerifier) GateActive() bool { return true } -func (v *lockedUnconfiguredElevatedVerifier) Configured() bool { return false } - -// Verify should never be reached (the middleware checks Configured() first) -// but returns a non-match defensively so a future caller can't fail open. -func (v *lockedUnconfiguredElevatedVerifier) Verify(string) (bool, error) { - return false, nil -} - -func (v *lockedUnconfiguredElevatedVerifier) Mode() string { return "locked" } diff --git a/internal/mobileapp/auth_admin_test.go b/internal/mobileapp/auth_admin_test.go deleted file mode 100644 index 16bb1db30..000000000 --- a/internal/mobileapp/auth_admin_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package mobileapp - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" -) - -// setAdminPassword writes an admin password onto the stored tournament so -// the file-mode elevated verifier has something to compare against. Creates -// the tournament record if needed. -func setAdminPassword(t *testing.T, store *state.Store, admin string) { - t.Helper() - _, err := store.UpdateTournamentChanged(&state.Tournament{}, func(current, desired *state.Tournament) error { - if current != nil { - *desired = *current - } else { - desired.Name = "Test" - desired.Password = "main" - } - desired.AdminPassword = admin - return nil - }) - require.NoError(t, err) -} - -func TestFileElevatedVerifier_GateInactiveWhenUnset(t *testing.T) { - store := setupVerifierTestStore(t) - v := NewFileElevatedVerifier(store) - assert.Equal(t, "file", v.Mode()) - // No tournament / no admin password → gate inactive, not configured. - assert.False(t, v.GateActive()) - assert.False(t, v.Configured()) -} - -func TestFileElevatedVerifier_SetActivatesGate(t *testing.T) { - store := setupVerifierTestStore(t) - v := NewFileElevatedVerifier(store) - setAdminPassword(t, store, "secret-admin") - - assert.True(t, v.GateActive()) - assert.True(t, v.Configured()) - - t.Run("matching → true", func(t *testing.T) { - ok, err := v.Verify("secret-admin") - require.NoError(t, err) - assert.True(t, ok) - }) - t.Run("wrong → false", func(t *testing.T) { - ok, err := v.Verify("nope") - require.NoError(t, err) - assert.False(t, ok) - }) - t.Run("empty presented against non-empty stored → false", func(t *testing.T) { - ok, err := v.Verify("") - require.NoError(t, err) - assert.False(t, ok) - }) -} - -func TestBcryptElevatedVerifier(t *testing.T) { - plain := "elevated-secret" - hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - v, err := NewBcryptElevatedVerifier(string(hash)) - require.NoError(t, err) - - assert.Equal(t, "locked", v.Mode()) - assert.True(t, v.GateActive()) - assert.True(t, v.Configured()) - - t.Run("matching → true", func(t *testing.T) { - ok, err := v.Verify(plain) - require.NoError(t, err) - assert.True(t, ok) - }) - t.Run("wrong → false", func(t *testing.T) { - ok, err := v.Verify("nope") - require.NoError(t, err) - assert.False(t, ok) - }) - t.Run("empty → false (short-circuit)", func(t *testing.T) { - ok, err := v.Verify("") - require.NoError(t, err) - assert.False(t, ok) - }) - // Hardening parity with bcryptPasswordVerifier: oversized input must not - // 500 (which would be a locked-vs-file differential oracle). - t.Run("oversize (>72 bytes) → false (no error)", func(t *testing.T) { - ok, err := v.Verify(strings.Repeat("x", 100)) - require.NoError(t, err) - assert.False(t, ok) - }) -} - -func TestNewBcryptElevatedVerifier_EmptyAndInvalid(t *testing.T) { - _, err := NewBcryptElevatedVerifier("") - require.Error(t, err) - assert.Contains(t, err.Error(), "empty") - - _, err = NewBcryptElevatedVerifier("not-a-bcrypt-hash") - require.Error(t, err) - assert.Contains(t, err.Error(), "valid bcrypt hash") -} - -func TestLockedUnconfiguredElevatedVerifier(t *testing.T) { - v := NewLockedUnconfiguredElevatedVerifier() - assert.Equal(t, "locked", v.Mode()) - // Gate active (fail-closed) but no credential → middleware must 503. - assert.True(t, v.GateActive()) - assert.False(t, v.Configured()) - ok, err := v.Verify("anything") - require.NoError(t, err) - assert.False(t, ok) -} - -// --- middleware behavior --- - -// elevatedTestRouter mounts a single gated GET behind RequireElevatedPassword -// so the three outcome paths can be exercised without the full admin stack. -func elevatedTestRouter(ev ElevatedVerifier) *gin.Engine { - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/gated", RequireElevatedPassword(ev), func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true}) - }) - return r -} - -func doGated(r *gin.Engine, adminHeader string, setHeader bool) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/gated", nil) - if setHeader { - req.Header.Set("X-Admin-Password", adminHeader) - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func TestRequireElevatedPassword_GateInactivePassesThrough(t *testing.T) { - store := setupVerifierTestStore(t) - // file mode, no admin password → gate inactive → request passes with no header. - r := elevatedTestRouter(NewFileElevatedVerifier(store)) - w := doGated(r, "", false) - assert.Equal(t, http.StatusOK, w.Code) -} - -func TestRequireElevatedPassword_FileMode(t *testing.T) { - store := setupVerifierTestStore(t) - setAdminPassword(t, store, "the-admin-pw") - r := elevatedTestRouter(NewFileElevatedVerifier(store)) - - t.Run("missing header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, doGated(r, "", false).Code) - }) - t.Run("wrong header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, doGated(r, "wrong", true).Code) - }) - t.Run("correct header → 200", func(t *testing.T) { - assert.Equal(t, http.StatusOK, doGated(r, "the-admin-pw", true).Code) - }) -} - -func TestRequireElevatedPassword_LockedUnconfigured503(t *testing.T) { - r := elevatedTestRouter(NewLockedUnconfiguredElevatedVerifier()) - // Even a (wrong or right-looking) header can't help, no credential exists. - w := doGated(r, "anything", true) - assert.Equal(t, http.StatusServiceUnavailable, w.Code) - assert.Contains(t, w.Body.String(), "not configured") -} - -func TestRequireElevatedPassword_LockedConfigured(t *testing.T) { - plain := "locked-elevated" - hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - ev, err := NewBcryptElevatedVerifier(string(hash)) - require.NoError(t, err) - r := elevatedTestRouter(ev) - - assert.Equal(t, http.StatusUnauthorized, doGated(r, "wrong", true).Code) - assert.Equal(t, http.StatusOK, doGated(r, plain, true).Code) -} diff --git a/internal/mobileapp/auth_source.go b/internal/mobileapp/auth_source.go index f17c6d3eb..87b1bd242 100644 --- a/internal/mobileapp/auth_source.go +++ b/internal/mobileapp/auth_source.go @@ -1,6 +1,7 @@ package mobileapp import ( + "crypto/subtle" "errors" "fmt" @@ -100,7 +101,7 @@ func (v *filePasswordVerifier) Verify(presented string) (bool, error) { if t == nil { return false, nil } - return presented == t.Password, nil + return subtle.ConstantTimeCompare([]byte(presented), []byte(t.Password)) == 1, nil } func (v *filePasswordVerifier) Mode() string { return "file" } diff --git a/internal/mobileapp/confirm_password.go b/internal/mobileapp/confirm_password.go new file mode 100644 index 000000000..cb92a27bc --- /dev/null +++ b/internal/mobileapp/confirm_password.go @@ -0,0 +1,65 @@ +package mobileapp + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ConfirmPasswordHeader carries the password typed in the moment a competition +// is deleted. Deleting a competition is the one action that asks for the +// password again (operator ruling 2026-10-12); every other organiser write is +// authorised by the sign-in alone. +const ConfirmPasswordHeader = "X-Confirm-Password" // #nosec G101 -- a header name, not a credential + +// Codes of the two refusals; both carry confirmPasswordSentence. +const ( + confirmPasswordRequiredCode = "confirm_password_required" + confirmPasswordWrongCode = "confirm_password_wrong" +) + +const confirmPasswordSentence = "Wrong password. The competition was not deleted." + +// isCompetitionDelete reports whether a request is DELETE /api/competitions/:id, +// by the route pattern, so DELETE .../draw and the other sub-routes are not it. +func isCompetitionDelete(method, fullPath string) bool { + return method == http.MethodDelete && fullPath == "/api/competitions/:id" +} + +// RequireConfirmPasswordForDelete refuses a competition delete unless +// X-Confirm-Password matches the admin password, judged by the same +// PasswordVerifier as the sign-in. It runs after AuthMiddleware, so the +// sign-in applies first in both modes, and before the handler deletes anything. +// The refusal is a 401 the SPA reads as a wrong typed password, not as a lost +// session (it names a code, and the sign-in's own 401 body does not). +func RequireConfirmPasswordForDelete(verifier PasswordVerifier) gin.HandlerFunc { + return func(c *gin.Context) { + if !isCompetitionDelete(c.Request.Method, c.FullPath()) { + c.Next() + return + } + confirm := c.GetHeader(ConfirmPasswordHeader) + if confirm == "" { + respondConfirmPassword(c, confirmPasswordRequiredCode) + return + } + ok, err := verifier.Verify(confirm) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "auth verification failed"}) + return + } + if !ok { + respondConfirmPassword(c, confirmPasswordWrongCode) + return + } + c.Next() + } +} + +func respondConfirmPassword(c *gin.Context, code string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": confirmPasswordSentence, + "code": code, + "message": confirmPasswordSentence, + }) +} diff --git a/internal/mobileapp/confirm_password_test.go b/internal/mobileapp/confirm_password_test.go new file mode 100644 index 000000000..996563b05 --- /dev/null +++ b/internal/mobileapp/confirm_password_test.go @@ -0,0 +1,138 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// Deleting a competition asks for the password again: the sign-in password +// typed in that moment, in X-Confirm-Password, checked by the same +// PasswordVerifier as the sign-in (operator ruling 2026-10-12). + +const confirmWrongSentence = "Wrong password. The competition was not deleted." + +func confirmDeleteRig(t *testing.T, mode string, locked bool) (*gin.Engine, *state.Store) { + t.Helper() + gin.SetMode(gin.TestMode) + store := newTempStore(t) + var v PasswordVerifier + if locked { + hash, err := bcrypt.GenerateFromPassword([]byte("main-pw"), bcrypt.MinCost) + require.NoError(t, err) + v, err = NewBcryptVerifier(string(hash)) + require.NoError(t, err) + } + tour := &state.Tournament{Name: "T", Date: "01-06-2026", Venue: "Dojo", Courts: []string{"A"}, Mode: mode} + if !locked { + tour.Password = "main-pw" + } + require.NoError(t, store.SaveTournament(tour)) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "doomed", Name: "Doomed"})) + return setupSelfRunRouter(t, store, v), store +} + +func confirmDelete(r *gin.Engine, signIn, confirm string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/doomed", nil) + if signIn != "" { + req.Header.Set("X-Tournament-Password", signIn) + } + if confirm != "" { + req.Header.Set("X-Confirm-Password", confirm) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestDeleteCompetition_AsksForThePasswordAgain(t *testing.T) { + for _, tc := range []struct { + name string + mode string + locked bool + }{ + {"officiated file mode", state.TournamentModeOfficiated, false}, + {"officiated locked mode", state.TournamentModeOfficiated, true}, + {"self-run file mode", state.TournamentModeSelfRun, false}, + {"self-run locked mode", state.TournamentModeSelfRun, true}, + } { + t.Run(tc.name, func(t *testing.T) { + r, store := confirmDeleteRig(t, tc.mode, tc.locked) + stillThere := func() { + t.Helper() + c, err := store.LoadCompetition("doomed") + require.NoError(t, err) + require.NotNil(t, c, "the competition must still exist") + } + + for label, w := range map[string]*httptest.ResponseRecorder{ + "no confirm header": confirmDelete(r, "main-pw", ""), + "wrong confirm header": confirmDelete(r, "main-pw", "nope"), + "sign-in as the confirm, wrong sign-in": confirmDelete(r, "nope", "main-pw"), + } { + assert.Equal(t, http.StatusUnauthorized, w.Code, label) + stillThere() + } + + // The refusal names the sentence in the house shape. + w := confirmDelete(r, "main-pw", "nope") + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, confirmWrongSentence, body["error"]) + assert.Equal(t, confirmWrongSentence, body["message"]) + assert.Equal(t, "confirm_password_wrong", body["code"]) + + w = confirmDelete(r, "main-pw", "") + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, confirmWrongSentence, body["error"]) + assert.Equal(t, "confirm_password_required", body["code"]) + stillThere() + + w = confirmDelete(r, "main-pw", "main-pw") + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + gone, err := store.LoadCompetition("doomed") + require.NoError(t, err) + assert.Nil(t, gone, "the right password deletes the competition") + }) + } +} + +// The gate that already keeps an anonymous caller out of the route in self-run +// answers first, with its own body, whatever confirm header is sent. +func TestDeleteCompetition_SelfRunAnonymousStillRefusedByTheGate(t *testing.T) { + r, store := confirmDeleteRig(t, state.TournamentModeSelfRun, false) + w := confirmDelete(r, "", "main-pw") + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "invalid admin password") + c, err := store.LoadCompetition("doomed") + require.NoError(t, err) + require.NotNil(t, c) +} + +// Only the competition delete asks. Discarding a draw and the other +// destructive routes stay one-step for a signed-in organiser. +func TestConfirmPassword_OnlyDeleteCompetitionAsks(t *testing.T) { + r, _ := confirmDeleteRig(t, state.TournamentModeOfficiated, false) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/doomed/draw", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusUnauthorized, w.Code, w.Body.String()) + assert.NotContains(t, w.Body.String(), confirmWrongSentence) +} + +func TestCORSAllowsTheConfirmPasswordHeader(t *testing.T) { + r, _ := confirmDeleteRig(t, state.TournamentModeOfficiated, false) + req := httptest.NewRequest(http.MethodOptions, "/api/competitions/doomed", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-Confirm-Password") +} diff --git a/internal/mobileapp/coverage_gap_test.go b/internal/mobileapp/coverage_gap_test.go index 21fc1f1b7..147df8bfe 100644 --- a/internal/mobileapp/coverage_gap_test.go +++ b/internal/mobileapp/coverage_gap_test.go @@ -514,46 +514,6 @@ func TestRegistration_POST_BadJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// --------------------------------------------------------------------------- -// Admin password endpoint, input validation gaps -// --------------------------------------------------------------------------- - -// TestAdminPassword_BadJSON covers the ShouldBindJSON error path -// (line 50-53 in handlers_auth_admin.go). -func TestAdminPassword_BadJSON(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - req := httptest.NewRequest(http.MethodPut, "/api/auth/admin-password", bytes.NewBufferString("{invalid")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -// TestAdminPassword_NewPasswordTooLong covers the validateMaxLen("newPassword") -// error path (line 62-65 in handlers_auth_admin.go). -func TestAdminPassword_NewPasswordTooLong(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": strings.Repeat("x", MaxLenTournamentPassword+1)}) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -// TestAdminPassword_CurrentPasswordTooLong covers the validateMaxLen("currentPassword") -// error path (line 66-69 in handlers_auth_admin.go). -func TestAdminPassword_CurrentPasswordTooLong(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{ - "newPassword": "valid", - "currentPassword": strings.Repeat("x", MaxLenTournamentPassword+1), - }) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - // --------------------------------------------------------------------------- // Eligibility POST, bad JSON and validate-length error paths // --------------------------------------------------------------------------- @@ -658,6 +618,8 @@ func TestReset_BadJSON(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/tournament/reset", bytes.NewBufferString("{invalid")) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) diff --git a/internal/mobileapp/handlers_auth_admin.go b/internal/mobileapp/handlers_auth_admin.go deleted file mode 100644 index cfee7e5ba..000000000 --- a/internal/mobileapp/handlers_auth_admin.go +++ /dev/null @@ -1,120 +0,0 @@ -package mobileapp - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" -) - -// adminPasswordRequest is the body shape for PUT /api/auth/admin-password. -// -// - NewPassword is the elevated password to store (required, non-empty). -// - CurrentPassword is required ONLY when an admin password is already set -// (rotation): it must equal the stored value so a main-password holder -// cannot silently re-set the elevated secret (spec 004 Finding 2). On -// first-time set (TOFU) it is ignored. -// -// Neither value is ever echoed back, the elevated password is write-only at -// the API boundary (Tournament.AdminPassword has json:"-"). -type adminPasswordRequest struct { - NewPassword string `json:"newPassword"` - CurrentPassword string `json:"currentPassword,omitempty"` -} - -// RegisterAdminPasswordHandler wires PUT /api/auth/admin-password, the only -// way to set or rotate the file-mode elevated password (spec 004 §6a). It is -// registered inside the admin group, so AuthMiddleware has already verified -// the MAIN password before this runs. -// -// Gating: -// - locked mode → 404. The elevated credential is the env-var bcrypt hash -// (TOURNAMENT_ADMIN_PASSWORD_HASH); it is not settable via the API. The -// 404 (not 405/403) matches the "route doesn't exist in this mode" shape -// used by the password-reset endpoint. -// - file mode, no admin password set yet → trust-on-first-use: the main -// password (already verified) authorizes the initial set. -// - file mode, admin password already set → rotation requires the correct -// CurrentPassword (verified via the ElevatedVerifier) in addition to the -// main password. -func RegisterAdminPasswordHandler(r *gin.RouterGroup, store *state.Store, ev ElevatedVerifier) { - r.PUT("/auth/admin-password", func(c *gin.Context) { - // Locked mode: not settable via API. ev.Mode() == "locked" for both - // the configured (bcrypt) and unconfigured locked verifiers. - if ev.Mode() == "locked" { - c.JSON(http.StatusNotFound, gin.H{"error": "admin password is controlled by TOURNAMENT_ADMIN_PASSWORD_HASH and is not settable via the API"}) - return - } - - var req adminPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // NewPassword is NOT trimmed (passwords may legitimately contain - // whitespace; the auth comparison is exact-string match, same - // policy as the main password). - if req.NewPassword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "newPassword is required"}) - return - } - if err := validateMaxLen("newPassword", req.NewPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := validateMaxLen("currentPassword", req.CurrentPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // A tournament record must exist before an admin password can be - // attached to it, otherwise the set would land on a non-existent - // record (and the bulk PUT/POST guards require a name anyway). - t, err := store.LoadTournament() - if err != nil { - internalError(c, err) - return - } - if t == nil { - c.JSON(http.StatusConflict, gin.H{"error": "tournament not configured yet"}) - return - } - - // Rotation gate: if an admin password is already set, the caller - // must prove possession of the current one. GateActive() is true in - // file mode exactly when AdminPassword != "". Verify reads the - // current stored value, so this is the canonical "old password" - // check. First-time set (GateActive() == false) skips it (TOFU). - if ev.GateActive() { - ok, verr := ev.Verify(req.CurrentPassword) - if verr != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "admin auth verification failed"}) - return - } - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "current admin password is incorrect"}) - return - } - } - - // Persist under the store's write lock. The desired struct's other - // fields are irrelevant, the transform copies everything from the - // current record forward and changes only AdminPassword, so a - // concurrent bulk PUT can't be clobbered by a stale desired here. - _, err = store.UpdateTournamentChanged(t, func(current, desired *state.Tournament) error { - if current != nil { - *desired = *current - } - desired.AdminPassword = req.NewPassword - return nil - }) - if err != nil { - internalError(c, err) - return - } - - // Write-only: never echo the value. Report only the resulting state. - c.JSON(http.StatusOK, gin.H{"status": "ok", "elevatedConfigured": true}) - }) -} diff --git a/internal/mobileapp/handlers_auth_admin_test.go b/internal/mobileapp/handlers_auth_admin_test.go deleted file mode 100644 index bcf705e6f..000000000 --- a/internal/mobileapp/handlers_auth_admin_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package mobileapp - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" -) - -// elevatedHandlerRouter mounts the tournament + admin-password + auth-config -// handlers for a given verifier. No AuthMiddleware, the main-password gate -// is orthogonal to the elevated-password logic under test here. -func elevatedHandlerRouter(t *testing.T, store *state.Store, verifier PasswordVerifier) (*gin.Engine, ElevatedVerifier) { - t.Helper() - gin.SetMode(gin.TestMode) - r := gin.New() - hub := NewHub() - ev := defaultElevatedVerifier(verifier, store) - api := r.Group("/api") - RegisterTournamentHandlers(api, store, hub, verifier) - RegisterAdminPasswordHandler(api, store, ev) - RegisterAuthConfigHandlers(api, verifier, ev, false) - return r, ev -} - -func seedTournament(t *testing.T, store *state.Store) { - t.Helper() - _, err := store.SaveTournamentChanged(&state.Tournament{ - Name: "Seeded", Date: "01-01-2026", Venue: "Hall", Courts: []string{"A"}, Password: "main", - }) - require.NoError(t, err) -} - -func putAdminPassword(r *gin.Engine, body map[string]string) *httptest.ResponseRecorder { - b, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPut, "/api/auth/admin-password", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func TestAdminPasswordEndpoint_TOFUThenRotate(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, ev := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - // First-time set: no current password needed (gate inactive). - require.False(t, ev.GateActive()) - w := putAdminPassword(r, map[string]string{"newPassword": "first-admin"}) - assert.Equal(t, http.StatusOK, w.Code) - assert.True(t, ev.GateActive(), "gate should activate after first set") - - // Rotation without the current password → 401. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin"}) - assert.Equal(t, http.StatusUnauthorized, w.Code) - - // Rotation with wrong current password → 401. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin", "currentPassword": "wrong"}) - assert.Equal(t, http.StatusUnauthorized, w.Code) - - // Rotation with correct current password → 200, new value takes effect. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin", "currentPassword": "first-admin"}) - assert.Equal(t, http.StatusOK, w.Code) - ok, err := ev.Verify("second-admin") - require.NoError(t, err) - assert.True(t, ok) - ok, _ = ev.Verify("first-admin") - assert.False(t, ok, "old admin password must no longer verify") -} - -func TestAdminPasswordEndpoint_EmptyNewPasswordRejected(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": ""}) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestAdminPasswordEndpoint_NoTournament409(t *testing.T) { - store := setupVerifierTestStore(t) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": "x"}) - assert.Equal(t, http.StatusConflict, w.Code) -} - -// Finding 1 regression: GET /tournament must NEVER expose the elevated -// password, even in file mode where the main password IS returned. -func TestTournamentGet_NeverLeaksAdminPassword(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - setAdminPassword(t, store, "super-secret-admin") - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - body := w.Body.String() - assert.NotContains(t, body, "super-secret-admin", "admin password leaked in GET /tournament") - assert.NotContains(t, body, "adminPassword") - assert.NotContains(t, body, "admin_password") -} - -// Finding 2 regression: PUT /tournament (main-password gate) must NOT be able -// to set or overwrite the elevated password, and a routine settings save must -// preserve the existing one. -func TestTournamentPut_CannotSetOrWipeAdminPassword(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - setAdminPassword(t, store, "original-admin") - r, ev := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - // Attacker attempts to overwrite the elevated password via the bulk PUT, - // sending both the JSON field name variants. - body := []byte(`{"name":"Renamed","date":"01-01-2026","venue":"Hall","courts":["A"],"password":"main","adminPassword":"attacker","admin_password":"attacker"}`) - req := httptest.NewRequest(http.MethodPut, "/api/tournament", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - - // The elevated password is unchanged: attacker's value does NOT verify, - // the original still does (preserve-on-write). - ok, err := ev.Verify("attacker") - require.NoError(t, err) - assert.False(t, ok, "PUT /tournament overwrote the admin password, privilege escalation") - ok, err = ev.Verify("original-admin") - require.NoError(t, err) - assert.True(t, ok, "routine PUT /tournament wiped the admin password") - - // And the rename actually took effect (sanity: the PUT did persist). - tn, err := store.LoadTournament() - require.NoError(t, err) - assert.Equal(t, "Renamed", tn.Name) -} - -func TestAuthConfig_ElevatedBits_FileMode(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - get := func() authConfigResponse { - req := httptest.NewRequest(http.MethodGet, "/api/auth-config", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var resp authConfigResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - return resp - } - - // Before setting: editable (file mode) but not required/configured. - resp := get() - assert.Equal(t, "file", resp.Mode) - assert.True(t, resp.ElevatedEditable) - assert.False(t, resp.ElevatedRequired) - assert.False(t, resp.ElevatedConfigured) - - // After setting: required + configured. - require.Equal(t, http.StatusOK, putAdminPassword(r, map[string]string{"newPassword": "abc"}).Code) - resp = get() - assert.True(t, resp.ElevatedRequired) - assert.True(t, resp.ElevatedConfigured) - assert.True(t, resp.ElevatedEditable) -} - -func TestAdminPasswordEndpoint_LockedMode404(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - // Build a locked main verifier so defaultElevatedVerifier picks the - // locked branch. The env var is unset → unconfigured elevated verifier, - // which still reports Mode()=="locked" → endpoint 404s. - hash := mustBcrypt(t, "main") - mainV, err := NewBcryptVerifier(hash) - require.NoError(t, err) - r, ev := elevatedHandlerRouter(t, store, mainV) - assert.Equal(t, "locked", ev.Mode()) - - w := putAdminPassword(r, map[string]string{"newPassword": "x"}) - assert.Equal(t, http.StatusNotFound, w.Code) -} - -// Regression for the bypass Copilot caught on PR #193: the elevated gate on -// the dedicated participant endpoints was circumventable via PUT -// /api/competitions/:id, which persists the roster (SaveParticipants/SaveSeeds) -// whenever the body has a non-nil Players field, and that is the SPA's -// primary roster flow. The bulk PUT must now enforce the same gate, but ONLY -// when it carries a roster mutation; settings-only PUTs stay single-factor. -func TestBulkCompetitionPut_RosterMutationIsGated(t *testing.T) { - r, store, _, _, dir := setupTestRouter(t) - defer os.RemoveAll(dir) - - // Activate the gate (file mode) and create a competition. - setAdminPassword(t, store, "destroypw") - require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: "c1", Name: "C1", Format: "knockout", Courts: []string{"A"}, Status: state.CompStatusSetup, - })) - - putComp := func(body string, admin string, withAdmin bool) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPut, "/api/competitions/c1", bytes.NewReader([]byte(body))) - req.Header.Set("Content-Type", "application/json") - if withAdmin { - req.Header.Set("X-Admin-Password", admin) - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w - } - - rosterBody := `{"id":"c1","name":"C1","format":"knockout","courts":["A"],"players":[{"name":"Alice","dojo":"D"}]}` - settingsBody := `{"id":"c1","name":"C1 Renamed","format":"knockout","courts":["A"]}` // no players field → nil - - t.Run("roster mutation without admin header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, putComp(rosterBody, "", false).Code) - }) - t.Run("roster mutation with wrong admin header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, putComp(rosterBody, "wrong", true).Code) - }) - t.Run("roster mutation with correct admin header → 200 and persists", func(t *testing.T) { - assert.Equal(t, http.StatusOK, putComp(rosterBody, "destroypw", true).Code) - players, err := store.LoadParticipants("c1", false) - require.NoError(t, err) - require.Len(t, players, 1) - assert.Equal(t, "Alice", players[0].Name) - }) - t.Run("settings-only PUT (no players) is NOT gated", func(t *testing.T) { - // No admin header, gate active, must still succeed because the body - // carries no roster mutation. - assert.Equal(t, http.StatusOK, putComp(settingsBody, "", false).Code) - }) -} - -func mustBcrypt(t *testing.T, plain string) string { - t.Helper() - h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - return string(h) -} diff --git a/internal/mobileapp/handlers_auth_config.go b/internal/mobileapp/handlers_auth_config.go index 41d7f58f2..10234aca2 100644 --- a/internal/mobileapp/handlers_auth_config.go +++ b/internal/mobileapp/handlers_auth_config.go @@ -21,22 +21,6 @@ type authConfigResponse struct { Mode string `json:"mode"` ResetEnabled bool `json:"resetEnabled"` - // Elevated-password (destructive-ops) bits, spec 004 / mp-e21. The SPA - // reads these on mount to decide whether to prompt for X-Admin-Password - // before a destructive action and whether the Settings field is editable. - // - // ElevatedRequired , is the gate active? (file: admin pw set; - // locked: always true). When false, destructive - // ops need only the main password. - // ElevatedConfigured, does a credential exist? (file: admin pw set; - // locked: env hash present). When required but - // not configured, gated endpoints return 503. - // ElevatedEditable , can the admin password be set via the API? - // file mode true; locked mode false (env-var). - ElevatedRequired bool `json:"elevatedRequired"` - ElevatedConfigured bool `json:"elevatedConfigured"` - ElevatedEditable bool `json:"elevatedEditable"` - // ScheduleEnabled controls whether the SPA renders the admin // "Tournament schedule" card. Sourced from the ENABLE_TOURNAMENT_SCHEDULE // env var (truthy: "1", "true", "yes", "on"). Default OFF, mp-fwce. @@ -57,17 +41,12 @@ type authConfigResponse struct { // scheduleEnabled is sourced from ENABLE_TOURNAMENT_SCHEDULE in // cmd/mobile_app.go and threaded here so the env var is read once at // startup rather than on every request. -func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier, elevated ElevatedVerifier, scheduleEnabled bool) { +func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier, scheduleEnabled bool) { r.GET("/auth-config", func(c *gin.Context) { c.JSON(http.StatusOK, authConfigResponse{ - Mode: verifier.Mode(), - ResetEnabled: verifier.ResetEnabled(), - ElevatedRequired: elevated.GateActive(), - ElevatedConfigured: elevated.Configured(), - // Editable only in file mode, the locked-mode credential is the - // TOURNAMENT_ADMIN_PASSWORD_HASH env var, not settable via API. - ElevatedEditable: elevated.Mode() == "file", - ScheduleEnabled: scheduleEnabled, + Mode: verifier.Mode(), + ResetEnabled: verifier.ResetEnabled(), + ScheduleEnabled: scheduleEnabled, }) }) } diff --git a/internal/mobileapp/handlers_auth_config_test.go b/internal/mobileapp/handlers_auth_config_test.go index 92f7cf080..92f4a68ee 100644 --- a/internal/mobileapp/handlers_auth_config_test.go +++ b/internal/mobileapp/handlers_auth_config_test.go @@ -33,8 +33,7 @@ func TestAuthConfigHandler_ScheduleEnabledFlag(t *testing.T) { r := gin.New() api := r.Group("/api") verifier := NewFileVerifier(store) - elevated := NewFileElevatedVerifier(store) - RegisterAuthConfigHandlers(api, verifier, elevated, tc.scheduleEnabled) + RegisterAuthConfigHandlers(api, verifier, tc.scheduleEnabled) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/auth-config", nil) @@ -47,6 +46,14 @@ func TestAuthConfigHandler_ScheduleEnabledFlag(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, tc.scheduleEnabled, resp.ScheduleEnabled, "scheduleEnabled in response must match the flag passed to RegisterAuthConfigHandlers") + + // The destructive-ops password was removed (operator ruling + // 2026-10-12), so the payload carries none of its bits. + var raw map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) + for _, gone := range []string{"elevatedRequired", "elevatedConfigured", "elevatedEditable"} { + assert.NotContains(t, raw, gone, "auth-config must not report %s", gone) + } }) } } diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index d7c88c310..7c44c7566 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -724,7 +724,7 @@ func migrateMissingNumberPrefixes(store *state.Store, eng *engine.Engine, comps return reloaded } -func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub, elevated ElevatedVerifier) { +func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub) { r.GET("/competitions", func(c *gin.Context) { comps, err := loadAllCompetitions(store) if err != nil { @@ -1154,8 +1154,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // GET /competitions/:id/schedule/estimate, pre-draw schedule estimate // for a specific competition. Read-only; main-password gated (registered - // under adminGroup via RegisterCompetitionHandlers) but does NOT require - // elevated auth. Returns a ScheduleEstimate JSON body; 404 for unknown + // under adminGroup via RegisterCompetitionHandlers). Returns a ScheduleEstimate JSON body; 404 for unknown // competition. mp-zoh Phase 3. r.GET("/competitions/:id/schedule/estimate", func(c *gin.Context) { id, ok := requireValidCompID(c) @@ -1222,18 +1221,11 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en } comp.ID = id // ensure ID matches URL (also for empty-body case) - // Elevated-password gate for the ROSTER-mutation path (spec 004 / - // mp-e21; Copilot PR #193). This handler doubles as a roster writer: - // a non-nil Players field below triggers SaveParticipants/SaveSeeds. - // That is the SPA's PRIMARY roster flow (paste/import, seed edits via - // API.updateCompetition), so gating only the dedicated - // POST/PUT /participants endpoints would leave the gate bypassable. - // Route-level middleware can't see Players (it runs before binding), - // so enforce inline now that the body is decoded. Settings-only PUTs - // (Players == nil) are unaffected and stay single-factor. - if comp.Players != nil && !enforceElevated(c, elevated) { - return - } + // This handler doubles as a roster writer (a non-nil Players field + // below triggers SaveParticipants/SaveSeeds). It needs no second + // password: the admin sign-in is enough (operator ruling 2026-10-12), + // and in self-run the route is organiser-only + // (isSelfRunMainGatedConfigRoute). comp.Name = strings.TrimSpace(comp.Name) // See POST handler comment, same trim is needed here so the @@ -2361,7 +2353,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en }{Competition: updated, Warnings: nearDupWarnings}) }) - r.DELETE("/competitions/:id", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2385,7 +2377,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - r.POST("/competitions/:id/invalidate", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.POST("/competitions/:id/invalidate", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2499,7 +2491,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, comp) }) - r.DELETE("/competitions/:id/draw", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id/draw", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2512,11 +2504,12 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - // Elevated-gated for the same reason as DELETE /draw above: it discards - // recorded results. It does NOT delete the operator's file, but the - // knockout results inside it become unreachable through the app, so it is - // as consequential as a discard from the console's point of view. - r.POST("/competitions/:id/bracket/quarantine", RequireElevatedPassword(elevated), func(c *gin.Context) { + // Organiser-only in self-run (isSelfRunMainGatedConfigRoute), like DELETE + // /draw above: it discards recorded results. It does NOT delete the + // operator's file, but the knockout results inside it become unreachable + // through the app, so it is as consequential as a discard from the + // console's point of view. + r.POST("/competitions/:id/bracket/quarantine", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2539,10 +2532,14 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en }) }) - // Elevated-gated: completing a competition is IRREVERSIBLE (invalidate - // rejects a completed comp), like DELETE /competitions/:id and - // /invalidate. In file mode without an admin password the gate is a no-op. - r.POST("/competitions/:id/complete", RequireElevatedPassword(elevated), func(c *gin.Context) { + // Completing a competition is IRREVERSIBLE (invalidate rejects a completed + // comp), but it asks for no second password (operator ruling 2026-10-12: + // "Completing a competition must not require an admin password"). It needs + // what starting a competition needs: the sign-in in officiated mode, and + // nothing in self-run, where it is deliberately NOT in + // isSelfRunMainGatedConfigRoute. The bracket guard below is what stops a + // competition being sealed early. + r.POST("/competitions/:id/complete", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2564,14 +2561,17 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // completion. tx.LoadBracket reuses the already-held lock; the public // Store.LoadBracket would deadlock the non-reentrant mutex. // - // len(Rounds) > 0 skips bracketless formats (league/swiss/pools complete - // via their own path -- no bracket.json, which LoadBracket maps to an - // empty bracket + nil error). Completion is IRREVERSIBLE, so a genuine - // I/O/parse fault fails CLOSED (500) rather than sealing with unknown - // bracket state. + // A bracketless format (league/swiss/pools: no bracket.json, which + // LoadBracket maps to an empty bracket + nil error) is held to its own + // matches instead (engine.PoolMatchesSettled, plus the Swiss round + // count). Completion is IRREVERSIBLE, so a genuine I/O/parse fault + // fails CLOSED (500) rather than sealing with unknown state. var compOut *state.Competition - var statusErr, bracketFault error + var statusErr, bracketFault, matchesFault error var notFoundFlag bool + // tx.LoadPoolMatches skips the first-read legacy conversion the + // store's own read runs; run it first, as AdvanceSwissRound does. + store.EnsureLegacyUpgraded(id) txErr := store.WithTransaction(id, func(tx state.StoreTx) error { current, lerr := tx.LoadCompetition(id) if lerr != nil { @@ -2594,6 +2594,27 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en statusErr = fmt.Errorf("all bracket matches must be completed before this competition can be finished") return nil } + if bracket == nil || len(bracket.Rounds) == 0 { + // No knockout rounds to check (a league, a Swiss competition, + // a pools-only one): the competition is finished only once its + // own matches are, by the rule a league completes by itself + // (engine.PoolMatchesSettled), and a Swiss competition only + // once every configured round has been drawn. Every caller is + // held to it, the anonymous self-run caller included. + matches, merr := tx.LoadPoolMatches(id) + if merr != nil { + matchesFault = merr + return nil + } + if !engine.PoolMatchesSettled(matches) { + statusErr = fmt.Errorf("all matches must be completed before this competition can be finished") + return nil + } + if n := engine.SwissRoundsToDraw(current); n > 0 { + statusErr = fmt.Errorf("%d Swiss round(s) remain to be drawn and played before this competition can be finished", n) + return nil + } + } current.Status = state.CompStatusComplete compOut = current return tx.SaveCompetition(current) @@ -2606,6 +2627,10 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bracket"}) return } + if matchesFault != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load matches"}) + return + } if statusErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": statusErr.Error()}) return @@ -2801,7 +2826,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // the override-rank endpoint. "recorded" lists the ties a chusen already // settled, with the recorded ranks, so a wrong order can be changed. Admin-gated: RegisterCompetitionHandlers is // mounted under the authenticated router group, so callers must send the - // tournament password header (unlike the public league-tiebreak candidates). + // admin password header (unlike the public league-tiebreak candidates). r.GET("/competitions/:id/chusen-candidates", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { @@ -2857,7 +2882,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, gin.H{"candidates": groupsJSON(report.Pending), "recorded": groupsJSON(report.Recorded)}) }) - r.DELETE("/competitions/:id/overrides", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id/overrides", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2890,7 +2915,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - r.PUT("/competitions/:id/awards", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.PUT("/competitions/:id/awards", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return diff --git a/internal/mobileapp/handlers_competition_estimate_test.go b/internal/mobileapp/handlers_competition_estimate_test.go index 600fb974a..292dc70e9 100644 --- a/internal/mobileapp/handlers_competition_estimate_test.go +++ b/internal/mobileapp/handlers_competition_estimate_test.go @@ -99,11 +99,10 @@ func TestGETCompetitionScheduleEstimate(t *testing.T) { "expected non-zero total duration for competition with 8 participants") }) - t.Run("no elevated auth required, main password sufficient", func(t *testing.T) { - // The endpoint is registered under adminGroup (main-password gated) - // but does NOT require elevated (admin) auth. This test confirms - // the handler itself does not check for elevated credentials; - // AuthMiddleware enforcement is tested via the real server wiring, + t.Run("main password sufficient", func(t *testing.T) { + // The endpoint is registered under adminGroup (main-password gated). + // This test confirms the handler itself asks for no other + // credential; AuthMiddleware enforcement is tested via the real server wiring, // not this unit-level router (setupTestRouter omits AuthMiddleware). comp := state.Competition{ ID: "main-auth-estimate", @@ -115,10 +114,10 @@ func TestGETCompetitionScheduleEstimate(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/competitions/main-auth-estimate/schedule/estimate", nil) - // No X-Admin-Password, only main-password auth is needed (enforced - // by AuthMiddleware in production, not by the handler itself). + // Only main-password auth is needed (enforced by AuthMiddleware in + // production, not by the handler itself). r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code, - "estimate endpoint must not require elevated auth") + "estimate endpoint must need no other credential") }) } diff --git a/internal/mobileapp/handlers_competition_test.go b/internal/mobileapp/handlers_competition_test.go index 808e8be68..da5fee6b0 100644 --- a/internal/mobileapp/handlers_competition_test.go +++ b/internal/mobileapp/handlers_competition_test.go @@ -3358,43 +3358,6 @@ func TestPUTCompetitionAwards(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("missing elevated password rejected", func(t *testing.T) { - // Build a router with an active elevated gate: store has a tournament - // with a non-empty AdminPassword. The gate becomes active and any - // request without X-Admin-Password must be rejected. - tmpDir := t.TempDir() - s, err := state.NewStore(tmpDir) - require.NoError(t, err) - // SaveTournament with AdminPassword set activates the fileElevatedVerifier gate. - require.NoError(t, s.SaveTournament(&state.Tournament{ - Name: "T", - Password: "mainpw", - Courts: []string{"A"}, - AdminPassword: "secretadminpw", - })) - require.NoError(t, s.SaveCompetition(&state.Competition{ - ID: "awards-elev", Name: "Elev Test", - })) - - gin.SetMode(gin.TestMode) - elev := NewFileElevatedVerifier(s) - hr := gin.New() - api := hr.Group("/api") - RegisterCompetitionHandlers(api, s, nil, NewHub(), elev) - - body2, _ := json.Marshal(map[string]any{ - "fightingSpiritAwards": []map[string]any{ - {"title": "Spirit", "recipientName": "Alice"}, - }, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/awards-elev/awards", bytes.NewBuffer(body2)) - req.Header.Set("Content-Type", "application/json") - // Do NOT set X-Admin-Password, should be rejected with 401 (wrong credential). - hr.ServeHTTP(w, req) - assert.Equalf(t, http.StatusUnauthorized, w.Code, "missing elevated password must return 401, got %d: %s", w.Code, w.Body.String()) - }) - t.Run("whitespace trimmed from awards before persisting", func(t *testing.T) { body, _ := json.Marshal(map[string]any{ "fightingSpiritAwards": []map[string]any{ diff --git a/internal/mobileapp/handlers_import.go b/internal/mobileapp/handlers_import.go index e99e82f5b..799412ca2 100644 --- a/internal/mobileapp/handlers_import.go +++ b/internal/mobileapp/handlers_import.go @@ -85,8 +85,8 @@ type ImportResult struct { Warning string `json:"warning,omitempty"` } -func RegisterImportHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub, elevated ElevatedVerifier) { - r.POST("/tournament/import", RequireElevatedPassword(elevated), func(c *gin.Context) { +func RegisterImportHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub) { + r.POST("/tournament/import", func(c *gin.Context) { if err := c.Request.ParseMultipartForm(64 << 20); err != nil { // 64 MB limit c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) return diff --git a/internal/mobileapp/handlers_kachinuki_roster_test.go b/internal/mobileapp/handlers_kachinuki_roster_test.go index 6283d903a..f0cd0fba4 100644 --- a/internal/mobileapp/handlers_kachinuki_roster_test.go +++ b/internal/mobileapp/handlers_kachinuki_roster_test.go @@ -207,7 +207,7 @@ func TestKachinukiRosterGET_ReadFailureIs500(t *testing.T) { // not, so it is not in isSelfRunMainGatedConfigRoute. func TestSelfRun_KachinukiRosterIsPublic(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) seedKachinukiRosterComp(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) diff --git a/internal/mobileapp/handlers_lineup_changed_test.go b/internal/mobileapp/handlers_lineup_changed_test.go index 7310396f6..19356422b 100644 --- a/internal/mobileapp/handlers_lineup_changed_test.go +++ b/internal/mobileapp/handlers_lineup_changed_test.go @@ -43,7 +43,7 @@ const ( func newChangedLineupFixture(t *testing.T) changedLineupFixture { t.Helper() store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) require.NoError(t, store.SaveCompetition(&state.Competition{ ID: "c1", Name: "Teams", Kind: "team", TeamSize: 5, Format: state.CompFormatLeague, Status: state.CompStatusPools, })) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index cb9c32d2b..84bc645f0 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -888,7 +888,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // calls it over its own snapshot) so the two cannot drift; only // the error SHAPE differs here (partial-success entries carry a // plain message). - check, snap, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult) + check, snap, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult, comp) if snapErr != nil { return snapErr } @@ -2013,7 +2013,9 @@ var errRepBoutAdded = &selfRunRefusal{ // stored. In order: // // - A match that has finished is the organiser's to correct -// (errResultFinalized). +// (errResultFinalized), except that an exact resend of the finish already +// recorded (engine.ExactScoreReplay) passes: the engine answers it as +// recorded and writes nothing to the match (bc-xrpl). // - An encounter has one representative bout: a write listing two is // refused (errDuplicateRepBout). A participant's write never creates one // (only the add route does, with its tie, pool, kachinuki, engi and @@ -2056,11 +2058,23 @@ var errRepBoutAdded = &selfRunRefusal{ // error rather than allowing the write (matchSnapshotOrErr). A match in neither // store (found false) passes on purpose: the write that follows under the same // lock refuses it (the engine's errMatchNotFound), so nothing can land on it. -func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { +func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, comp *state.Competition, startOnly bool) error { if !found { return nil } if isMatchFinalized(snap.Status) { + // The participant's own finish sent again because its answer was + // lost (the offline queue replays it with the stamp it was sent + // with) is no change to a finished match: the engine answers it as + // recorded and writes nothing (engine.ExactScoreReplay, the same + // probe over the same stored match, under this same lock, runs first + // in RecordMatchResultWithIneligibilityTx). The rewrites below shape + // a write that will land, so they have nothing to do here. Any + // resend that differs where it would land is no such replay and is + // refused as before. + if engine.ExactScoreReplay(snap.Stored, result, comp, !snap.InBracket) { + return nil + } return errResultFinalized } var subs []state.SubMatchResult @@ -2934,7 +2948,7 @@ type correctionCheck struct { // reads the snapshot ONCE for every guard of the write (the self-run judge or // the pick judge, then this), and the start gate after it reads the returned // status instead. -func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionCheck { +func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult, comp *state.Competition) correctionCheck { r.ReopenPending = snap.ReopenPending if r.Status == state.MatchStatusCompleted && snap.Status == state.MatchStatusCompleted { // A finish made BEFORE the stored result (a queued Finish replayed @@ -2943,12 +2957,22 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC // (HoldReasonFinishAtomic, bc-mrgc Finding 4) -- its scoreline never // lands beside a verdict it never declared -- and keeps it in the // match's history. Demanding a reason here refused it before it got - // there, and it was lost (bc-mrgc review). An equal stamp is an - // exact replay, which applies, so it is still a correction to - // justify. + // there, and it was lost (bc-mrgc review). if r.ModifiedAt > 0 && r.ModifiedAt < snap.ResultStamp { return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } + // An equal stamp with nothing it changes differing from what is + // stored is the finish already recorded, sent again because its + // answer was lost: the engine answers it as recorded and writes + // nothing (engine.ExactScoreReplay, bc-xrpl). A group changed since + // (a later point from another board, R3/S2) is held by the merge and + // lands nothing, so it is not the resend's to differ in. A same-stamp + // write that differs where it would land is a correction, and still + // needs its reason. comp tells the probe a kachinuki or engi write, + // which the write path shapes before the merge. + if engine.ExactScoreReplay(snap.Stored, r, comp, !snap.InBracket) { + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} + } if r.CorrectionReason == "" { return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision, Reject: missingCorrectionReasonError()} } @@ -2984,7 +3008,7 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC // bulk-score, one read per entry, whose later guard (the landed picks' // judge) compares the match against it. The score handler reads the snapshot // once for all its guards and asks applyCorrectionReason directly. -func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, matchSnapshot, error) { +func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult, comp *state.Competition) (correctionCheck, matchSnapshot, error) { // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason // gate must not pass on an assumed-running status, silently overwriting a // finalized result without the mandatory reason. @@ -2992,7 +3016,7 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * if err != nil { return correctionCheck{}, matchSnapshot{}, err } - return applyCorrectionReason(snap, r), snap, nil + return applyCorrectionReason(snap, r, comp), snap, nil } // missingCorrectionReasonError is the refusal for overwriting a completed @@ -3583,12 +3607,19 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti if snapErr != nil { return snapErr } + // The competition tells the exact-replay probe (the self-run + // judge's and applyCorrectionReason's) a kachinuki or engi + // write (bc-xrpl). + comp, compErr := stx.LoadCompetition(id) + if compErr != nil { + return compErr + } // mp-ba3, bc-dhas: the self-run judge runs under the per-comp // lock, so concurrent anonymous submissions cannot race it. It // may rewrite result's representative bout before the write. selfReported := resultSource == "self-reported" if selfReported { - if err := holdSelfReportedWriteUnderTx(id, mid, snap, found, result, body.StartOnly); err != nil { + if err := holdSelfReportedWriteUnderTx(id, mid, snap, found, result, comp, body.StartOnly); err != nil { engErr = err return nil } @@ -3609,7 +3640,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // runs inside the tx so the is-completed read is race-free (same // lock), over the snapshot read above, and the status it returns // is reused by the start gate below. - check := applyCorrectionReason(snap, result) + check := applyCorrectionReason(snap, result, comp) existingStatus := check.StoredStatus if check.Reject != nil { engErr = check.Reject @@ -3653,6 +3684,9 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti Force: body.ForceDownstreamReopen, Reopened: &reopenedDownstream, StartOnly: body.StartOnly, + // A participant's exact resend of a finish changes nothing, + // so it leaves no history line to loop. + NoReplayHistory: selfReported, }) // engErr is a normal application-level signal (AlreadyIneligible // → 409, validation/not-found → other codes); we surface it diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index 51cff67d6..85a43b0ef 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -45,7 +45,7 @@ func TestApplyCorrectionReasonUnderTx_FailsClosedOnLoadError(t *testing.T) { var gotErr error txErr := store.WithTransaction("c1", func(stx state.StoreTx) error { r := &state.MatchResult{ID: "m1", Status: state.MatchStatusCompleted} - _, _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r) + _, _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r, nil) return nil }) require.NoError(t, txErr) @@ -3641,7 +3641,7 @@ func TestScoreHandler_ScheduledWriteCannotRevertCompleted(t *testing.T) { // near-future epoch into runningRevStore and poisoning the guard so that // every subsequent legitimate admin autosave looks "older" and gets dropped. // -// Requires a non-empty tournament password so that a request with no +// Requires a non-empty admin password so that a request with no // X-Tournament-Password header is treated as an anonymous caller (not admin). func TestSelfRunPolicy_NoRevMetadata(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) diff --git a/internal/mobileapp/handlers_participants.go b/internal/mobileapp/handlers_participants.go index 0384e043c..5b1cc2d58 100644 --- a/internal/mobileapp/handlers_participants.go +++ b/internal/mobileapp/handlers_participants.go @@ -13,7 +13,7 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub Broadcaster, elevated ElevatedVerifier) { +func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub Broadcaster) { r.GET("/competitions/:id/participants", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { @@ -33,7 +33,7 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, players) }) - r.POST("/competitions/:id/participants", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.POST("/competitions/:id/participants", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -375,7 +375,7 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, saved) }) - r.PUT("/competitions/:id/participants/:pid", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.PUT("/competitions/:id/participants/:pid", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -691,6 +691,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } pid := c.Param("pid") comp, err := store.LoadCompetition(id) @@ -727,6 +730,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } var req struct { ParticipantIDs []string `json:"participantIds"` @@ -780,6 +786,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } pid := c.Param("pid") comp, err := store.LoadCompetition(id) @@ -809,3 +818,29 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, updatedPlayer) }) } + +// errCheckInNotInSelfRun refuses every check-in write in a self-run +// tournament, whoever sends it (operator ruling 2026-10-13: "Self run mode has +// no check-in"). The organiser is refused too: the draw ignores check-in there +// (Competition.CheckInApplies), so a stored check-in would decide nothing. +var errCheckInNotInSelfRun = &selfRunRefusal{ + status: http.StatusConflict, + code: "check_in_not_in_self_run", + message: "Self-run mode has no check-in.", +} + +// refuseCheckInInSelfRun answers a check-in write with errCheckInNotInSelfRun +// when the tournament is self-run and reports whether it did, so the handler +// stops. A tournament that cannot be loaded is a 500, never a pass. +func refuseCheckInInSelfRun(c *gin.Context, store *state.Store) bool { + t, err := store.LoadTournament() + if err != nil { + internalError(c, err) + return true + } + if !t.IsSelfRun() { + return false + } + c.JSON(errCheckInNotInSelfRun.status, errCheckInNotInSelfRun.body()) + return true +} diff --git a/internal/mobileapp/handlers_participants_test.go b/internal/mobileapp/handlers_participants_test.go index 50595be74..031c8979d 100644 --- a/internal/mobileapp/handlers_participants_test.go +++ b/internal/mobileapp/handlers_participants_test.go @@ -989,7 +989,7 @@ func TestBulkCheckIn_BroadcastBehaviour(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() admin := r.Group("/api") - RegisterParticipantHandlers(admin, store, engine.New(store), spy, NewFileElevatedVerifier(store)) + RegisterParticipantHandlers(admin, store, engine.New(store), spy) compID := "comp-broadcast-test" require.NoError(t, store.SaveCompetition(&state.Competition{ diff --git a/internal/mobileapp/handlers_reset.go b/internal/mobileapp/handlers_reset.go index a7552389c..5c70e64ee 100644 --- a/internal/mobileapp/handlers_reset.go +++ b/internal/mobileapp/handlers_reset.go @@ -2,6 +2,7 @@ package mobileapp import ( "errors" + "net" "net/http" "net/url" "strings" @@ -18,7 +19,9 @@ import ( // // Operators who want to deny this endpoint should enable locked mode // (--lock-password) which 404s the route. In file mode the endpoint is -// the documented recovery path for a forgotten admin password. +// the documented recovery path for a forgotten admin password, and it +// answers only a caller on the machine running the server (403 +// reset_local_only otherwise). type resetPasswordRequest struct { Password string `json:"password"` // OriginatorID is an opaque per-tab identifier the SPA generates @@ -81,18 +84,57 @@ var errResetPasswordRequired = errors.New("password is required") // /reset page still renders a disabled-state message) and rotate // credentials via env-var hash; the recovery endpoint is designed // for direct same-host operator access. -// - DNS rebinding: a malicious page can rebind its domain to the -// tournament server's IP so that both Origin.Host and c.Request.Host -// equal the attacker's domain, passing the Origin == Host check even -// though the request reaches this server. This endpoint is intended -// for trusted networks (local / private LAN); for any -// internet-exposed deployment use --lock-password instead, which -// disables this endpoint entirely. +// - DNS rebinding: a malicious page opened in a browser on the server +// machine can rebind its domain to 127.0.0.1, so that the peer is +// loopback and both Origin.Host and c.Request.Host equal the +// attacker's domain. isLoopbackHost closes this: the Host must be a +// loopback name, which the attacker's domain is not. // -// We deliberately do NOT restrict to loopback-only: the recovery path -// must be reachable from any browser tab on the same network so -// operators at a scoring table on the far side of the venue can reset -// without SSH access to the server. +// resetLocalOnlyCode and resetLocalOnlySentence are the refusal of a reset +// that does not come from the machine running the server. The SPA shows the +// sentence when it sees the code. +const ( + resetLocalOnlyCode = "reset_local_only" + resetLocalOnlySentence = "Reset the password on the machine running the server." +) + +// isLoopbackPeer reports whether the request arrived over TCP from the machine +// running the server: the peer address is a loopback address (127.0.0.0/8, ::1, +// or an IPv4-mapped 127/8). It reads r.RemoteAddr alone. X-Forwarded-For, +// X-Real-IP, Forwarded and gin's c.ClientIP() are client-controlled (ClientIP +// trusts them by default), so a request from another machine could claim to be +// local through any of them. An address that does not parse is not loopback. +func isLoopbackPeer(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + return isLoopbackIP(host) +} + +// isLoopbackIP reports whether host is a loopback IP literal (127.0.0.0/8, ::1, +// or an IPv4-mapped 127/8). A name or an address that does not parse is not. +func isLoopbackIP(host string) bool { + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// isLoopbackHost reports whether the request was addressed to the machine +// running the server by a loopback name: "localhost" (any case), a loopback IPv4 +// or a bracketed loopback IPv6, each with or without a port. A page whose +// hostname was rebound to 127.0.0.1 (DNS rebinding) reaches this server from a +// loopback peer but carries its own name as Host, so the peer test alone cannot +// tell it from the operator's browser. +func isLoopbackHost(r *http.Request) bool { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + return strings.EqualFold(host, "localhost") || isLoopbackIP(host) +} + func isSameOriginReset(c *gin.Context) bool { origin := strings.TrimSpace(c.GetHeader("Origin")) if origin == "" { @@ -141,6 +183,18 @@ func RegisterResetHandlers(r *gin.RouterGroup, store *state.Store, verifier Pass return } + // File mode answers only the machine running the server, judged on + // the TCP peer address (see isLoopbackPeer) and on a loopback Host + // (see isLoopbackHost), one refusal for both. + if !isLoopbackPeer(c.Request) || !isLoopbackHost(c.Request) { + c.JSON(http.StatusForbidden, gin.H{ + "error": resetLocalOnlySentence, + "code": resetLocalOnlyCode, + "message": resetLocalOnlySentence, + }) + return + } + // Cross-origin POST defense, see isSameOriginReset. Done before // body parsing so a malicious site can't even probe how the // endpoint reacts to a payload. diff --git a/internal/mobileapp/handlers_reset_test.go b/internal/mobileapp/handlers_reset_test.go index 76f6d4aeb..ba4523f27 100644 --- a/internal/mobileapp/handlers_reset_test.go +++ b/internal/mobileapp/handlers_reset_test.go @@ -37,11 +37,181 @@ func setupResetTest(t *testing.T, verifier PasswordVerifier) (*state.Store, *gin r := gin.New() api := r.Group("/api") RegisterResetHandlers(api, store, verifier, hub) - RegisterAuthConfigHandlers(api, verifier, defaultElevatedVerifier(verifier, store), false) + RegisterAuthConfigHandlers(api, verifier, false) return store, r, hub } +// loopbackPeer is the TCP peer address of a browser on the machine running +// the server. httptest.NewRequest defaults RemoteAddr to 192.0.2.1:1234, which +// reset treats as another machine, so every test that reaches the reset body +// sets this. +const loopbackPeer = "127.0.0.1:54321" + +// setupFileModeReset is setupResetTest in file mode over a tournament whose +// password is "old". +func setupFileModeReset(t *testing.T) (*state.Store, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Password: "old"})) + r := gin.New() + RegisterResetHandlers(r.Group("/api"), store, NewFileVerifier(store), NewHub()) + return store, r +} + +// postReset sends a reset POST from the given TCP peer address and returns the +// recorder, so each local-only case states only the peer and the extra headers. +func postReset(r *gin.Engine, remoteAddr string, headers map[string]string) *httptest.ResponseRecorder { + return postResetHost(r, remoteAddr, "localhost:8080", headers) +} + +// postResetHost posts a reset with an explicit Host header. httptest.NewRequest +// defaults it to "example.com", which is not a loopback name. +func postResetHost(r *gin.Engine, remoteAddr, host string, headers map[string]string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"password": "new-password"}) + req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = remoteAddr + req.Host = host + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestReset_FileMode_LoopbackPeers_Succeed(t *testing.T) { + for _, peer := range []string{ + "127.0.0.1:54321", + "[::1]:54321", + "127.8.9.10:54321", + "[::ffff:127.0.0.1]:54321", + } { + t.Run(peer, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, peer, nil) + + assert.Equal(t, http.StatusNoContent, w.Code) + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "new-password", loaded.Password) + }) + } +} + +// A loopback peer is answered only when the Host is a loopback name too: a page +// whose hostname was rebound to 127.0.0.1 arrives from a loopback peer with its +// own name as Host. +func TestReset_FileMode_LoopbackHosts_Succeed(t *testing.T) { + for _, host := range []string{ + "localhost:8080", + "localhost", + "LOCALHOST:8080", + "127.0.0.1:8080", + "127.0.0.1", + "127.8.9.10:8080", + "[::1]:8080", + "[::1]", + "[::ffff:127.0.0.1]:8080", + } { + t.Run(host, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postResetHost(r, loopbackPeer, host, nil) + + assert.Equal(t, http.StatusNoContent, w.Code) + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "new-password", loaded.Password) + }) + } +} + +func TestReset_FileMode_NonLoopbackHost_Refused(t *testing.T) { + for _, host := range []string{ + "evil.example:8080", + "evil.example", + "example.com", + "192.168.1.100:8080", + "localhost.evil.example:8080", + "127.0.0.1.evil.example", + "[2001:db8::1]:8080", + "", + } { + t.Run(host, func(t *testing.T) { + store, r := setupFileModeReset(t) + + // Origin equals Host: the same-origin check alone would pass. + headers := map[string]string{"Origin": "http://" + host} + w := postResetHost(r, loopbackPeer, host, headers) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + assert.Contains(t, w.Body.String(), "Reset the password on the machine running the server.") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password, "a refused reset must leave the password alone") + }) + } +} + +func TestReset_FileMode_NonLoopbackPeer_Refused(t *testing.T) { + for _, peer := range []string{ + "192.0.2.1:1234", + "10.0.0.5:4000", + "[2001:db8::1]:4000", + "not-an-address", + } { + t.Run(peer, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, peer, nil) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + assert.Contains(t, w.Body.String(), "Reset the password on the machine running the server.") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password, "a refused reset must leave the password alone") + }) + } +} + +// A forged forwarding header must not turn a remote peer into a local one: +// the judgement is on the TCP peer address alone. +func TestReset_FileMode_ForgedForwardingHeaders_StillRefused(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, "192.0.2.1:1234", map[string]string{ + "X-Forwarded-For": "127.0.0.1", + "X-Real-IP": "127.0.0.1", + "Forwarded": "for=127.0.0.1", + }) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password) +} + +func TestReset_LockedMode_NonLoopbackPeer_Still404(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("envpass"), bcrypt.MinCost) + require.NoError(t, err) + bcryptV, err := NewBcryptVerifier(string(hash)) + require.NoError(t, err) + _, r, _ := setupResetTest(t, bcryptV) + + w := postReset(r, "192.0.2.1:1234", nil) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "reset disabled") +} + func TestReset_FileMode_Success(t *testing.T) { dir, err := os.MkdirTemp("", "reset-test-*") require.NoError(t, err) @@ -60,6 +230,8 @@ func TestReset_FileMode_Success(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -90,6 +262,8 @@ func TestReset_LockedMode_Returns404(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -112,6 +286,8 @@ func TestReset_NoTournament_Returns409(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -141,6 +317,8 @@ func TestReset_NamedNewTournamentWithPassword_AllowsReset(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "fresh"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -169,9 +347,10 @@ func TestReset_CrossOriginPost_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker-set"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://evil.example") - req.Host = "tournament.local:8080" w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -199,9 +378,10 @@ func TestReset_SameOriginPost_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://tournament.local:8080") - req.Host = "tournament.local:8080" + req.Header.Set("Origin", "http://localhost:8080") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -211,28 +391,20 @@ func TestReset_SameOriginPost_Accepted(t *testing.T) { // A browser on a LAN IP (non-loopback) with matching Origin/Host is accepted. // Operators access the server from other devices on the same network; the // same-origin check (Origin == Host) is sufficient protection here. -func TestReset_LanIPSameOrigin_Accepted(t *testing.T) { - store, err := state.NewStore(t.TempDir()) - require.NoError(t, err) - r := gin.New() - api := r.Group("/api") - RegisterResetHandlers(api, store, NewFileVerifier(store), NewHub()) +func TestReset_LanIPHost_Refused(t *testing.T) { + store, r := setupFileModeReset(t) - require.NoError(t, store.SaveTournament(&state.Tournament{ - Name: "T", - Password: "old", - })) - - body, _ := json.Marshal(map[string]string{"password": "newpw"}) - req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - // LAN IP, Origin matches Host, so same-origin check passes. - req.Header.Set("Origin", "http://192.168.1.100:8080") - req.Host = "192.168.1.100:8080" - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + // A loopback peer addressed by the machine's LAN IP, Origin matching Host: + // the same-origin check passes, the loopback Host check does not. + w := postResetHost(r, loopbackPeer, "192.168.1.100:8080", map[string]string{ + "Origin": "http://192.168.1.100:8080", + }) - assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password) } // A browser on an HTTPS origin that POSTs to an HTTP tournament server @@ -254,14 +426,16 @@ func TestReset_SchemeMismatch_Rejected(t *testing.T) { // Origin says HTTPS; server is HTTP (req.TLS == nil → expectedScheme = "http"). body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://tournament.local:8080") - req.Host = "tournament.local:8080" + req.Header.Set("Origin", "https://localhost:8080") // req.TLS is nil by default (http), scheme mismatch with https Origin w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code, "https Origin against http server must be rejected") + assert.Contains(t, w.Body.String(), "cross-origin") loaded, _ := store.LoadTournament() assert.Equal(t, "old", loaded.Password) } @@ -285,6 +459,8 @@ func TestReset_OriginNull_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "null") w := httptest.NewRecorder() @@ -321,6 +497,8 @@ func TestReset_OriginMalformed_Rejected(t *testing.T) { t.Run(origin, func(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", origin) w := httptest.NewRecorder() @@ -348,6 +526,8 @@ func TestReset_NoOriginHeader_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -383,6 +563,8 @@ func TestReset_BroadcastsPasswordResetEvent(t *testing.T) { "originatorId": "client-abc-123", }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -435,6 +617,8 @@ func TestReset_OriginatorIDOversized_Returns400(t *testing.T) { "originatorId": strings.Repeat("x", MaxLenOriginatorID+1), }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -456,6 +640,8 @@ func TestReset_EmptyPassword_Returns400(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": ""}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -478,6 +664,8 @@ func TestReset_OversizedPassword_Returns400(t *testing.T) { huge := strings.Repeat("x", MaxLenTournamentPassword+1) body, _ := json.Marshal(map[string]string{"password": huge}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/internal/mobileapp/handlers_sponsors_test.go b/internal/mobileapp/handlers_sponsors_test.go index e45c57bac..372168d7e 100644 --- a/internal/mobileapp/handlers_sponsors_test.go +++ b/internal/mobileapp/handlers_sponsors_test.go @@ -426,7 +426,7 @@ func TestPostSponsor_NoTournament(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/api/sponsors", body) req.Header.Set("Content-Type", ct) - // No tournament password set (no tournament exists). AuthMiddleware + // No admin password set (no tournament exists). AuthMiddleware // permits unauth POST /api/tournament on a virgin install, but // /api/sponsors is NOT on that allowlist. The middleware should // reject (4xx, not 200 or 404 from the handler), proving the auth @@ -450,10 +450,9 @@ func TestPostSponsor_SelfRunMode_RejectsAnonymous(t *testing.T) { store, err := state.NewStore(tempDir) require.NoError(t, err) require.NoError(t, store.SaveTournament(&state.Tournament{ - Name: "Self-Run Cup", - Password: "main-pw", - AdminPassword: "admin-pw", - Mode: state.TournamentModeSelfRun, + Name: "Self-Run Cup", + Password: "main-pw", + Mode: state.TournamentModeSelfRun, })) eng := engine.New(store) mockFS := fstest.MapFS{"web-mobile/index.html": {Data: []byte("")}} diff --git a/internal/mobileapp/handlers_swiss.go b/internal/mobileapp/handlers_swiss.go index e311483e4..74165448b 100644 --- a/internal/mobileapp/handlers_swiss.go +++ b/internal/mobileapp/handlers_swiss.go @@ -88,7 +88,7 @@ func RegisterSwissHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.E // which are themselves public via the viewer endpoint, so spectators, // coaches, and TV displays need them without admin credentials. // Discovered during the post-merge browser UAT pass: the SwissStandings -// viewer tab broke with "invalid tournament password" because the +// viewer tab broke with "invalid admin password" because the // admin-only RegisterSwissHandlers was the only registration. // // FR-050e. diff --git a/internal/mobileapp/handlers_swiss_test.go b/internal/mobileapp/handlers_swiss_test.go index 97a7e2387..ad790f11a 100644 --- a/internal/mobileapp/handlers_swiss_test.go +++ b/internal/mobileapp/handlers_swiss_test.go @@ -17,7 +17,7 @@ import ( ) // adminReqSwiss is a tiny helper that wraps a request through the -// admin-protected swiss endpoints. Tournament password is the empty +// admin-protected swiss endpoints. Admin password is the empty // string by default (set on the tournament in setupTestRouter); we // re-use the existing test plumbing. func adminReqSwiss(method, path string) *http.Request { diff --git a/internal/mobileapp/handlers_test.go b/internal/mobileapp/handlers_test.go index e1ccfa65f..92d71dc71 100644 --- a/internal/mobileapp/handlers_test.go +++ b/internal/mobileapp/handlers_test.go @@ -50,9 +50,9 @@ func setupTestRouter(t testing.TB) (*gin.Engine, *state.Store, *engine.Engine, * // Admin API admin := r.Group("/api") RegisterTournamentHandlers(admin, store, hub, NewFileVerifier(store)) - RegisterImportHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) - RegisterCompetitionHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) - RegisterParticipantHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) + RegisterImportHandlers(admin, store, eng, hub) + RegisterCompetitionHandlers(admin, store, eng, hub) + RegisterParticipantHandlers(admin, store, eng, hub) RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) RegisterDecisionHandlers(admin, eng, store, store, hub) RegisterEligibilityHandlers(admin, store, hub) @@ -269,7 +269,7 @@ func TestTournamentHandlers(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "POST /api/tournament with empty Password must return 400") - assert.Contains(t, w.Body.String(), "tournament password is required", + assert.Contains(t, w.Body.String(), "admin password is required", "rejection should explain the empty-password reason") // Confirm it didn't land on disk. stored, _ := store.LoadTournament() @@ -346,7 +346,7 @@ func TestTournamentHandlers(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "PUT with empty Password against legacy empty-password tournament must reject") - assert.Contains(t, w.Body.String(), "tournament password is required") + assert.Contains(t, w.Body.String(), "admin password is required") } // mp-ef3 Copilot round 2: public info field trimming, contacts-count @@ -837,7 +837,7 @@ func TestTournamentHandlers_FileMode_POSTOverwriteWithNewPassword_BroadcastsRese } } assert.True(t, seen["tournament_updated"], "tournament_updated event missing") - assert.True(t, seen["password_reset"], "password_reset event missing, overwriting an existing tournament's password must clear other admins' cached credentials") + assert.True(t, seen["password_reset"], "password_reset event missing, overwriting an existing admin password must clear other admins' cached credentials") } // TestTournamentHandlers_ModeSwitchPreservesStoredPassword walks the diff --git a/internal/mobileapp/handlers_tournament.go b/internal/mobileapp/handlers_tournament.go index fffda5710..c1c651cc3 100644 --- a/internal/mobileapp/handlers_tournament.go +++ b/internal/mobileapp/handlers_tournament.go @@ -458,7 +458,7 @@ func resolveCompetitionCourts(compCourts []string, tourn *state.Tournament) []st // UpdateTournamentChanged unchanged so the handler can map it to a // 400 response. Using a typed sentinel rather than an inline error // keeps the handler's errors.Is check stable across refactors. -var errPasswordRequired = errors.New("tournament password is required") +var errPasswordRequired = errors.New("admin password is required") // validateTournamentDurationDays returns an error when durationDays is // outside the accepted range. 0 is allowed (treated as "default to 1" by @@ -479,15 +479,6 @@ func validateTournamentDurationDays(durationDays int) error { // participants out mid-scoring (self-run → officiated). var errModeImmutable = errors.New("tournament mode cannot be changed after creation") -// errSelfRunRequiresAdminPassword is the sentinel returned when a caller -// tries to create or save a self-run tournament in file mode without an -// elevated (admin) password configured. In self-run mode the main-gate -// is skipped, so if enforceElevated's GateActive()==false path (file mode, -// no admin password) were reached the destructive routes would be fully -// public, violating the self-run invariant that destructive calls still -// require X-Admin-Password. (mp-7h7 fail-open fix) -var errSelfRunRequiresAdminPassword = errors.New("self-run tournaments require an admin (destructive-ops) password to be set; configure it before switching to self-run mode or set it in the same request") - // trimPublicInfoFields trims all optional public tournament info string fields // and normalises the Contacts slice: each entry's Label/Value is trimmed and // all-empty entries are dropped. Called identically by the PUT and POST @@ -766,15 +757,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub // immediately instead of waiting for their next write to 401. passwordChanged := false changed, err := store.UpdateTournamentChanged(&t, func(current, desired *state.Tournament) error { - // Preserve the write-only elevated password (spec 004). It has - // json:"-", so the bound body always leaves desired.AdminPassword - // == "", without copying it forward, a routine name/venue save - // would silently wipe the elevated credential. It is changed only - // via PUT /api/auth/admin-password, never here. - if current != nil { - desired.AdminPassword = current.AdminPassword - } - // Preserve Theme.LogoPath (mp-scf): LogoPath has json:"-" so the // bound body never carries it. The logo is managed via // POST/DELETE /api/branding/logo; this PUT must not clear it. @@ -790,8 +772,7 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub } // Immutability of Mode (mp-7h7): Mode is chosen once at creation - // and cannot be changed via PUT. Mirror the write-only AdminPassword - // preservation pattern: when the incoming body sends Mode == "" + // and cannot be changed via PUT. When the incoming body sends Mode == "" // (omitempty), preserve the current stored value. When the body // sends a non-empty Mode that differs from the stored Mode, reject. // @@ -847,27 +828,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub passwordChanged = true } - // Fail-open guard for self-run + file mode (mp-7h7): if the - // tournament is self-run, the main-gate is skipped, so - // destructive routes rely SOLELY on RequireElevatedPassword / - // enforceElevated. enforceElevated allows requests when - // GateActive()==false (file mode, no AdminPassword set). Without - // this guard, a self-run tournament with no admin password would - // leave destructive routes fully public. Locked mode always fails - // closed (GateActive always true → 503 when unconfigured) so we - // must NOT apply this guard in locked mode: there, the on-disk - // AdminPassword is always empty/inert (the env-var bcrypt hash is - // authoritative), so guarding on desired.AdminPassword == "" - // would wrongly reject every PUT to a locked-mode self-run - // tournament (e.g. a routine venue edit). The `locked` flag is - // captured from the enclosing handler scope. - // - // desired.AdminPassword was preserved from current above, so it - // reflects the current on-disk value. - if !locked && desired.Mode == state.TournamentModeSelfRun && desired.AdminPassword == "" { - return errSelfRunRequiresAdminPassword - } - return nil }) if errors.Is(err, errPasswordRequired) { @@ -878,10 +838,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub c.JSON(http.StatusBadRequest, gin.H{"error": errModeImmutable.Error()}) return } - if errors.Is(err, errSelfRunRequiresAdminPassword) { - c.JSON(http.StatusBadRequest, gin.H{"error": errSelfRunRequiresAdminPassword.Error()}) - return - } if err != nil { internalError(c, err) return @@ -921,34 +877,12 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub r.POST("/tournament", func(c *gin.Context) { var t state.Tournament - // ShouldBindBodyWith caches the raw request body so we can bind it a - // SECOND time below into a separate struct. This lets us read a - // transient `adminPassword` field from the SAME POST body WITHOUT - // weakening the json:"-" invariant on Tournament.AdminPassword (which - // deliberately can never be populated by binding a request body). - // Creation is the one safe place to accept the elevated credential in - // the body: there is no existing credential to protect at bootstrap, - // and it lets self-run creation be atomic (the fail-open guard never - // sees a self-run tournament persisted without an admin password). PUT - // keeps the json:"-" preserve-only behaviour (rotation stays on - // PUT /api/auth/admin-password). + // An `adminPassword` field sent by an older client (the removed + // destructive-ops password) is an unknown key here and is ignored. if err := c.ShouldBindBodyWith(&t, binding.JSON); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Second bind into a local struct to capture the transient admin - // (destructive-ops) password. Tournament.AdminPassword has json:"-" - // so it is never populated by the bind above, this is the only way - // to read it from the body at creation time. A bind failure here is - // impossible (the first bind already validated the JSON), but handle - // it for safety. - var extra struct { - AdminPassword string `json:"adminPassword"` - } - if err := c.ShouldBindBodyWith(&extra, binding.JSON); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } // See PUT handler above. The current CreateTournament UI in // app.jsx trims client-side before submit; this is defense-in-depth // against direct API callers (curl etc.) sending padded values, @@ -1069,19 +1003,10 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub t.Password = "" } } else if t.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "tournament password is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "admin password is required"}) return } - // Preserve the write-only elevated password (spec 004) across a - // re-bootstrap POST. It has json:"-" so the bound body never carries - // it; without this a re-POST over an existing record would wipe the - // elevated credential. On a true first bootstrap (existingForPost == - // nil) it stays "", the operator sets it later via Settings. - if existingForPost != nil { - t.AdminPassword = existingForPost.AdminPassword - } - // Fix 3329416172: Mode is immutable after creation (mp-7h7). On a // re-bootstrap POST (existingForPost != nil) preserve the stored Mode // so a caller cannot flip an officiated tournament to self-run or vice @@ -1097,59 +1022,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub t.Mode = existingMode } - // Atomic admin-password set at creation (mp-7h7). In file mode the - // admin (destructive-ops) password is stored as PLAINTEXT in - // Tournament.AdminPassword (fileElevatedVerifier.Verify does an - // exact-string compare). When the body carried an `adminPassword` - // (read into `extra` above) and no admin password is already on disk, - // set it now, BEFORE the fail-open guard and before - // SaveTournamentChanged, so a self-run tournament is persisted WITH - // the credential in a single request. This is what makes self-run - // creation possible via the real API (the json:"-" invariant blocks - // the field from being bound directly into Tournament). - // - // Locked mode: the env-var bcrypt hash is the authoritative elevated - // credential; any body adminPassword is inert, so ignore it (do not - // write a plaintext value that would never be consulted). - // - // Fix 3329406566: validate adminPassword length before copy. The field - // is transient (not in Tournament) so validateTournamentLengths never - // sees it; enforce MaxLenTournamentPassword here. - fileMode := verifier == nil || !verifier.RedactStoredPassword() - // Fix 3331061367: validate adminPassword length whenever the field is - // present in the body, regardless of mode or whether an existing - // credential is already stored. This enforces the OpenAPI maxLength:256 - // contract consistently: locked mode and re-bootstrap with an existing - // password would otherwise accept and silently discard an oversized - // value, giving callers no feedback that their request violates the - // contract. Only persist the value when in file mode with no existing - // elevated credential (locked mode uses TOURNAMENT_ADMIN_PASSWORD_HASH; - // re-bootstrap preserves the existing credential). - if extra.AdminPassword != "" { - if err := validateMaxLen("adminPassword", extra.AdminPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if fileMode && t.AdminPassword == "" { - t.AdminPassword = extra.AdminPassword - } - } - - // Fail-open guard for self-run + file mode (mp-7h7). - // In self-run mode the main-gate is skipped, so destructive routes - // rely SOLELY on RequireElevatedPassword / enforceElevated. - // enforceElevated GateActive()==false (file mode, no admin password) - // means destructive routes would be fully public, violating the - // invariant. Reject creation of a self-run tournament in file mode - // unless an AdminPassword is set: either already on disk (preserved - // from existingForPost) or supplied in this request's body (set just - // above). Locked mode always fails closed (GateActive always true → - // 503) so no guard needed there. - if fileMode && t.Mode == state.TournamentModeSelfRun && t.AdminPassword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": errSelfRunRequiresAdminPassword.Error()}) - return - } - if _, err := store.SaveTournamentChanged(&t); err != nil { internalError(c, err) return @@ -1157,7 +1029,7 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub hub.Broadcast(EventTournamentUpdated, nil) // In file mode, broadcast EventPasswordReset when an existing - // tournament's password is OVERWRITTEN (re-bootstrap with a new + // admin password is OVERWRITTEN (re-bootstrap with a new // credential) so other logged-in admin sessions clear their // stale cached credentials. Mirrors the PUT handler's behavior. // diff --git a/internal/mobileapp/middleware.go b/internal/mobileapp/middleware.go index 26858fe37..68c2cf8e3 100644 --- a/internal/mobileapp/middleware.go +++ b/internal/mobileapp/middleware.go @@ -186,86 +186,33 @@ func loadExistingCompetition(c *gin.Context, comps CompetitionStore, compID stri return comp, true } -// RequireElevatedPassword gates destructive operations behind a SECOND -// password supplied in the X-Admin-Password header (spec 004 / mp-e21). It -// is layered on top of AuthMiddleware, the route group already verified the -// main password, so this only adds the second factor. Attach it per-route to -// the gated subset (competition delete / invalidate / draw / overrides, -// participant roster mutations, CSV import) rather than to the whole group, -// since most admin routes stay single-factor. -// -// Three outcomes, driven by the ElevatedVerifier contract: -// -// - GateActive() == false → c.Next(). File mode with no admin password -// set: the feature is opt-in, so destructive ops behave exactly as -// before (main password only). This is the back-compat path. -// - Configured() == false → 503. Locked mode with no/invalid -// TOURNAMENT_ADMIN_PASSWORD_HASH: fail closed rather than silently -// allowing destructive ops with the main password alone. -// - otherwise → Verify(X-Admin-Password); 401 on mismatch. -// -// Per the product decision (re-prompt every time) there is no elevation -// token or session, the password travels on each gated request. -func RequireElevatedPassword(ev ElevatedVerifier) gin.HandlerFunc { - return func(c *gin.Context) { - if !enforceElevated(c, ev) { - c.Abort() - return - } - c.Next() - } -} - -// enforceElevated runs the elevated-password gate decision and returns true -// when the request may proceed. On a blocked request it writes the -// appropriate response (503 unconfigured / 401 wrong / 500 verify-error) and -// returns false, the caller must stop (Abort in middleware, or `return` in a -// handler). -// -// It exists as a standalone function, not just the middleware closure, -// because some gated mutations can't be caught by route-level middleware: -// PUT /api/competitions/:id persists the roster (SaveParticipants/SaveSeeds) -// only when the bound body has a non-nil Players field, so its gate must run -// INSIDE the handler after binding. Sharing this one decision keeps the -// inline check and the middleware byte-for-byte identical (same status codes, -// same fail-closed semantics), Copilot PR #193 caught that gating only the -// dedicated participant endpoints left the bulk-PUT roster path open. -func enforceElevated(c *gin.Context, ev ElevatedVerifier) bool { - if !ev.GateActive() { - return true - } - if !ev.Configured() { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin password not configured"}) - return false - } - ok, err := ev.Verify(c.GetHeader("X-Admin-Password")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "admin auth verification failed"}) - return false - } - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin password"}) - return false - } - return true -} - // isSelfRunMainGatedConfigRoute reports whether the given (method, route) // is an organiser-owned configuration/setup mutation that must remain behind // the main-password gate even in self-run mode (mp-7h7 / Copilot #203). // -// Self-run makes the OPERATIONAL play surface public (scoring, check-in, -// start, draw generation) because there is no table operator. But -// competition COMPLETION is elevated-gated (irreversible, like invalidate). -// configuration mutations are NOT operational play and are NOT elevated-gated, -// so the self-run pass-through would otherwise let an anonymous client tamper -// with setup. The routes below mutate: tournament setup (name/password/courts/ -// check-in windows) and competition setup (creation + config). They are keyed -// by the param-free FullPath so the match is stable across path parameters. +// Self-run makes the OPERATIONAL play surface public (scoring, start, draw +// generation, and completing a competition; check-in does not exist) because there is no +// table operator. Organiser actions are NOT operational play, so the self-run +// pass-through would otherwise let an anonymous client tamper with them: the +// routes below mutate tournament setup (name/password/courts/check-in +// windows), competition setup (creation + config, seeds), make +// corrections, or destroy data (delete or invalidate a competition, discard a +// draw, reset overrides, import a tournament). There is no second password +// for any of them (operator ruling 2026-10-12: the destructive-ops password +// was removed; an admin-level action needs the admin sign-in alone), so this +// list is the ONE thing keeping each of them off the public surface. They are +// keyed by the param-free FullPath so the match is stable across path +// parameters. +// +// Completing a competition (POST /api/competitions/:id/complete) is +// deliberately NOT here: in self-run it is open like starting one (operator +// ruling 2026-10-12, "For self managed, no admin password necessary"). Nor +// are the roster add and edit (POST .../participants, PUT +// .../participants/:pid): in self-run anyone can edit the roster (operator +// ruling 2026-10-12). // -// IMPORTANT: any NEW admin route that mutates organiser-owned setup (rather -// than operational play) and is not already elevated-gated MUST be added here, -// otherwise it becomes anonymously writable in self-run mode. +// IMPORTANT: any NEW admin route that is not operational play MUST be added +// here, otherwise it becomes anonymously writable in self-run mode. // // Three team writes are deliberately public in self-run (operator decision, // bc-dhas): the ones the public score sheet makes when a competitor names a @@ -278,18 +225,27 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { switch method + " " + fullPath { case http.MethodGet + " /api/tournament", // Fix 3329406556: password field in full Tournament struct; viewer uses /api/viewer/tournament http.MethodPost + " /api/tournament", // Fix 3329416167: re-bootstrap overwrite when tournament already exists - http.MethodPut + " /api/tournament", // tournament name/password/courts/check-in windows + http.MethodPut + " /api/tournament", // tournament name/password/courts http.MethodPost + " /api/competitions", // create a competition (category), setup http.MethodPut + " /api/competitions/:id", // edit competition config, setup http.MethodPost + " /api/tournament/announce", // Fix 3329416176: organiser config, not operational play http.MethodDelete + " /api/announcements/:id", // Fix 3329416176: organiser config, not operational play http.MethodDelete + " /api/announcements", // Fix 3329416176: organiser config, not operational play - http.MethodPut + " /api/auth/admin-password", // Fix 3330063192: relies on AuthMiddleware main-pw verification; not elevated-gated + http.MethodDelete + " /api/competitions/:id", // bc-cmpw: deleting a competition, organiser, destructive + http.MethodPost + " /api/competitions/:id/invalidate", // bc-cmpw: invalidating a competition, organiser, irreversible + http.MethodDelete + " /api/competitions/:id/draw", // bc-cmpw: discarding a draw, organiser, destructive + http.MethodPost + " /api/competitions/:id/bracket/quarantine", // bc-cmpw: setting aside a damaged bracket file, organiser repair + http.MethodDelete + " /api/competitions/:id/overrides", // bc-cmpw: resetting manual ranking overrides, organiser correction + http.MethodPut + " /api/competitions/:id/awards", // bc-cmpw: fighting-spirit awards, organiser + http.MethodPost + " /api/tournament/import", // bc-cmpw: importing a tournament replaces its data, organiser, destructive http.MethodPut + " /api/competitions/:id/matches/:mid/override-winner", // Fix 3330080949: result correction, organiser, not participant play http.MethodPost + " /api/competitions/:id/matches/:mid/reopen", // mp-gmcg: reopen a finalized kachinuki result, organiser correction, same class as override-winner http.MethodPost + " /api/competitions/:id/matches/:mid/requeue-blocker-and-reopen", // mp-gmcg A4: destructive (requeues a blocker) organiser correction; same class as reopen http.MethodPut + " /api/competitions/:id/pools/:poolId/override-rank", // Fix 3330080949: standings correction, organiser, not participant play http.MethodPost + " /api/competitions/:id/competitor-status", // Fix 3331033814: eligibility mutation, organiser decision, not operational play + http.MethodPost + " /api/competitions/:id/league-tiebreak", // a team league's supplementary tie-break bouts, organiser decision, same class as override-rank + http.MethodDelete + " /api/competitions/:id/league-tiebreak", // removing them, organiser decision + http.MethodPost + " /api/competitions/:id/league-tiebreak/finalize", // accepting a league's shared ranks, organiser decision http.MethodGet + " /api/competitions/:id/export", // Fix 3332740291: xlsx export, admin/CPU-heavy, not operational play http.MethodGet + " /api/competitions/:id/export-results", // mp-i96p: results xlsx export, admin/CPU-heavy, keep gated like /export http.MethodPut + " /api/competitions/:id/matches/:mid/court", // court assignment, organiser coordination @@ -299,9 +255,13 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { http.MethodPut + " /api/competitions/:id/matches/:mid/quick-score", // bc-dhas: same, and it replaces a match's bouts outright, finished or not http.MethodPut + " /api/competitions/:id/seeds", // seeding, organiser pre-draw setup http.MethodPost + " /api/competitions/:id/competitors/:pid/reinstate", // kiken-injury reinstatement, organiser decision - // Forward check-in (single PUT + bulk POST /checkin-bulk) is intentionally ungated, - // participants and desk staff can check in without the main admin password. - // Only reversal (DELETE) requires it. + // Self-run mode has no check-in (operator ruling 2026-10-13): the three + // check-in writes (PUT, POST /checkin-bulk, DELETE) answer 409 + // check_in_not_in_self_run there for every caller + // (refuseCheckInInSelfRun). Forward check-in is ungated here, so that 409 + // is what an anonymous PUT or bulk POST gets; the reversal below stays on + // this list, so an anonymous DELETE gets the gate's 401 first. In an + // officiated tournament the main gate covers all three. http.MethodDelete + " /api/competitions/:id/participants/:pid/checkin", // check-in reversal, organiser correction // The score sheet's own lineup and member writes are NOT here (see the // doc comment above); these are the Lineups page's, which the public @@ -315,6 +275,8 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { http.MethodDelete + " /api/competitions/:id/matches/:mid/kachinuki-bout", // mp-gmcg: removing a bout is an organiser correction, same class as reopen/override-winner; the participant score path gates itself via enforceSelfRunPolicy, this route does not http.MethodPost + " /api/sponsors", // mp-c38: sponsor logo upload, organiser setup, not operational play http.MethodDelete + " /api/sponsors/:index", // mp-c38: sponsor deletion, organiser setup, not operational play + http.MethodPost + " /api/branding/logo", // mp-scf: tournament logo upload, organiser setup, not operational play + http.MethodDelete + " /api/branding/logo", // mp-scf: tournament logo removal, organiser setup, not operational play http.MethodPost + " /api/print/:type": // mp-w87e: PDF export, admin/CPU-heavy, not operational play return true default: @@ -362,7 +324,7 @@ func selfRunAnonymous(c *gin.Context, tl TournamentLoader, verifier PasswordVeri // wrong, on a gated route (AuthMiddleware) and on one that is public in // self-run (selfRunAnonymous) alike. func respondInvalidPassword(c *gin.Context) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid tournament password"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin password"}) } // AuthMiddleware gates admin endpoints behind the X-Tournament-Password @@ -427,18 +389,15 @@ func AuthMiddleware(verifier PasswordVerifier, store *state.Store) gin.HandlerFu // Self-run mode (mp-7h7): skip the main-password gate for operational // routes (scoring, check-in, start, generate draw, etc.), // there is no dedicated table operator, so participants drive their own - // flow. Destructive routes (delete competition, invalidate, complete, - // draw, overrides, participant roster mutations, import) remain gated by the - // EXISTING RequireElevatedPassword / enforceElevated decorators that - // already fire downstream on those specific routes. No new allowlist - // needed, the elevated-decorator set IS the allowlist (DRY). + // flow. // - // Exception, configuration/setup mutations stay main-gated even in - // self-run mode (see isSelfRunMainGatedConfigRoute). These routes - // mutate organiser-owned setup (tournament name/password/courts/ - // check-in windows; competition creation and competition config) as - // opposed to the operational play surface participants drive. They are - // NOT elevated-gated, so without this carve-out the pass-through would + // Exception, organiser routes stay main-gated even in self-run mode + // (see isSelfRunMainGatedConfigRoute). These routes mutate + // organiser-owned setup (tournament name/password/courts/check-in + // windows; competition creation and config), make corrections, + // or destroy data (delete/invalidate a competition, discard a draw, + // reset overrides, import), as opposed to the operational play surface + // participants drive. Without this carve-out the pass-through would // expose them to anonymous callers in self-run (Copilot #203). The SPA // always sends the main password as X-Tournament-Password on admin // mutations, so for the organiser who created the tournament this is @@ -469,7 +428,7 @@ func AuthMiddleware(verifier PasswordVerifier, store *state.Store) gin.HandlerFu // tournament with empty Password is a misconfiguration, and // refusing to authorize is the safer fail-closed choice. The // 403 message tells the operator to fix the password rather than - // the misleading 401 ("invalid tournament password", which + // the misleading 401 ("invalid admin password", which // would imply the request is wrong, not the server state). // // In locked mode the stored Password is irrelevant (auth comes diff --git a/internal/mobileapp/middleware_selfrun_test.go b/internal/mobileapp/middleware_selfrun_test.go index a011c7b59..a4ccd0bde 100644 --- a/internal/mobileapp/middleware_selfrun_test.go +++ b/internal/mobileapp/middleware_selfrun_test.go @@ -2,12 +2,12 @@ package mobileapp // Tests for the self-run tournament mode auth boundary (mp-7h7). // -// Three categories: -// 1. Auth matrix: officiated vs self-run × constructive vs destructive routes -// 2. Fail-open guard: self-run + file mode + no admin pw → 400 at creation +// Categories: +// 1. Auth matrix: officiated vs self-run × public vs organiser routes +// 2. No second password: self-run creation needs none, an old one is ignored // 3. Immutability: POST persists mode; PUT switch → 400; PUT omitting mode → preserved // 4. ValidateTournamentMode + ApplyTournamentDefaults unit tests -// 5. Locked + self-run: elevated gate still fires on destructive routes +// 5. Locked + self-run: organiser routes need the main password import ( "bytes" @@ -17,6 +17,9 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" + "slices" + "strings" "testing" "testing/fstest" @@ -47,19 +50,16 @@ func setupSelfRunRouter(t *testing.T, store *state.Store, verifier PasswordVerif return r } -// seedSelfRunTournament saves a self-run tournament with a main password -// AND sets an admin password via the store directly (since we need the -// adminPassword on disk before we can call enforceElevated). -func seedSelfRunTournament(t *testing.T, store *state.Store, adminPw string) { +// seedSelfRunTournament saves a self-run tournament with a main password. +func seedSelfRunTournament(t *testing.T, store *state.Store) { t.Helper() err := store.SaveTournament(&state.Tournament{ - Name: "SelfRun Test", - Date: "01-06-2026", - Venue: "Dojo", - Courts: []string{"A"}, - Password: "main-pw", - AdminPassword: adminPw, - Mode: state.TournamentModeSelfRun, + Name: "SelfRun Test", + Date: "01-06-2026", + Venue: "Dojo", + Courts: []string{"A"}, + Password: "main-pw", + Mode: state.TournamentModeSelfRun, }) require.NoError(t, err) } @@ -68,13 +68,12 @@ func seedSelfRunTournament(t *testing.T, store *state.Store, adminPw string) { func seedOfficiatedTournament(t *testing.T, store *state.Store) { t.Helper() err := store.SaveTournament(&state.Tournament{ - Name: "Officiated Test", - Date: "01-06-2026", - Venue: "Dojo", - Courts: []string{"A"}, - Password: "main-pw", - AdminPassword: "admin-pw", - Mode: state.TournamentModeOfficiated, + Name: "Officiated Test", + Date: "01-06-2026", + Venue: "Dojo", + Courts: []string{"A"}, + Password: "main-pw", + Mode: state.TournamentModeOfficiated, }) require.NoError(t, err) } @@ -148,11 +147,11 @@ func TestSelfRun_OfficiatedMode_RequiresMainPassword(t *testing.T) { // Constructive routes are public (no main password needed) in self-run mode. func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // Routes that are main-gated-only today (no elevated decorator) become - // public in self-run. We test a subset because full competition state + // Routes that are main-gated in officiated mode and are not organiser + // routes (isSelfRunMainGatedConfigRoute) become public in self-run. We test a subset because full competition state // is complex to seed. The key invariant is: no 401 from the main gate. // // Note: GET /api/tournament is intentionally NOT in this list. In file @@ -162,6 +161,10 @@ func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { // (Copilot #203 finding 3329406556). It is kept in isSelfRunMainGatedConfigRoute. constructiveRoutes := []struct{ method, path string }{ {http.MethodGet, "/api/competitions"}, + // bc-cmpw: completing a competition is open in self-run, like + // starting one (operator ruling 2026-10-12). The handler 404s the + // unknown id; the point is that no gate refused it. + {http.MethodPost, "/api/competitions/nonexistent-id/complete"}, } for _, tc := range constructiveRoutes { @@ -182,7 +185,7 @@ func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { func TestSelfRun_SelfRunMode_GETTournament_RequiresMainPassword(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) t.Run("no_pw_returns_401", func(t *testing.T) { @@ -210,7 +213,7 @@ func TestSelfRun_SelfRunMode_GETTournament_RequiresMainPassword(t *testing.T) { // on PUT /api/tournament even in self-run mode, so this is transparent. func TestSelfRun_SelfRunMode_PUTTournament_RequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) putBody := map[string]any{ @@ -243,11 +246,11 @@ func TestSelfRun_SelfRunMode_PUTTournament_RequiresMainPassword(t *testing.T) { // config) are organiser setup, NOT operational play, like PUT /api/tournament // they stay main-gated in self-run mode (mp-7h7 / Copilot #203 sibling audit). // Without this an anonymous client could create or reconfigure competitions in -// a self-run tournament. These routes are not elevated-gated, so the main gate +// a self-run tournament. These are organiser routes, so the main gate // is the protection. The organiser's SPA always sends X-Tournament-Password. func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) configRoutes := []struct{ method, path string }{ @@ -267,7 +270,18 @@ func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *test // bc-mrgc: a match's write history, held values included, is the // score editors' organiser view; the public page never reads it. {http.MethodGet, "/api/competitions/some-id/matches/some-match/history"}, + // A team league's tie-breaker decisions (run supplementary bouts, + // remove them, or accept the shared ranks) are the organiser's, as a + // pool rank override is. + {http.MethodPost, "/api/competitions/some-id/league-tiebreak"}, + {http.MethodDelete, "/api/competitions/some-id/league-tiebreak"}, + {http.MethodPost, "/api/competitions/some-id/league-tiebreak/finalize"}, + } + for _, fullPath := range []string{"/api/competitions/:id/league-tiebreak", "/api/competitions/:id/league-tiebreak/finalize"} { + assert.True(t, isSelfRunMainGatedConfigRoute(http.MethodPost, fullPath), "POST %s must be organiser-only in self-run", fullPath) } + assert.True(t, isSelfRunMainGatedConfigRoute(http.MethodDelete, "/api/competitions/:id/league-tiebreak"), + "DELETE /api/competitions/:id/league-tiebreak must be organiser-only in self-run") for _, tc := range configRoutes { t.Run("no_pw_401_"+tc.method+"_"+tc.path, func(t *testing.T) { @@ -292,81 +306,328 @@ func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *test } } -// Destructive routes still require X-Admin-Password in self-run mode. -// The main gate is skipped but RequireElevatedPassword still fires. -func TestSelfRun_SelfRunMode_DestructiveRoutes_Require_AdminPassword(t *testing.T) { +// formerlyElevatedOrganiserRoutes lists every route that needed the separate +// destructive-ops password before it was removed (operator ruling 2026-10-12: +// "not re enter admin password to change something. For self managed, no +// admin password necessary"), plus the bulk competition PUT, whose roster path +// asked for it inline. Each is an ORGANISER route now: the admin sign-in alone +// in officiated mode, and organiser-only in self-run +// (isSelfRunMainGatedConfigRoute), so none became public. POST /complete, the +// one other route that password guarded, is public in self-run and is pinned +// by TestSelfRun_Complete_IsPublicAndKeepsTheBracketGuard. The roster add and +// edit were among them too and are open to anyone in self-run (operator +// ruling 2026-10-12: "anyone can edit the roster"): selfRunOpenRosterRoutes. +var formerlyElevatedOrganiserRoutes = []struct{ method, fullPath, path string }{ + {http.MethodDelete, "/api/competitions/:id", "/api/competitions/nonexistent-id"}, + {http.MethodPut, "/api/competitions/:id", "/api/competitions/nonexistent-id"}, + {http.MethodPost, "/api/competitions/:id/invalidate", "/api/competitions/nonexistent-id/invalidate"}, + {http.MethodDelete, "/api/competitions/:id/draw", "/api/competitions/nonexistent-id/draw"}, + {http.MethodPost, "/api/competitions/:id/bracket/quarantine", "/api/competitions/nonexistent-id/bracket/quarantine"}, + {http.MethodDelete, "/api/competitions/:id/overrides", "/api/competitions/nonexistent-id/overrides"}, + {http.MethodPut, "/api/competitions/:id/awards", "/api/competitions/nonexistent-id/awards"}, + {http.MethodPost, "/api/tournament/import", "/api/tournament/import"}, +} + +// selfRunOpenRosterRoutes are the roster add and edit: the admin sign-in in +// officiated mode, open to an anonymous caller in self-run. +var selfRunOpenRosterRoutes = []struct{ method, fullPath, path string }{ + {http.MethodPost, "/api/competitions/:id/participants", "/api/competitions/nonexistent-id/participants"}, + {http.MethodPut, "/api/competitions/:id/participants/:pid", "/api/competitions/nonexistent-id/participants/some-pid"}, +} + +// In self-run mode anyone may add and edit a competition's participants: an +// anonymous caller (an EMPTY password, as the public page sends) reaches both +// handlers and the change is stored. +func TestSelfRun_SelfRunMode_RosterAddAndEdit_AreOpenToAnyone(t *testing.T) { store := newTempStore(t) - // Need a competition to exercise DELETE /competitions/:id - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - destructiveRoutes := []struct{ method, path string }{ - {http.MethodDelete, "/api/competitions/nonexistent-id"}, - {http.MethodPost, "/api/competitions/nonexistent-id/invalidate"}, - {http.MethodDelete, "/api/competitions/nonexistent-id/draw"}, - {http.MethodDelete, "/api/competitions/nonexistent-id/overrides"}, - {http.MethodPost, "/api/tournament/import"}, + for _, tc := range selfRunOpenRosterRoutes { + assert.False(t, isSelfRunMainGatedConfigRoute(tc.method, tc.fullPath), + "%s %s must stay out of isSelfRunMainGatedConfigRoute", tc.method, tc.fullPath) } - for _, tc := range destructiveRoutes { + const compID = "open-roster" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "Open Roster", Status: state.CompStatusSetup})) + + add := jsonReq(http.MethodPost, "/api/competitions/"+compID+"/participants", map[string]any{"name": "Alice", "dojo": "Dojo A"}) + add.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, add) + require.Less(t, w.Code, 300, "an anonymous self-run caller must add a participant: %d %s", w.Code, w.Body.String()) + + players, err := store.LoadParticipants(compID, false) + require.NoError(t, err) + require.Len(t, players, 1) + require.NotEmpty(t, players[0].ID) + + edit := jsonReq(http.MethodPut, "/api/competitions/"+compID+"/participants/"+players[0].ID, map[string]any{"name": "Alice Edited", "dojo": "Dojo A"}) + edit.Header.Set("X-Tournament-Password", "") + w = httptest.NewRecorder() + r.ServeHTTP(w, edit) + require.Less(t, w.Code, 300, "an anonymous self-run caller must edit a participant: %d %s", w.Code, w.Body.String()) + + players, err = store.LoadParticipants(compID, false) + require.NoError(t, err) + require.Len(t, players, 1) + assert.Equal(t, "Alice Edited", players[0].Name) +} + +// In self-run mode every formerly elevated route is organiser-only: refused +// without the main password, and the old admin header alone grants nothing. +func TestSelfRun_SelfRunMode_FormerlyElevatedRoutes_AreOrganiserOnly(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + for _, tc := range formerlyElevatedOrganiserRoutes { + assert.True(t, isSelfRunMainGatedConfigRoute(tc.method, tc.fullPath), + "%s %s must be in isSelfRunMainGatedConfigRoute", tc.method, tc.fullPath) + t.Run("selfrun_no_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "self-run organiser route %s %s must return 401 without the main password", tc.method, tc.path) + }) + + t.Run("selfrun_old_admin_header_alone_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - // No X-Tournament-Password and no X-Admin-Password + req.Header.Set("X-Admin-Password", "main-pw") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must return 401 without admin pw", tc.method, tc.path) + "the removed X-Admin-Password header must grant nothing on %s %s", tc.method, tc.path) }) - t.Run("selfrun_wrong_admin_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + t.Run("selfrun_main_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - req.Header.Set("X-Admin-Password", "wrong-admin-pw") + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") // only DELETE /competitions/:id reads it w := httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must return 401 with wrong admin pw", tc.method, tc.path) + // Past the gate the handler answers for the unknown id or the + // empty body (400/404); no second password is asked for. + assert.NotContains(t, []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable}, w.Code, + "the main password alone must reach %s %s (got %d: %s)", tc.method, tc.path, w.Code, w.Body.String()) }) + } +} + +// In officiated mode the signed-in admin performs every formerly elevated +// action with the main password alone. +func TestOfficiatedMode_FormerlyElevatedRoutes_NeedOnlyTheSignIn(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - t.Run("selfrun_correct_admin_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + for _, tc := range append(slices.Clone(formerlyElevatedOrganiserRoutes), selfRunOpenRosterRoutes...) { + t.Run("officiated_no_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "officiated %s %s must require the sign-in", tc.method, tc.path) + }) + t.Run("officiated_main_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - req.Header.Set("X-Admin-Password", "admin-pw") + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") // only DELETE /competitions/:id reads it w := httptest.NewRecorder() r.ServeHTTP(w, req) - // The gate passed (not 401); the downstream handler may return - // 400/404/422 because we're not providing valid payload / IDs. - // We only care that RequireElevatedPassword didn't reject it. - assert.NotEqual(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must not 401 with correct admin pw", tc.method, tc.path) + assert.NotContains(t, []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable}, w.Code, + "the sign-in alone must reach %s %s (got %d: %s)", tc.method, tc.path, w.Code, w.Body.String()) }) } + + t.Run("delete a competition with the sign-in and the password typed again", func(t *testing.T) { + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "to-delete", Name: "To Delete"})) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/to-delete", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Less(t, w.Code, 300, "delete must succeed: %d %s", w.Code, w.Body.String()) + gone, err := store.LoadCompetition("to-delete") + require.NoError(t, err) + assert.Nil(t, gone, "the competition must be deleted") + }) + + t.Run("save fighting-spirit awards with the sign-in alone", func(t *testing.T) { + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "awards-comp", Name: "Awards"})) + req := jsonReq(http.MethodPut, "/api/competitions/awards-comp/awards", map[string]any{ + "fightingSpiritAwards": []map[string]any{{"title": "Spirit", "recipientName": "Alice"}}, + }) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + saved, err := store.LoadCompetition("awards-comp") + require.NoError(t, err) + require.Len(t, saved.FightingSpiritAwards, 1) + }) +} + +// seedFinishedKnockout saves a knockout competition whose bracket is fully +// played (final and bronze completed) or, with finished false, whose bronze +// match is still to play. +func seedFinishedKnockout(t *testing.T, store *state.Store, compID string, finished bool) { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout, + })) + bronze := state.MatchStatusCompleted + if !finished { + bronze = state.MatchStatusScheduled + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + Rounds: [][]state.BracketMatch{{ + {ID: "m-final", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + }}, + ThirdPlaceMatch: &state.BracketMatch{ID: "m-bronze", SideA: "Carol", SideB: "Dave", Status: bronze, Winner: "Carol"}, + })) } -// POST /api/competitions/:id/participants is elevated-gated (Path A: mp-7h7 -// does NOT relax this endpoint; that's mp-e5j's job). Verify it still -// requires the admin password in self-run mode. -func TestSelfRun_SelfRunMode_ParticipantPOST_StillElevatedGated(t *testing.T) { +// Completing a competition asks no second password (operator ruling +// 2026-10-12): in self-run it is public, like starting one; the bracket +// guard still refuses a bracket with a match to play. +func TestSelfRun_Complete_IsPublicAndKeepsTheBracketGuard(t *testing.T) { + assert.False(t, isSelfRunMainGatedConfigRoute(http.MethodPost, "/api/competitions/:id/complete"), + "POST /complete must stay public in self-run") + store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // No X-Admin-Password → 401 even in self-run. - req := jsonReq(http.MethodPost, "/api/competitions/any-id/participants", map[string]any{}) + seedFinishedKnockout(t, store, "ko-unfinished", false) w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-unfinished/complete", nil)) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "all bracket matches") + + seedFinishedKnockout(t, store, "ko-finished", true) + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + done, err := store.LoadCompetition("ko-finished") + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, done.Status) +} + +// A competition with no knockout rounds to check (a league, a Swiss +// competition) is finished only once every one of its matches is: an +// anonymous self-run caller, who may complete a competition, cannot seal one +// with a match still to play, since completion is irreversible. +func TestSelfRun_Complete_RefusesALeagueWithAMatchToPlay(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + const compID = "league-unfinished" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + matches := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusScheduled}, + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + + complete := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/complete", nil) + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := complete() + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "all matches must be completed") + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusPools, stored.Status, "a refused completion must leave the competition unfinished") + + matches[1].Status = state.MatchStatusCompleted + matches[1].Winner = "Carol" + require.NoError(t, store.SavePoolMatches(compID, matches)) + w = complete() + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + stored, err = store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, stored.Status) +} + +// A Swiss competition is finished only once every configured round has been +// drawn and played: the matches of the rounds drawn so far being complete is +// not enough. +func TestSelfRun_Complete_RefusesASwissCompetitionWithARoundToDraw(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + const compID = "swiss-unfinished" + comp := &state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatSwiss, Status: state.CompStatusPools, + SwissRounds: 3, SwissCurrentRound: 2, + } + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Swiss R1-M1", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + {ID: "Swiss R2-M1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusCompleted, Winner: "Carol"}, + })) + + complete := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/complete", nil) + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := complete() + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "round") + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusPools, stored.Status) + + comp.SwissCurrentRound = 3 + require.NoError(t, store.SaveCompetition(comp)) + w = complete() + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +// In officiated mode completing needs the sign-in, and nothing more. +func TestOfficiatedMode_Complete_NeedsOnlyTheSignIn(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + seedFinishedKnockout(t, store, "ko-finished", true) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, "officiated completion needs the sign-in") + + req := httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w = httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code, - "POST /competitions/:id/participants must remain elevated-gated in self-run (Path A)") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + done, err := store.LoadCompetition("ko-finished") + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, done.Status) } // --------------------------------------------------------------------------- -// §2 Fail-open guard: self-run + file mode without admin password +// §2 No second password // --------------------------------------------------------------------------- -// Creating a self-run tournament in file mode without an admin password -// must return 400. (Without the guard, destructive routes would be public.) -func TestSelfRun_FailOpenGuard_CreationWithoutAdminPw_Rejected(t *testing.T) { +// Creating a self-run tournament needs no admin password: organiser routes +// are protected by the main password (isSelfRunMainGatedConfigRoute). +func TestSelfRun_Creation_NeedsNoAdminPassword(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - // File mode verifier. r := setupSelfRunRouter(t, store, NewFileVerifier(store)) body := map[string]any{ @@ -376,140 +637,82 @@ func TestSelfRun_FailOpenGuard_CreationWithoutAdminPw_Rejected(t *testing.T) { "courts": []string{"A"}, "password": "main-pw", "mode": "self-run", - // NO admin password set at all } - req := jsonReq(http.MethodPost, "/api/tournament", body) w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, - "self-run tournament without admin pw must be rejected in file mode") - assert.Contains(t, w.Body.String(), "admin") + r.ServeHTTP(w, jsonReq(http.MethodPost, "/api/tournament", body)) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + stored, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, state.TournamentModeSelfRun, stored.Mode) + + wDel := httptest.NewRecorder() + r.ServeHTTP(wDel, httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil)) + assert.Equal(t, http.StatusUnauthorized, wDel.Code, "a destructive route stays organiser-only") } -// Creating a self-run tournament via the REAL API body path: the POST carries -// a transient `adminPassword` field (the only way the credential can reach the -// server, since Tournament.AdminPassword is json:"-"). The handler must set it -// atomically with creation so the fail-open guard passes, the tournament is -// persisted WITH the credential, and the destructive routes are gated by it. -// -// This exercises the path the browser/SPA actually uses, the prior -// store-seeding test bypassed it, which is why the "creation impossible via -// real API" bug slipped through. -func TestSelfRun_FailOpenGuard_CreationWithBodyAdminPw_Atomic(t *testing.T) { +// An older client may still send the removed `adminPassword` field, of any +// length; it is ignored and never stored. +func TestSelfRun_Creation_IgnoresALegacyAdminPasswordField(t *testing.T) { gin.SetMode(gin.TestMode) - store := newTempStore(t) + dir := t.TempDir() + store, err := state.NewStore(dir) + require.NoError(t, err) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // Fresh file-mode bootstrap POST: self-run + main password + a transient - // adminPassword in the SAME body. No header (file-mode bootstrap). body := map[string]any{ - "name": "Self-Run Tournament", + "name": "T", "date": "01-06-2026", - "venue": "Dojo", + "venue": "V", "courts": []string{"A"}, "password": "main-pw", "mode": "self-run", - "adminPassword": "destructive-pw", + "adminPassword": string(make([]byte, MaxLenTournamentPassword+1)), } - req := jsonReq(http.MethodPost, "/api/tournament", body) w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code, - "self-run creation with adminPassword in body must succeed: %s", w.Body.String()) + r.ServeHTTP(w, jsonReq(http.MethodPost, "/api/tournament", body)) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) - // The persisted tournament must have mode=self-run AND a non-empty - // (correct) admin password on disk. - stored, err := store.LoadTournament() + raw, err := os.ReadFile(filepath.Join(dir, "tournament.md")) require.NoError(t, err) - require.NotNil(t, stored) - assert.Equal(t, state.TournamentModeSelfRun, stored.Mode, - "stored mode must be self-run") - assert.Equal(t, "destructive-pw", stored.AdminPassword, - "admin password must be persisted atomically with creation") - - // Verify the destructive-route gate now uses the stored admin password: - // no X-Admin-Password → 401; correct X-Admin-Password → passes the gate. - t.Run("destructive route requires admin pw", func(t *testing.T) { - reqNoPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil) - wNoPw := httptest.NewRecorder() - r.ServeHTTP(wNoPw, reqNoPw) - assert.Equal(t, http.StatusUnauthorized, wNoPw.Code, - "destructive route must 401 without X-Admin-Password") - }) - - t.Run("destructive route passes gate with correct admin pw", func(t *testing.T) { - reqWithPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil) - reqWithPw.Header.Set("X-Admin-Password", "destructive-pw") - wWithPw := httptest.NewRecorder() - r.ServeHTTP(wWithPw, reqWithPw) - // Not 401: the elevated gate passed (downstream handler may 204/404). - assert.NotEqual(t, http.StatusUnauthorized, wWithPw.Code, - "destructive route must pass the elevated gate with correct X-Admin-Password (got %d)", wWithPw.Code) - }) - - t.Run("constructive route is public", func(t *testing.T) { - // Use GET /api/competitions, GET /api/tournament is main-gated in - // self-run (file-mode password leak prevention, fix 3329406556). - reqPub := httptest.NewRequest(http.MethodGet, "/api/competitions", nil) - wPub := httptest.NewRecorder() - r.ServeHTTP(wPub, reqPub) - assert.NotEqual(t, http.StatusUnauthorized, wPub.Code, - "constructive route must be public in self-run (no main password)") - }) + assert.NotContains(t, string(raw), "admin_password", "the legacy field must not be stored") } -// Creating a self-run tournament IS allowed when the existing record already -// has an AdminPassword on disk (from a prior setAdminPassword call or -// direct store write). This exercises the preserveFromExisting path where -// t.AdminPassword != "" after the preserve step. -func TestSelfRun_FailOpenGuard_CreationWithExistingAdminPw_Allowed(t *testing.T) { - gin.SetMode(gin.TestMode) - store := newTempStore(t) - - // Seed a prior record with an admin password already set. - err := store.SaveTournament(&state.Tournament{ - Name: "Previous", - Date: "01-01-2026", - Venue: "Old", - Courts: []string{"A"}, - Password: "old-pw", - AdminPassword: "existing-admin-pw", - }) +// A tournament.md written by an earlier release with admin_password still +// loads and works; the value is ignored and the next save drops it. +func TestLegacyAdminPasswordInTournamentFile_IsIgnored(t *testing.T) { + dir := t.TempDir() + store, err := state.NewStore(dir) require.NoError(t, err) + seedOfficiatedTournament(t, store) + path := filepath.Join(dir, "tournament.md") + raw, err := os.ReadFile(path) + require.NoError(t, err) + withLegacy := strings.Replace(string(raw), "password: main-pw\n", "password: main-pw\nadmin_password: old-admin-pw\n", 1) + require.Contains(t, withLegacy, "admin_password: old-admin-pw") + require.NoError(t, os.WriteFile(path, []byte(withLegacy), 0o600)) - r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + // A fresh store reads the file from disk. + store2, err := state.NewStore(dir) + require.NoError(t, err) + loaded, err := store2.LoadTournament() + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Equal(t, "main-pw", loaded.Password) - // Re-POST (re-bootstrap) with mode:"self-run" in body, but no adminPassword - // in the body. The fail-open guard passes because the handler's preserve step - // copies existingForPost.AdminPassword from the prior record. - // NOTE: Mode is immutable, the preserve step also copies existingForPost.Mode - // (empty → "officiated"), so the stored record remains officiated regardless - // of what mode the body requests. This test exercises the fail-open path, not - // self-run creation; for first-create self-run see TestSelfRun_Immutability_POSTPreservesMode. - body := map[string]any{ - "name": "Self-Run Tournament", - "date": "01-06-2026", - "venue": "Dojo", - "courts": []string{"A"}, - "password": "main-pw", - "mode": "self-run", - // adminPassword omitted from body (json:"-"); preserve step picks it up. - } - req := jsonReq(http.MethodPost, "/api/tournament", body) - req.Header.Set("X-Tournament-Password", "old-pw") + r := setupSelfRunRouter(t, store2, NewFileVerifier(store2)) + require.NoError(t, store2.SaveCompetition(&state.Competition{ID: "c1", Name: "C1"})) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/c1", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") w := httptest.NewRecorder() r.ServeHTTP(w, req) - // Should succeed (201) because the existing record has an admin password. - assert.Equal(t, http.StatusCreated, w.Code, - "re-bootstrap must succeed when existing record has admin pw") + assert.Less(t, w.Code, 300, "the sign-in and the password typed again delete a competition: %d %s", w.Code, w.Body.String()) - // The stored mode must be "officiated", mode immutability preserves the - // existing record's mode (empty → officiated) and ignores the body's "self-run". - loaded, err := store.LoadTournament() + require.NoError(t, store2.SaveTournament(loaded)) + after, err := os.ReadFile(path) require.NoError(t, err) - require.NotNil(t, loaded) - assert.Equal(t, state.TournamentModeOfficiated, loaded.Mode, - "re-bootstrap must preserve existing mode (officiated), not adopt body mode") + assert.NotContains(t, string(after), "admin_password", "the next save drops the legacy key") } // --------------------------------------------------------------------------- @@ -518,12 +721,9 @@ func TestSelfRun_FailOpenGuard_CreationWithExistingAdminPw_Allowed(t *testing.T) // POST self-run → GET mode returns "self-run". // This test exercises a fresh bootstrap (no prior tournament.md) so the -// AuthMiddleware bootstrap branch fires (anonymous POST allowed). -// The fail-open guard for self-run requires an admin password, so we do a -// two-step: (1) fresh bootstrap as officiated to create the record with an -// admin password, (2) verify the store persisted mode=officiated, then -// (3) verify that a self-run can be started fresh when admin pw is pre-set -// directly via the store. +// AuthMiddleware bootstrap branch fires (anonymous POST allowed): (1) fresh +// bootstrap as officiated, verify the store persisted mode=officiated, then +// (2) a fresh self-run bootstrap, verify mode=self-run. func TestSelfRun_Immutability_POSTPreservesMode(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) @@ -563,13 +763,12 @@ func TestSelfRun_Immutability_POSTPreservesMode(t *testing.T) { r2 := setupSelfRunRouter(t, store2, NewFileVerifier(store2)) postSelfRun := map[string]any{ - "name": "My Self-Run", - "date": "01-06-2026", - "venue": "Dojo", - "courts": []string{"A"}, - "password": "main-pw", - "mode": "self-run", - "adminPassword": "admin-pw", // required for self-run in file mode + "name": "My Self-Run", + "date": "01-06-2026", + "venue": "Dojo", + "courts": []string{"A"}, + "password": "main-pw", + "mode": "self-run", } // Fresh bootstrap, no X-Tournament-Password needed (file mode, no record). req3 := jsonReq(http.MethodPost, "/api/tournament", postSelfRun) @@ -619,7 +818,7 @@ func TestSelfRun_Immutability_PUT_SwitchModeRejected(t *testing.T) { func TestSelfRun_Immutability_PUT_OmittingModePreservesIt(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) // PUT without mode field, should preserve self-run. @@ -648,7 +847,7 @@ func TestSelfRun_Immutability_PUT_OmittingModePreservesIt(t *testing.T) { func TestSelfRun_Immutability_PUT_SameModeIsIdempotent(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) putBody := map[string]any{ @@ -733,10 +932,12 @@ func TestApplyTournamentDefaults_NormalizesEmptyMode(t *testing.T) { // §5 Locked mode + self-run // --------------------------------------------------------------------------- -// In locked mode the elevated gate is always active (GateActive==true); -// destructive routes in self-run mode must still return 401/503. -func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) { +// In locked mode + self-run the formerly elevated routes are organiser-only: +// 401 without the main password, reached with it. A deployment that still +// sets TOURNAMENT_ADMIN_PASSWORD_HASH starts and works; the value is ignored. +func TestSelfRun_LockedMode_OrganiserRoutes_NeedOnlyTheMainPassword(t *testing.T) { gin.SetMode(gin.TestMode) + t.Setenv(retiredAdminPasswordHashEnv, "$2a$10$retiredretiredretiredretiredretiredretiredretiredre") store := newTempStore(t) // Build a bcrypt verifier (locked mode). @@ -752,24 +953,32 @@ func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) Venue: "Dojo", Courts: []string{"A"}, Password: "", // irrelevant in locked mode - // No AdminPassword on disk, locked mode reads from env var only. - Mode: state.TournamentModeSelfRun, + Mode: state.TournamentModeSelfRun, }) require.NoError(t, err) r := setupSelfRunRouter(t, store, lockedV) - // Destructive route without admin header → 503 (lockedUnconfigured elevated - // verifier: GateActive==true, Configured==false → 503). req := httptest.NewRequest(http.MethodDelete, "/api/competitions/any-id", nil) - // Main password provided (required by locked verifier even in self-run?, - // no: self-run skips the main gate, so we send nothing). w := httptest.NewRecorder() r.ServeHTTP(w, req) - // 401 (admin pw required but not provided) or 503 (no hash configured). - // Either is acceptable, both mean "destructive gate is active." - assert.True(t, w.Code == http.StatusUnauthorized || w.Code == http.StatusServiceUnavailable, - "locked+self-run destructive route must return 401 or 503, got %d: %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "locked+self-run organiser route must return 401 without the main password: %s", w.Body.String()) + + reqPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/any-id", nil) + reqPw.Header.Set("X-Tournament-Password", "main-pw") + reqPw.Header.Set("X-Confirm-Password", "main-pw") + wPw := httptest.NewRecorder() + r.ServeHTTP(wPw, reqPw) + // DELETE of an unknown competition is idempotent: 204 from the handler. + assert.Equal(t, http.StatusNoContent, wPw.Code, + "locked+self-run organiser route must reach the handler with the main password typed twice: %s", wPw.Body.String()) + + reqComplete := httptest.NewRequest(http.MethodPost, "/api/competitions/any-id/complete", nil) + wComplete := httptest.NewRecorder() + r.ServeHTTP(wComplete, reqComplete) + assert.Equal(t, http.StatusNotFound, wComplete.Code, + "locked+self-run completion must be public (the handler 404s the unknown id): %s", wComplete.Body.String()) // Constructive route without any header must NOT be 401 in self-run // (main gate is skipped). Use GET /api/competitions, GET /api/tournament @@ -781,14 +990,10 @@ func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) "locked+self-run constructive route must be public (not 401)") } -// Regression (mp-7h7): a locked-mode self-run tournament has an empty on-disk -// AdminPassword (the env-var bcrypt hash is authoritative). The self-run -// fail-open guard applies to FILE MODE ONLY, in locked mode the elevated -// gate already fails closed via GateActive(). A routine PUT that edits -// venue/name MUST therefore succeed. Before the fix the PUT transform guard -// fired unconditionally on desired.AdminPassword == "", returning 400 -// errSelfRunRequiresAdminPassword and making locked self-run tournaments -// permanently uneditable. +// Regression (mp-7h7): a routine PUT that edits venue/name on a locked-mode +// self-run tournament MUST succeed. An earlier guard (since removed with the +// destructive-ops password) once rejected it and made locked self-run +// tournaments permanently uneditable. func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) @@ -798,8 +1003,8 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { lockedV, err := NewBcryptVerifier(string(hashBytes)) require.NoError(t, err) - // Locked-mode self-run tournament: empty on-disk Password AND empty - // on-disk AdminPassword (both inert; credentials come from env vars). + // Locked-mode self-run tournament: empty on-disk Password (inert; the + // credential comes from the env var). err = store.SaveTournament(&state.Tournament{ Name: "LockedSelfRun", Date: "01-06-2026", @@ -816,8 +1021,7 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { // require the main password even in self-run mode (mp-7h7: only operational // scoring/check-in routes bypass the main gate). In locked mode the bcrypt // verifier requires the env-var password. Password rotation is omitted - // (disabled in locked mode); mode is omitted (preserved). This must NOT be - // rejected by the file-mode-only fail-open guard. + // (disabled in locked mode); mode is omitted (preserved). putBody := map[string]any{ "name": "LockedSelfRun", "date": "01-06-2026", @@ -829,7 +1033,7 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, - "locked self-run PUT venue edit must succeed, not be rejected by the file-mode fail-open guard: %s", w.Body.String()) + "locked self-run PUT venue edit must succeed: %s", w.Body.String()) t2, err := store.LoadTournament() require.NoError(t, err) @@ -863,38 +1067,6 @@ func TestSelfRun_POST_InvalidMode_Rejected(t *testing.T) { "POST with invalid mode must return 400") } -// POST with an oversized adminPassword returns 400 even in locked mode -// (where the field is never persisted). Fix 3331061367: validate the -// transient field whenever present, regardless of server mode. -func TestSelfRun_POST_OversizedAdminPassword_Rejected(t *testing.T) { - gin.SetMode(gin.TestMode) - store := newTempStore(t) - - // Build a bcrypt verifier (locked mode). - hashBytes, err := bcrypt.GenerateFromPassword([]byte("main-pw"), bcrypt.MinCost) - require.NoError(t, err) - lockedV, err := NewBcryptVerifier(string(hashBytes)) - require.NoError(t, err) - - r := setupSelfRunRouter(t, store, lockedV) - - body := map[string]any{ - "name": "T", - "date": "01-06-2026", - "venue": "V", - "courts": []string{"A"}, - "password": "main-pw", - "adminPassword": string(make([]byte, MaxLenTournamentPassword+1)), // 257 chars - } - req := jsonReq(http.MethodPost, "/api/tournament", body) - req.Header.Set("X-Tournament-Password", "main-pw") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, - "oversized adminPassword must return 400 even in locked mode") - assert.Contains(t, w.Body.String(), "adminPassword") -} - // POST with no mode → defaults to officiated. func TestSelfRun_POST_NoMode_DefaultsToOfficiated(t *testing.T) { gin.SetMode(gin.TestMode) @@ -934,7 +1106,7 @@ func TestSelfRun_POST_NoMode_DefaultsToOfficiated(t *testing.T) { func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) buildMultipart := func() (*bytes.Buffer, string) { @@ -980,6 +1152,78 @@ func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// §branding: Self-run auth gate for the tournament logo (mp-scf) +// --------------------------------------------------------------------------- +// +// The logo POST/DELETE are organiser setup, the same class as the sponsor +// routes above, and are listed in isSelfRunMainGatedConfigRoute. Without that +// entry an anonymous caller in self-run mode could replace or remove the +// tournament logo. GET /api/branding/logo stays public: every viewer surface +// shows it. +func TestSelfRun_BrandingLogo_RequiresMainPassword(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + buildMultipart := func() (*bytes.Buffer, string) { + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, err := mw.CreateFormFile("file", "logo.png") + require.NoError(t, err) + _, err = fw.Write(tinyPNG) // reuse tinyPNG defined in handlers_sponsors_test.go + require.NoError(t, err) + require.NoError(t, mw.Close()) + return buf, mw.FormDataContentType() + } + + t.Run("POST_without_password_returns_401", func(t *testing.T) { + body, ct := buildMultipart() + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "POST /api/branding/logo must be main-gated in self-run mode") + _, err := os.Stat(filepath.Join(store.GetFolder(), state.BrandingDirName)) + assert.True(t, os.IsNotExist(err), "a refused upload must store no logo") + }) + + t.Run("DELETE_without_password_returns_401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/branding/logo", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "DELETE /api/branding/logo must be main-gated in self-run mode") + }) + + t.Run("POST_with_main_password_reaches_the_handler", func(t *testing.T) { + body, ct := buildMultipart() + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + }) + + t.Run("DELETE_with_main_password_reaches_the_handler", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/branding/logo", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + }) + + t.Run("GET_logo_is_public", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/branding/logo", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusUnauthorized, w.Code, + "GET /api/branding/logo must be public, every viewer surface shows it") + }) +} + // --------------------------------------------------------------------------- // §reopen: Self-run auth gate for kachinuki match reopen (mp-gmcg) // --------------------------------------------------------------------------- @@ -992,7 +1236,7 @@ func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { // deliberately carries no hand-rolled self-run check. func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) t.Run("without_password_returns_401", func(t *testing.T) { @@ -1027,7 +1271,7 @@ func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { // a missing allowlist entry would let the self-run pass-through expose it. func TestSelfRun_RemoveKachinukiBoutRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) req := jsonReq(http.MethodDelete, "/api/competitions/some-comp/matches/m-r1-0/kachinuki-bout", nil) @@ -1042,7 +1286,7 @@ func TestSelfRun_RemoveKachinukiBoutRequiresMainPassword(t *testing.T) { // anonymously callable in self-run mode. Same central-allowlist gate as reopen. func TestSelfRun_RequeueBlockerAndReopenRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) req := jsonReq(http.MethodPost, "/api/competitions/some-comp/matches/m-r1-0/requeue-blocker-and-reopen", @@ -1061,7 +1305,7 @@ func TestSelfRun_RequeueBlockerAndReopenRequiresMainPassword(t *testing.T) { // either route; organiser tooling sends the password. func TestSelfRun_BulkAndQuickScoreRequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) routes := []struct { diff --git a/internal/mobileapp/public_projection.go b/internal/mobileapp/public_projection.go index 021f4dd17..68f27d638 100644 --- a/internal/mobileapp/public_projection.go +++ b/internal/mobileapp/public_projection.go @@ -17,7 +17,7 @@ import ( // them back over the wire (the SPA only WRITES decisionReason; decisionBy, an // enum, is what drives viewer rendering and is deliberately preserved). They must // never reach spectators. This mirrors the existing redaction at handlers_viewer.go -// where the tournament password is zeroed before public serialization. +// where the admin password is zeroed before public serialization. // // Two flavours, both safe against corrupting stored/cached state: // - COPY helpers take the value by value and return a redacted copy: diff --git a/internal/mobileapp/score_exact_replay_test.go b/internal/mobileapp/score_exact_replay_test.go new file mode 100644 index 000000000..2de9066d6 --- /dev/null +++ b/internal/mobileapp/score_exact_replay_test.go @@ -0,0 +1,449 @@ +package mobileapp + +// bc-xrpl: a completing score write the server stored but whose answer the +// client never read (the page went away, the connection dropped, the fetch +// timed out) is sent again by the offline queue with the SAME stamp. That +// resend is the write it already recorded, so it is answered as recorded +// (engine.ExactScoreReplay), with no correction reason asked and nothing +// written to the match file. A same-stamp write that differs from what is +// stored is still a correction, and still needs its reason. + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// exactReplayServer is a running individual match on court A, Alice v Bob, +// in a pool (Pool A-0) or a knockout (m-r1-0, feeding the final m-r2-0). It +// returns the match's id, the file that holds it and the data folder. +func exactReplayServer(t *testing.T, compID string, knockout bool) (http.Handler, *state.Store, string, string, string) { + t.Helper() + r, store, _, _, dataDir := setupTestRouter(t) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Password: "", Courts: []string{"A"}})) + comp := &state.Competition{ + ID: compID, Name: compID, Kind: "individual", Format: state.CompFormatLeague, + Status: state.CompStatusPools, Courts: []string{"A"}, + } + if knockout { + comp.Format, comp.Status = state.CompFormatKnockout, state.CompStatusKnockout + } + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: mgAliceID, Name: mgAlice, Dojo: "D1"}, + {ID: mgBobID, Name: mgBob, Dojo: "D2"}, + })) + if !knockout { + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: mgAlice, SideAID: mgAliceID, SideB: mgBob, SideBID: mgBobID, Court: "A", Status: state.MatchStatusRunning}, + })) + runningRevStore.Delete(compID + ":Pool A-0") + return r, store, "Pool A-0", "pool-matches.csv", dataDir + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "m-r1-0", SideA: mgAlice, SideAID: mgAliceID, SideB: mgBob, SideBID: mgBobID, + Status: state.MatchStatusRunning, MatchNumber: 1, Court: "A", ScheduledAt: "10:00"}, + }, + { + {ID: "m-r2-0", SideA: "Winner of m-r1-0", MatchNumber: 2, Court: "A"}, + }, + }})) + runningRevStore.Delete(compID + ":m-r1-0") + return r, store, "m-r1-0", "bracket.json", dataDir +} + +// exactReplayFinish is the Finish an operator's score editor sends: Alice +// wins M-K, stamped at. +func exactReplayFinish(at int64) map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "sideAId": mgAliceID, "sideBId": mgBobID, + "winner": mgAlice, "winnerId": mgAliceID, + "ipponsA": []string{"M", "K"}, "ipponsB": []string{}, + "hansokuA": 0, "hansokuB": 0, "decision": "", + "status": "completed", "modifiedAt": at, + } +} + +func TestScoreHandler_ExactResendOfAFinishIsAnsweredAsRecorded(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + // body shapes the resend: as the editor sends it, naming its groups, + // and with no winnerId (the client sends one only when the server + // supplied the side ids; the write fills it in from the sides). + body func(map[string]any) + }{ + {name: "pool", body: func(map[string]any) {}}, + {name: "pool, naming its groups", body: func(b map[string]any) { b["changed"] = []string{"points", "result"} }}, + {name: "pool, no winnerId", body: func(b map[string]any) { delete(b, "winnerId") }}, + {name: "knockout", knockout: true, body: func(map[string]any) {}}, + {name: "knockout, naming its groups", knockout: true, body: func(b map[string]any) { b["changed"] = []string{"points", "result"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay" + r, store, mid, file, dataDir := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + + first := exactReplayFinish(at) + tc.body(first) + w := serveJSON(r, "PUT", url, first) + require.Equal(t, http.StatusOK, w.Code, "the first send records the finish: %s", w.Body.String()) + var recorded map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &recorded)) + + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + versionBefore := store.FileVersion(compID, file) + + // The answer was lost: the queue sends the same write again. + resend := exactReplayFinish(at) + tc.body(resend) + w = serveJSON(r, "PUT", url, resend) + require.Equal(t, http.StatusOK, w.Code, "an exact resend is answered as recorded: %s", w.Body.String()) + var out map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.NotContains(t, out, "applied", "answered like a landed write, not a refusal") + assert.NotContains(t, out, "heldGroups") + assert.Equal(t, "completed", out["status"]) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, mgAliceID, out["winnerId"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"]) + assert.EqualValues(t, at, out["modifiedAt"]) + assert.Equal(t, "A", out["court"], "the answer carries the match's court, as the first one did") + assert.Equal(t, recorded["court"], out["court"]) + assert.Equal(t, recorded["scheduledAt"], out["scheduledAt"]) + + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "nothing is written to the match file") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "nor is its version moved") + + history, err := store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + require.Len(t, history, 2, "the resend has its own history line, as an exact decision replay does") + assert.NotEmpty(t, history[1].Outcomes) + for group, outcome := range history[1].Outcomes { + assert.Equal(t, state.HistoryOutcomeUnchanged, outcome, "group %s", group) + } + assert.Empty(t, history[1].Held) + }) + } +} + +// A self-run participant's finish whose answer was lost is re-sent by the +// offline queue exactly as it was: it is answered as recorded, as the +// organiser's is, and not refused as a result already reported (which the +// page would show as "Not saved" for a result that is stored). A resend that +// differs in anything it would land is still refused: a finished match is +// the organiser's to correct. +func TestScoreHandler_SelfRunExactResendOfAFinishIsAnsweredAsRecorded(t *testing.T) { + for _, knockout := range []bool{false, true} { + name := "pool" + if knockout { + name = "knockout" + } + t.Run(name, func(t *testing.T) { + const compID = "exact-replay-self-run" + r, store, mid, file, dataDir := exactReplayServer(t, compID, knockout) + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "T", Password: "main-pw", Courts: []string{"A"}, Mode: state.TournamentModeSelfRun, + })) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + anonymous := func(payload map[string]any) *httptest.ResponseRecorder { + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := anonymous(exactReplayFinish(at)) + require.Equal(t, http.StatusOK, w.Code, "the participant's finish lands: %s", w.Body.String()) + + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + versionBefore := store.FileVersion(compID, file) + + out := requireAnsweredAsRecorded(t, anonymous(exactReplayFinish(at)), store, compID, file, versionBefore) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"]) + assert.EqualValues(t, at, out["modifiedAt"]) + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "nothing is written to the match file") + + history, err := store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + assert.Len(t, history, 1, "a participant's resend records no history line, so looping it cannot grow the file") + + // The same stamp with another scoreline is no resend. + differs := exactReplayFinish(at) + differs["ipponsA"] = []string{"M"} + w = anonymous(differs) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "result_finalized") + // A later stamp with the same scoreline is no resend either. + w = anonymous(exactReplayFinish(at + 1_000)) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "result_finalized") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "a refused resend writes nothing") + history, err = store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + assert.Len(t, history, 1, "a refused resend leaves no history line") + }) + } +} + +func TestScoreHandler_SameStampFinishThatDiffersStillNeedsAReason(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + change func(map[string]any) + }{ + {name: "pool, another winner", change: func(b map[string]any) { + b["winner"], b["winnerId"], b["ipponsA"], b["ipponsB"] = mgBob, mgBobID, []string{}, []string{"M", "K"} + }}, + {name: "pool, another scoreline", change: func(b map[string]any) { b["ipponsA"] = []string{"M", "D"} }}, + {name: "knockout, another winner", knockout: true, change: func(b map[string]any) { + b["winner"], b["winnerId"], b["ipponsA"], b["ipponsB"] = mgBob, mgBobID, []string{}, []string{"M", "K"} + }}, + {name: "knockout, another scoreline", knockout: true, change: func(b map[string]any) { b["ipponsA"] = []string{"M", "D"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay-differs" + r, _, mid, file, dataDir := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + + w := serveJSON(r, "PUT", url, exactReplayFinish(at)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + + changed := exactReplayFinish(at) + tc.change(changed) + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "the refused correction changes nothing") + + // With its reason it is the correction it is. + changed["correctionReason"] = "wrong winner entered" + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + } +} + +// A completed write with no stamp over a finished match is a correction like +// any other: it needs its reason (only a stamp can say it is the same write). +func TestScoreHandler_UnstampedFinishOverAFinishStillNeedsAReason(t *testing.T) { + const compID = "exact-replay-unstamped" + r, _, mid, _, _ := exactReplayServer(t, compID, false) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + w := serveJSON(r, "PUT", url, exactReplayFinish(time.Now().UnixMilli()-5_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + unstamped := exactReplayFinish(0) + delete(unstamped, "modifiedAt") + w = serveJSON(r, "PUT", url, unstamped) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") +} + +// requireAnsweredAsRecorded checks a resend was answered like a landed write +// (no refusal, nothing held) and that the match file was not written. +func requireAnsweredAsRecorded(t *testing.T, w *httptest.ResponseRecorder, store *state.Store, compID, file string, versionBefore uint64) map[string]any { + t.Helper() + require.Equal(t, http.StatusOK, w.Code, "an exact resend is answered as recorded: %s", w.Body.String()) + var out map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.NotContains(t, out, "applied", "answered like a landed write, not a refusal") + assert.NotContains(t, out, "heldGroups") + assert.Equal(t, "completed", out["status"]) + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "nothing is written to the match file") + return out +} + +// exactReplayLaterPoint is another board adding Alice's kote at `at`, after +// (R3) or before (S2) the finish arrived. +func exactReplayLaterPoint(at int64) map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "sideAId": mgAliceID, "sideBId": mgBobID, + "ipponsA": []string{"M", "K"}, "ipponsB": []string{}, + "status": "running", "changed": []string{"points"}, "modifiedAt": at, + } +} + +// A finish whose answer was lost, while another board added a point stamped +// after it, is resent with the finish's stamp. The merge would hold its points +// (the stored ones are newer) and apply only its verdict, which equals the +// stored one, so nothing of it would land: it is the finish already recorded +// and answered as such, not refused for a correction reason. Both arrival +// orders of the later point (R3: after the finish; S2: before it) end there. +func TestScoreHandler_ExactResendAfterALaterPointIsAnsweredAsRecorded(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + pointArrives string // "after" (R3) or "before" (S2) the finish + }{ + {name: "pool, R3", pointArrives: "after"}, + {name: "knockout, R3", knockout: true, pointArrives: "after"}, + {name: "pool, S2", pointArrives: "before"}, + {name: "knockout, S2", knockout: true, pointArrives: "before"}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay-later-point" + r, store, mid, file, _ := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + b := exactReplayFinish(at) + b["ipponsA"] = []string{"M"} + return b + } + + if tc.pointArrives == "before" { + w := serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + w := serveJSON(r, "PUT", url, finish()) + require.Equal(t, http.StatusOK, w.Code, "the finish lands: %s", w.Body.String()) + if tc.pointArrives == "after" { + w = serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, "R3: the later point applies to the finished match: %s", w.Body.String()) + } + versionBefore := store.FileVersion(compID, file) + + out := requireAnsweredAsRecorded(t, serveJSON(r, "PUT", url, finish()), store, compID, file, versionBefore) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"], "the answer is the match as stored, the later point included") + }) + } +} + +// The other half: a same-stamp resend over that state whose verdict (a group +// the merge would apply) differs from the stored one is a correction, and +// still needs its reason. +func TestScoreHandler_SameStampResendWhoseAppliedGroupDiffersStillNeedsAReason(t *testing.T) { + for _, knockout := range []bool{false, true} { + t.Run(map[bool]string{false: "pool", true: "knockout"}[knockout], func(t *testing.T) { + const compID = "exact-replay-later-point-differs" + r, store, mid, file, _ := exactReplayServer(t, compID, knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + finish := exactReplayFinish(at) + finish["ipponsA"] = []string{"M"} + w := serveJSON(r, "PUT", url, finish) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + w = serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + versionBefore := store.FileVersion(compID, file) + + changed := exactReplayFinish(at) + changed["winner"], changed["winnerId"] = mgBob, mgBobID + changed["ipponsA"], changed["ipponsB"] = []string{}, []string{"M", "K"} + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "the refused correction changes nothing") + }) + } +} + +// The resend of a finish of every other shape the score sheet sends: each +// lands once, then is sent again identically with the same stamp, and is +// answered as recorded however the write path rewrote the first landing +// (applyKachinukiMerge's strip of a trailing unscored pairing and member ids, +// the representative bout's hantei mark and names, the engi winner the flags +// decide). +func TestScoreHandler_ExactResendOfATeamOrEngiFinishIsAnsweredAsRecorded(t *testing.T) { + t.Run("kachinuki encounter", func(t *testing.T) { + const compID = "exact-replay-kachinuki" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M", "K"}, Winner: "R-1", Decision: "fought"}, + {Position: 2, SideA: "R-1", SideB: "W-2"}, + }, + }})) + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + return map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", "status": "completed", "decision": "kachinuki-exhaustion", + "modifiedAt": at, + "subResults": []map[string]any{ + kachinukiSub(1, "R-1", "W-1", []string{"M", "K"}, "R-1", "fought"), + kachinukiSub(2, "R-1", "W-2", []string{}, "", ""), + }, + } + } + w := putScore(t, r, compID, "P1-0", finish()) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Len(t, loadPoolMatch(t, store, compID, "P1-0").SubResults, 1, "the first landing strips the unscored pairing") + versionBefore := store.FileVersion(compID, "pool-matches.csv") + + out := requireAnsweredAsRecorded(t, putScore(t, r, compID, "P1-0", finish()), store, compID, "pool-matches.csv", versionBefore) + assert.Equal(t, "Ryu", out["winner"]) + }) + + t.Run("fixed-order team knockout decided by a hantei on the representative bout", func(t *testing.T) { + f := newRepBoutFixture(t, false) + w := f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now}) + require.Equal(t, http.StatusOK, w.Code, "adding the representative bout: %s", w.Body.String()) + at := f.now + 100 + w = f.score("main-pw", state.MatchStatusCompleted, "TeamA", at, hanteiRow()) + require.Equal(t, http.StatusOK, w.Code, "the finish lands: %s", w.Body.String()) + require.True(t, f.storedRepBout(t).HanteiDecided()) + versionBefore := f.store.FileVersion("c1", "bracket.json") + + out := requireAnsweredAsRecorded(t, f.score("main-pw", state.MatchStatusCompleted, "TeamA", at, hanteiRow()), f.store, "c1", "bracket.json", versionBefore) + assert.Equal(t, "TeamA", out["winner"]) + assert.True(t, f.storedRepBout(t).HanteiDecided(), "the verdict stands") + }) + + t.Run("engi", func(t *testing.T) { + const compID = "exact-replay-engi" + r, store := mergeServer(t, compID) + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Engi = true + require.NoError(t, store.SaveCompetition(comp)) + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "status": "completed", "flagsA": 3, "flagsB": 0, + "changed": []string{"result", "flags"}, "modifiedAt": at, + } + } + code, out := mergeReviewPut(t, r, compID, finish()) + require.Equal(t, http.StatusOK, code, out) + require.Equal(t, mgAlice, mergeStored(t, store, compID).Winner) + versionBefore := store.FileVersion(compID, "pool-matches.csv") + + got := requireAnsweredAsRecorded(t, serveJSON(r, "PUT", "/api/competitions/"+compID+"/matches/Pool A-0/score", finish()), store, compID, "pool-matches.csv", versionBefore) + assert.Equal(t, mgAlice, got["winner"]) + }) +} diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index fa1790ea5..fec3bd82a 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -48,7 +48,7 @@ func newRepBoutFixture(t *testing.T, selfRun bool) *repBoutFixture { t.Helper() store := newTempStore(t) if selfRun { - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) } else { seedOfficiatedTournament(t, store) } @@ -819,7 +819,7 @@ func TestHoldSelfReportedWriteUnderTx(t *testing.T) { snap, found, err := matchSnapshotOrErr(store, "c1", "B1", "test") require.NoError(t, err) - err = holdSelfReportedWriteUnderTx("c1", "B1", snap, found, result, tc.startOnly) + err = holdSelfReportedWriteUnderTx("c1", "B1", snap, found, result, nil, tc.startOnly) if tc.want != nil { var refusal *selfRunRefusal require.ErrorAs(t, err, &refusal) diff --git a/internal/mobileapp/self_run_no_checkin_test.go b/internal/mobileapp/self_run_no_checkin_test.go new file mode 100644 index 000000000..451324d15 --- /dev/null +++ b/internal/mobileapp/self_run_no_checkin_test.go @@ -0,0 +1,112 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Operator ruling 2026-10-13: "Self run mode has no check-in." The three +// check-in writes are refused 409 for every caller in a self-run tournament, +// the organiser included, and work as before in an officiated one. + +const noCheckInComp = "no-checkin" + +type checkInCall struct { + name, method, path string + body any +} + +func checkInCalls(pid string) []checkInCall { + base := "/api/competitions/" + noCheckInComp + "/participants" + return []checkInCall{ + {"PUT checkin", http.MethodPut, base + "/" + pid + "/checkin", nil}, + {"POST checkin-bulk", http.MethodPost, base + "/checkin-bulk", map[string]any{"participantIds": []string{pid}}}, + {"DELETE checkin", http.MethodDelete, base + "/" + pid + "/checkin", nil}, + } +} + +func seedCheckInRoster(t *testing.T, store *state.Store) string { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: noCheckInComp, Name: "No Check-in", Status: state.CompStatusSetup, CheckInEnabled: true, + })) + require.NoError(t, store.SaveParticipants(noCheckInComp, []domain.Player{{Name: "Alice", Dojo: "Suigetsu"}})) + players, err := store.LoadParticipants(noCheckInComp, false) + require.NoError(t, err) + require.Len(t, players, 1) + return players[0].ID +} + +func serve(r http.Handler, c checkInCall, password *string) *httptest.ResponseRecorder { + req := jsonReq(c.method, c.path, c.body) + if password != nil { + req.Header.Set("X-Tournament-Password", *password) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestSelfRun_CheckInWrites_RefusedForTheOrganiser(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + organiser := "main-pw" + + for _, c := range checkInCalls(pid) { + t.Run(c.name, func(t *testing.T) { + w := serve(r, c, &organiser) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "check_in_not_in_self_run", body["error"]) + assert.Equal(t, "Self-run mode has no check-in.", body["message"]) + }) + } + + players, err := store.LoadParticipants(noCheckInComp, false) + require.NoError(t, err) + assert.False(t, players[0].CheckedIn, "a refused check-in writes nothing") +} + +func TestSelfRun_CheckInWrites_RefusedForAnAnonymousCaller(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + anonymous := "" + + for _, c := range checkInCalls(pid)[:2] { // forward check-in: ungated, so the 409 answers + t.Run(c.name, func(t *testing.T) { + w := serve(r, c, &anonymous) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "check_in_not_in_self_run") + }) + } + + // The reversal stays on the organiser-only list: the gate answers first. + w := serve(r, checkInCalls(pid)[2], &anonymous) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestOfficiated_CheckInWrites_StillWork(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + organiser := "main-pw" + + calls := checkInCalls(pid) + for _, c := range []checkInCall{calls[0], calls[2], calls[1]} { + w := serve(r, c, &organiser) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", c.name, w.Body.String()) + } +} diff --git a/internal/mobileapp/self_run_team_writes_test.go b/internal/mobileapp/self_run_team_writes_test.go index 5fec1d99e..a64c35ea2 100644 --- a/internal/mobileapp/self_run_team_writes_test.go +++ b/internal/mobileapp/self_run_team_writes_test.go @@ -41,7 +41,7 @@ func newTeamWritesFixture(t *testing.T, selfRun bool) teamWritesFixture { t.Helper() store := newTempStore(t) if selfRun { - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) } else { seedOfficiatedTournament(t, store) } @@ -317,7 +317,7 @@ func TestSelfRun_ScoreSheetTeamWrites_AWrongPasswordIsRefused(t *testing.T) { func requireInvalidPassword(t *testing.T, w *httptest.ResponseRecorder) { t.Helper() require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) - assert.JSONEq(t, `{"error":"invalid tournament password"}`, w.Body.String()) + assert.JSONEq(t, `{"error":"invalid admin password"}`, w.Body.String()) } // An officiated tournament opens nothing: the same writes still need the diff --git a/internal/mobileapp/server.go b/internal/mobileapp/server.go index ebc75656c..4beb8b1fd 100644 --- a/internal/mobileapp/server.go +++ b/internal/mobileapp/server.go @@ -21,34 +21,26 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -// AdminPasswordHashEnv is the env var holding the bcrypt hash of the -// elevated (destructive-ops) password in locked mode (spec 004 / mp-e21). -// It is the elevated-credential analogue of TOURNAMENT_PASSWORD_HASH. -const AdminPasswordHashEnv = "TOURNAMENT_ADMIN_PASSWORD_HASH" +// retiredAdminPasswordHashEnv held the bcrypt hash of the destructive-ops +// password in locked mode. That password was removed (operator ruling +// 2026-10-12: an admin-level action needs the admin sign-in alone), so the +// variable is read only to say it is ignored: a deployment that still sets +// it starts and works exactly as one that does not. +const retiredAdminPasswordHashEnv = "TOURNAMENT_ADMIN_PASSWORD_HASH" // mobileWebRoot is the embedded-resources subdirectory the mobile-app SPA is // served from. Shared between the fs.Sub call below and FrontendBundleMissing // (frontend_bundle.go) so the two can't drift on which root they check. const mobileWebRoot = "web-mobile" -// defaultElevatedVerifier derives the elevated-password verifier from the -// main verifier's mode (spec 004). File mode reads the write-only -// Tournament.AdminPassword from the store (no env var); locked mode reads -// the bcrypt hash from TOURNAMENT_ADMIN_PASSWORD_HASH, falling back to the -// fail-closed unconfigured verifier (503 on gated endpoints) when the env -// var is absent or malformed. Reading the env here, rather than threading -// an explicit param through NewRouter, keeps the router signature stable -// for the many existing callers; file-mode tests never touch the env. -func defaultElevatedVerifier(verifier PasswordVerifier, store *state.Store) ElevatedVerifier { - if verifier != nil && verifier.Mode() == "locked" { - if v, err := NewBcryptElevatedVerifier(os.Getenv(AdminPasswordHashEnv)); err == nil { - return v - } - slog.Warn("mobile-app: locked mode without a valid " + AdminPasswordHashEnv + - "; destructive operations will return 503 until it is set") - return NewLockedUnconfiguredElevatedVerifier() +// noteRetiredAdminPasswordHash logs, once per router, that +// TOURNAMENT_ADMIN_PASSWORD_HASH is set but no longer used. It never fails +// startup: the value is simply ignored. +func noteRetiredAdminPasswordHash() { + if os.Getenv(retiredAdminPasswordHashEnv) != "" { + slog.Info("mobile-app: " + retiredAdminPasswordHashEnv + + " is set but no longer used; admin actions need only the admin password") } - return NewFileElevatedVerifier(store) } // NewRouter wires the mobile-app gin engine. The returned *gin.Engine @@ -75,15 +67,13 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res verifier = NewFileVerifier(store) } - // Elevated (destructive-ops) password verifier, spec 004 / mp-e21. - // Derived from the main verifier's mode; see defaultElevatedVerifier. - elevated := defaultElevatedVerifier(verifier, store) + noteRetiredAdminPasswordHash() // Enable CORS r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Tournament-Password, X-Admin-Password") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Tournament-Password, X-Confirm-Password") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -180,7 +170,7 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res // inert payloads when locked mode is active, see handlers_reset.go // and handlers_auth_config.go. RegisterResetHandlers(api, store, verifier, hub) - RegisterAuthConfigHandlers(api, verifier, elevated, scheduleEnabled) + RegisterAuthConfigHandlers(api, verifier, scheduleEnabled) // Admin API endpoints (protected). Split into three sub-groups by // expected body size so the body cap fires BEFORE AuthMiddleware at @@ -197,9 +187,11 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res adminSmallBody := adminGroup(r, DefaultMaxBodyBytes, verifier, store) RegisterTournamentHandlers(adminSmallBody, store, hub, verifier) - RegisterAdminPasswordHandler(adminSmallBody, store, elevated) - RegisterCompetitionHandlers(adminSmallBody, store, eng, hub, elevated) - RegisterParticipantHandlers(adminSmallBody, store, eng, hub, elevated) + // Deleting a competition asks for the password again (X-Confirm-Password); + // this middleware judges only that one route, after the sign-in. + adminSmallBody.Use(RequireConfirmPasswordForDelete(verifier)) + RegisterCompetitionHandlers(adminSmallBody, store, eng, hub) + RegisterParticipantHandlers(adminSmallBody, store, eng, hub) RegisterMatchHandlers(adminSmallBody, eng, store, store, hub, verifier, store) RegisterMatchHistoryHandler(adminSmallBody, store) RegisterDecisionHandlers(adminSmallBody, eng, store, store, hub) @@ -217,7 +209,7 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res RegisterExportResultsHandlers(adminSmallBody, store, eng) adminLargeBody := adminGroup(r, MaxImportBodyBytes, verifier, store) - RegisterImportHandlers(adminLargeBody, store, eng, hub, elevated) + RegisterImportHandlers(adminLargeBody, store, eng, hub) // Sponsor uploads (mp-c38), multipart logo upload needs envelope // headroom for the file plus boundary/form-field overhead; so it gets diff --git a/internal/state/check_in_applies_test.go b/internal/state/check_in_applies_test.go new file mode 100644 index 000000000..73b737be8 --- /dev/null +++ b/internal/state/check_in_applies_test.go @@ -0,0 +1,45 @@ +package state + +import "testing" + +// Operator ruling 2026-10-13: "Self run mode has no check-in." +func TestCompetitionCheckInApplies(t *testing.T) { + cases := []struct { + name string + t *Tournament + on bool + want bool + }{ + {"no tournament, on", nil, true, true}, + {"no tournament, off", nil, false, false}, + {"officiated, on", &Tournament{Mode: TournamentModeOfficiated}, true, true}, + {"officiated, off", &Tournament{Mode: TournamentModeOfficiated}, false, false}, + {"unset mode, on", &Tournament{}, true, true}, + {"self-run, on", &Tournament{Mode: TournamentModeSelfRun}, true, false}, + {"self-run, off", &Tournament{Mode: TournamentModeSelfRun}, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &Competition{CheckInEnabled: tc.on} + if got := c.CheckInApplies(tc.t); got != tc.want { + t.Fatalf("CheckInApplies = %v, want %v", got, tc.want) + } + if c.CheckInEnabled != tc.on { + t.Fatal("the stored setting was changed") + } + }) + } +} + +func TestTournamentIsSelfRun(t *testing.T) { + var nilT *Tournament + if nilT.IsSelfRun() { + t.Fatal("a nil tournament is not self-run") + } + if (&Tournament{Mode: TournamentModeOfficiated}).IsSelfRun() { + t.Fatal("officiated is not self-run") + } + if !(&Tournament{Mode: TournamentModeSelfRun}).IsSelfRun() { + t.Fatal("self-run is self-run") + } +} diff --git a/internal/state/models.go b/internal/state/models.go index ea259af32..c8477b615 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -31,22 +31,11 @@ type Tournament struct { // the load path (and ApplyTournamentDefaults). DurationDays int `yaml:"duration_days,omitempty" json:"durationDays,omitempty"` - // AdminPassword gates destructive operations (spec 004 / mp-e21): - // competition delete, draw/override/invalidate, and participant roster - // mutations. It is a SEPARATE, higher-privilege credential from - // Password (which gates the API as a whole). - // - // CRITICAL: it is WRITE-ONLY at the API boundary; `json:"-"` means it - // is never emitted in any response AND never populated by binding a - // request body into a Tournament. That is the opposite of Password - // (which is a peer of the credential gating /tournament, so returning - // it is harmless). AdminPassword is HIGHER privilege than that gate, so - // leaking it via GET; or letting a main-password holder overwrite it - // via the bulk PUT; would collapse the separation. It is set only via - // the dedicated, elevated-gated PUT /api/auth/admin-password handler. - // File mode only; in locked mode the env-var bcrypt hash is - // authoritative and any on-disk value here is inert. - AdminPassword string `yaml:"admin_password,omitempty" json:"-"` + // No admin_password field: the separate destructive-ops password was + // removed (operator ruling 2026-10-12, "not re enter admin password to + // change something"). A tournament.md written by an earlier release may + // still carry admin_password; front-matter decoding ignores unknown keys, + // so it loads, and the next save drops it. // Ceremony blocks expressed as human duration strings (e.g. "30m", // "1h"). When set, the auto-scheduler reserves a contiguous range @@ -73,9 +62,9 @@ type Tournament struct { // creation and IMMUTABLE thereafter (mp-7h7). "officiated" (default) // gates the full admin surface behind X-Tournament-Password. // "self-run" inverts the boundary: constructive actions (scoring, - // check-in, start/complete) are public; only destructive actions - // (those already gated by RequireElevatedPassword / enforceElevated) - // still require X-Admin-Password. omitempty means older tournament.md + // start/complete) are public and there is no check-in; organiser actions (setup, + // corrections and every destructive action) still require + // X-Tournament-Password (isSelfRunMainGatedConfigRoute). omitempty means older tournament.md // files (Mode == "") normalise to "officiated" via ApplyTournamentDefaults. Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` @@ -214,7 +203,7 @@ func ValidateTheme(theme *Theme) error { // Tournament mode constants (mp-7h7). const ( TournamentModeOfficiated = "officiated" // default: main-gate on all admin routes - TournamentModeSelfRun = "self-run" // main-gate skipped; elevated gate stays + TournamentModeSelfRun = "self-run" // main-gate skipped except on organiser routes ) // ValidateTournamentMode reports whether the given mode value is acceptable. @@ -277,6 +266,14 @@ func ApplyTournamentDefaults(t *Tournament) { // // Consumers should call ApplyTournamentDefaults before Days() to ensure // DurationDays has its correct minimum of 1. +// IsSelfRun reports whether the tournament runs in self-run mode. A nil +// tournament (none created yet) is not self-run. This is the one Go spelling +// of the question for engine code; the HTTP layer compares t.Mode against +// TournamentModeSelfRun directly where it already holds the tournament. +func (t *Tournament) IsSelfRun() bool { + return t != nil && t.Mode == TournamentModeSelfRun +} + func (t *Tournament) Days() []string { if t == nil || t.DurationDays < 1 || t.Date == "" { return nil @@ -631,6 +628,16 @@ func (c *Competition) IsTeam() bool { return c != nil && (c.Kind == "team" || c.TeamSize > 0) } +// CheckInApplies reports whether check-in decides anything for this +// competition in tournament t: its CheckInEnabled setting, except in a +// self-run tournament, which has no check-in (operator ruling 2026-10-13). +// This is the ONE owner of that rule: the draw filter, the Swiss field and the +// schedule estimate ask it, and nothing reads CheckInEnabled to decide +// behaviour. The stored setting is never rewritten. +func (c *Competition) CheckInApplies(t *Tournament) bool { + return c != nil && c.CheckInEnabled && !t.IsSelfRun() +} + // IsKachinuki reports whether c is a kachinuki (winner-stays-on) team // competition. The single spelling of this predicate (mp-gmcg review): before // this method it was reimplemented inline at 7 call sites across diff --git a/internal/state/participants.go b/internal/state/participants.go index 7d3f66350..072f28c2f 100644 --- a/internal/state/participants.go +++ b/internal/state/participants.go @@ -1349,8 +1349,7 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w // persistence path; the bulk PUT /competitions/:id roster import (the // SPA's primary flow), single add/edit, and any future caller; rejects // duplicate (normalizedName, normalizedDojo) pairs uniformly. Enforcing - // only in the handlers would leave the guard bypassable, the same reason - // the elevated-password gate is inline on the roster PUT path. + // only in the handlers would leave the guard bypassable. // Blank-dojo guard runs BEFORE the duplicate scan on purpose: with no // dojo, two same-named competitors key identically, so a blank-dojo // roster would otherwise be reported as a DUPLICATE, which names the diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 000000000..bedbe587a --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,274 @@ +# E2E journeys + +Playwright journeys that drive the tournament app (`mobile-app`) in a real +browser, the way an operator and a spectator use it. This is developer +documentation, for a contributor who runs, debugs and extends the journeys. + + make e2e # every journey + make e2e E2E_ARGS='journeys/smoke.spec.mjs' # one spec + make e2e E2E_ARGS='--grep "score on a shiaijo"' # one test by title + make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' # watch it run + make e2e/deps # install only + +`make e2e` builds `bin/bracket-creator` first, installs this directory's npm +dependencies and its Chromium on first use (stamped inside `node_modules/`, so +once per worktree), then runs `playwright test` here with `E2E_ARGS` passed +through unchanged. It is not part of `make go/test` and does not run in CI. Run +it before opening a PR that changes `web-mobile/`: the PR template asks. + +It needs Go (for the build), Node.js with npm, and a network connection on +every run: the first `make e2e/deps` (or `make e2e`) downloads the npm packages +and Chromium, and each `make e2e` rebuilds the binary, whose `vendor-frontend` +step downloads the vendored frontend libraries from unpkg every time and whose +`esbuild-jsx` step runs esbuild through `npx --yes`. "Green" means no test +failed: the `test.fixme` steps of open beads (see +[When a journey finds a bug](#when-a-journey-finds-a-bug)) are reported as +skipped. + +The Playwright version here and the one `scripts/screenshots` resolves are the +same release, so the two share one Chromium under `~/.cache/ms-playwright`. If +that cache is removed the stamp outlives it; run `npx playwright install +chromium` here. + +To debug, pass Playwright's own flags through `E2E_ARGS`: `--headed` shows the +browser, `--debug` opens the inspector, and a failing test leaves a trace +(see [Output](#output)). + +## Layout + + playwright.config.mjs projects, timeouts, output + fixtures/test.mjs the `test` every journey imports (server, baseURL, + page) and the console policy (below) + fixtures/devices.mjs the operator iPad and the spectator phone + fixtures/setup.mjs first-run tournament form, sign in, sign out + fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below) + fixtures/wizard.mjs the create-competition wizard + fixtures/competition.mjs roster paste box, Generate / Discard draw, Start + fixtures/shiaijo.mjs /admin/shiaijo/:court (queue, inline editor, which + match an editor holds) + fixtures/scores.mjs the Scores tab and its overlay editor + fixtures/scoring.mjs scoring an individual bout on either editor, and + the Finish check every editor shares + fixtures/individual.mjs individual rosters, the decision panel, playing and + correcting a bout on the court console + fixtures/team.mjs team members, the three lineup entry points (the + Lineups page, the lineup panel, the score sheet + row) and the team score sheet's bout rows + fixtures/kachinuki.mjs the kachinuki sheet: bouts, Record bout, Encho, End + match, the correction doors + fixtures/shared-court.mjs two competitions on one shiaijo and a moved + match: seeding, the competition picker, the nudges + fixtures/public.mjs the public viewer and the TV board + fixtures/clumsy.mjs the clumsy operator (below) + fixtures/pointer.mjs the coarse pointer: check, restore, tap-target size + fixtures/shots.mjs per-step screenshots, and each journey's folder + fixtures/audit.mjs audit rows (AUDIT lines and audit.json) + journeys/*.spec.mjs the journeys + +Each fixture module owns the selectors for one surface, so a UI change breaks +one file. The wizard is the one most likely to move; `fixtures/wizard.mjs` is +the only file that knows its copy. + +## Projects and devices + +Two projects, both Chromium: + +- `operator`: an iPad Air in landscape (1180x820) with touch. Its page is + checked to match `(pointer: coarse)`, so the tap-target rules apply as on + the device. If it does not, `fixtures/pointer.mjs` turns on CDP touch + emulation and the test carries a `coarse-pointer` annotation saying so. + Every journey runs here unless its file is named `*.public.spec.mjs`. +- `public`: a phone in portrait (390x844), for journeys about a public surface + alone (`*.public.spec.mjs`). No journey uses this project yet; the public + surfaces are checked from `operator` journeys through a second context. + +240 s is the default test timeout (`expect` waits 15 s); some journeys raise it +for a long test with `test.setTimeout`. `retries` is 0 and `forbidOnly` is on, so a stray `test.only` fails the run. + +An operator journey that needs a second device opens another context with one +of the two devices from `fixtures/devices.mjs`: `PUBLIC_DEVICE` for a public +surface on the side (the viewer showing a result the operator just entered), +`OPERATOR_DEVICE` for a second operator (the head table, or the console of +another court). + +## One server per spec file + +`fixtures/test.mjs` starts one `mobile-app` per spec file, on a free port and +a throwaway data folder, through `scripts/screenshots/lib/server.mjs`, and +stops it by PID. Spec files run in parallel across workers (Playwright's +default, about half the cores), each worker with its own browser and its own +server; on a slow or loaded machine pass `E2E_ARGS='--workers=1'`. A worker +runs its files one after another (all of them with `--workers=1`), and each +file starts with a fresh server, never the previous file's tournament. A +worker stops a file's server when it starts the next file's server, or when +the worker ends. Tests in one file share its server, so a spec file +that seeds once and then walks several tests declares +`test.describe.configure({ mode: 'serial' })`. A `beforeAll` hook asks for it +with `serverForFile(testInfo.file)`. Journeys navigate by path +(`page.goto('/admin')`); the host is the file's server. + +## Seeding goes through the interface + +Every journey builds its tournament the way an operator does: the first-run +form, the real sign-in form, the create wizard, the roster paste box, Generate +draw and Start. The HTTP API is not a seeding path here, and neither are +hand-written files in the data folder. This is a standing rule, and it couples +every journey to the wizard's copy: the setup steps are part of what +a journey exercises, and a shortcut would skip the part most likely to break. +`scripts/screenshots` seeds over the API for a different reason (a capture is +about one screen, not the path to it) and its `api.mjs`, `seed.mjs` and the +`authAdmin` sign-in shortcut are not used here. + +Scoring goes through the score editors too. `scripts/screenshots/lib/editor.mjs` +owns the editor selectors and Start, which `fixtures/scoring.mjs` re-exports +beside the ippon helpers. Finish is `fixtures/scoring.mjs`'s own: a journey +taps it, on the inline editor as well as the overlay, and `finishMatch` (and +`finishTeam`, through the same check) returns only once the write landed. It +fails at once when the server does not answer the completing write within the +expect timeout, answers it with an error status, or answers `applied: false` +(the refusal a disarmed Finish follows). It then waits for the editor to let +the match go, and fails if the editor shows its not-saved banner or a new +error alert. + +## The clumsy operator + +The review journeys play a distracted court operator: eyes on the shiaijo, +thumb on an iPad, often interrupted. `fixtures/clumsy.mjs` performs the +variants as real input: + +| Helper | Variant | +|---|---| +| `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | +| `doubleTap(locator)` | two taps inside the app's bounce window less 100 ms; throws when the machine was too slow to land them that close, so a caller may assert on the outcome unconditionally | +| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable); throws when "Saving…" is never seen | +| `interrupt(page, kind, { awayMs, during })` | `reload`, `back` (then forward, only if Back moved), `hidden` (tab to background and back), `offline` (network down and back); `awayMs` (600 by default) is the time away and `during(page)` runs while the operator is away | +| `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | +| `hastyShiaijoConfirm(page)` | the same for the shiaijo page's own confirms (Send back to queue, Move court) | +| `loudestButton(scope)` | the ranking both confirm helpers use, for any other panel | + +Each returns what happened, and a journey records that as an audit row. Where +the docs or an operator ruling say what must happen (a double tap on an ippon +button lands one mark, an interruption leaves the operator on the same court), +the journey also asserts it, so a regression fails the suite; the rest is +judged by a person from the screenshots. `hidden` needs a note: headless Chromium never changes page +visibility on its own, so the helper overrides `document.hidden` and +`document.visibilityState` and dispatches a real `visibilitychange` for each +edge, which is what the app's listeners read. + +## The pace of a tap + +The app ignores a pointer tap that lands within 400 ms of the layer it opened (a +confirm, the sign-in dialog) or of the tap that armed a two-tap commit (Finish, +End match), and a second ippon or foul tap on the same SIDE of a bout inside +that window, whichever button it lands on (`web-mobile/js/tap_guard.jsx`; a +tap on the other side counts at once). Playwright's second tap is always +inside it, so `fixtures/pace.mjs` holds the dwell (the screenshot harness's +`TAP_DWELL_MS`, 500 ms) and every helper that taps into a fresh layer, confirms an arm +or strikes for a side waits it out: `login`, `hastyConfirm`, +`hastyShiaijoConfirm`, `lineupsNameSlot`, `finishMatch`, `finishTeam`, +`endMatch`, `awardIppon`, `boutIppon`, `awardBoutIppon` (`tapOnSide` keeps the +time of the last tap on each side). A journey that taps such a thing by hand +does the same. `doubleTap` is the one helper that deliberately does not: it is +the bounce, and the app is meant to ignore the second tap. `pace.mjs` reads the +app's window from `web-mobile/js/tap_guard.jsx` (`TAP_BOUNCE_MS`) rather than +copying it, and fails to load if the dwell is not longer than it. + +## The console policy + +`fixtures/test.mjs` listens to every page of every browser context a test +opens: the default page, and every context a spec opens itself with +`browser.newContext` (in the test or in a `beforeAll` hook; the `browser` +fixture is wrapped so none is missed). When the test ends: + +- a console **error** or an uncaught page error FAILS the test, unless it + matches `CONSOLE_ALLOWLIST` in that file; +- a console **warning** never fails: it is added to the spec's audit as a row + with `journey: "console"` when the spec keeps one, and printed as a + `CONSOLE {json}` line otherwise, and is listed on the test as a + `console-warning` annotation. + +Everything a test's pages logged is attached to it as `console.json` (kept in a failing test's trace), with the +page it came from, whether the device was offline at the time, and, for a +failed request, the status and body of the `/api` answer. What a `beforeAll` +hook's pages log, or what the previous test's pages still had open, is checked +with the first test of the file and marked "before this test (a beforeAll hook or +the previous test's open pages)"; what an `afterAll` hook's pages log is reported under its own file when +the next file's server starts or the worker stops, and an error there fails +the run. + +The allowlist holds only noise a journey's scenario makes, in three entries: +requests the browser could not send while a journey has the device offline; +the app's own "Failed to fetch court matches" log from the court console +while the device is offline; and the 409s of a start (a `/score` write whose +body carries `status: "running"`) or a reopen the app sends through the +server's gate and shows refused (the court busy, a competitor fighting +elsewhere or barred by a withdrawal, or the knockout match it feeds being +fought). Any other write refused that way is an error. A browser "Failed to +load resource" line is paired with the `/api` answer it is about one to one, +in arrival order per URL. +Each entry matches as narrowly as it can (the message, the URL, the refusal +code, offline only) and says why in one line. An error that looks like the +app's own fault does not go on it: the test stays red until a person decides. + +## Output + +Each journey has a folder, `output//`, named by the journey passed to +`journeyShots` (it may be a subfolder, `knockout-mixed-individual/J1`). Its +per-step screenshots land there, numbered in the order they were taken, beside its `audit.json` (the +audit rows, also printed to the run log as `AUDIT {json}` lines), and are +replaced on the next run. Playwright's own traces and failure screenshots land +in `output/test-results/`. All of it is gitignored. There are no pixel +comparisons: a screenshot is for a person to look at. + +Traces are kept for a failing test only (`retain-on-failure`). Open one with + + npx playwright show-trace output/test-results//trace.zip + +from this directory. The trace's Attachments tab holds the test's +`console.json` (see [The console policy](#the-console-policy)); the list reporter +does not write it to a file of its own, and a passing test keeps no trace, so +its console errors are only in the run log as `CONSOLE {json}` lines. A failing +test also carries the server's output as `server.log`. + +Every run empties its whole results folder (`output/test-results/`, or +`E2E_RESULTS_DIR` when set) when it starts, and with `E2E_RESULTS_DIR` set the +journey folders live inside it, so pointing it at a folder that holds anything +else deletes that content. A journey empties +its own `output//` in `beforeAll`. Two runs at the same time in one +checkout would delete each other's traces mid-test, and two runs without +`E2E_RESULTS_DIR` share every `output//`, so give the second one its +own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. +With `E2E_RESULTS_DIR` set, the journey folders move inside it too +(`/journeys//`), so neither run empties the other's. +A relative `E2E_RESULTS_DIR` is relative to this directory. + +## When a journey finds a bug + +A functional defect found by a journey is filed as a bead, and that step +becomes + + test.fixme(': ', ...) + +so the suite stays green while the defect is open. The PR that fixes the bug +removes the `fixme`, and the step then guards the fix. + +## Adding a journey + +1. Name the file `journeys/.spec.mjs`. A journey about a public surface + alone is `journeys/.public.spec.mjs` and runs on the phone project. +2. Import `test` and `expect` from `../fixtures/test.mjs`, never from + `@playwright/test`: only that `test` starts the file's server and applies + the console policy and the coarse-pointer check. +3. Seed through the interface with the fixtures (`createTournament`, `login`, + `createCompetition`, `pasteRoster`, `generateDraw`, `startCompetition`). +4. Take screenshots and keep an audit: `const shot = journeyShots('')` + in `beforeAll` (it empties the folder), `const audit = auditLog('')` + at describe level with `audit.flush()` in `afterAll`, so a journey that fails + part way still leaves its rows. `record` from the audit prints each row as an + `AUDIT` line. +5. When tests share the seeded server, declare + `test.describe.configure({ mode: 'serial' })`. +6. A defect the journey finds becomes a `test.fixme(': ...')` step + (see below). + +`smoke.spec.mjs` is the smallest complete example. diff --git a/scripts/e2e/fixtures/audit.mjs b/scripts/e2e/fixtures/audit.mjs new file mode 100644 index 000000000..18e703457 --- /dev/null +++ b/scripts/e2e/fixtures/audit.mjs @@ -0,0 +1,47 @@ +// Audit rows: what a journey observed of each action x clumsy variant, the raw +// material for the review's audit table. Nothing here judges a row: judgement +// is formed by eye, from the screenshots. +// +// Every row is printed as an `AUDIT {json}` line, so the run log carries the +// table (people grep run logs for it), and kept for flush(), which writes the +// rows to /audit.json beside the journey's screenshots +// (fixtures/shots.mjs owns the folder). Call flush() in afterAll, so a journey +// that fails part way still leaves the rows it recorded. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { journeyDir } from './shots.mjs'; + +// spec file -> the audits it made, so the console policy (fixtures/test.mjs) +// can add its warning rows to the audit of the test's own file. The file is +// read off the call stack: auditLog is always called while a spec file loads. +const bySpec = new Map(); +function callingSpec() { + const m = (new Error().stack || '').match(/((?:file:\/\/)?\/[^\s():]+\.spec\.mjs)/); + if (!m) return null; + return m[1].startsWith('file://') ? fileURLToPath(m[1]) : m[1]; +} +export const auditsFor = (specFile) => bySpec.get(specFile) || []; + +export function auditLog(journey) { + const file = path.join(journeyDir(journey), 'audit.json'); + const rows = []; + const record = (row) => { + rows.push(row); + console.log(`AUDIT ${JSON.stringify(row)}`); + }; + const flush = () => { + let prior = []; + try { prior = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { prior = []; } + // A journey re-run replaces its own earlier rows (by their `journey` + // field) and keeps the others: one folder can hold several journeys' rows. + const journeys = new Set(rows.map((r) => r.journey)); + const kept = prior.filter((r) => !journeys.has(r.journey)); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify([...kept, ...rows], null, 2)); + }; + const audit = { record, flush, rows, file }; + const spec = callingSpec(); + if (spec) bySpec.set(spec, [...auditsFor(spec), audit]); + return audit; +} diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs new file mode 100644 index 000000000..495c142c3 --- /dev/null +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -0,0 +1,330 @@ +// The distracted, clumsy court operator (mp-yqxn.2's PERSONA AUDIT), as +// helpers a journey calls around its intended actions: +// +// V1 tapNeighbour the tap lands on the control next to the intended one +// V2 doubleTap two taps inside the app's bounce window +// retapWhileSaving a second tap while the button still reads "Saving…" +// V3 interrupt reload / back / tab hidden / offline, then return +// V4 hastyConfirm a confirm dialog answered by its loudest button +// hastyShiaijoConfirm the same for the shiaijo page's own confirms +// loudestButton the ranking both use, for any other panel +// +// Every helper performs a real input on a real control and RETURNS what +// happened (which element was hit, how far apart the taps were, what the +// dialog said), so the journey can record it as an audit row. None of them +// asserts the app's behaviour: whether the outcome is correct is the journey's +// call, and judgement columns are formed by eye, never asserted. +// +// Taps go through page.touchscreen at the element's centre: the browser's own +// hit-testing decides what is tapped, as it does for a thumb. Every project in +// playwright.config.mjs has a touchscreen. +import { errors } from '@playwright/test'; +import { dwell, TAP_BOUNCE_MS } from './pace.mjs'; + +// The longest gap that still counts as a double tap: the app's bounce window +// less a margin for the time between the first tap's start and the click the +// app times from. +const DOUBLE_TAP_MARGIN_MS = 100; +export const DOUBLE_TAP_LIMIT_MS = TAP_BOUNCE_MS - DOUBLE_TAP_MARGIN_MS; + +// Controls a thumb can land on. +const INTERACTIVE = 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, [role="checkbox"], [role="tab"], [role="switch"]'; + +// How far a mis-tap reaches: a neighbour further than this is not "the one +// next to it" but somewhere else on the screen. About one fingertip. +const REACH_PX = 96; + +async function centreOf(locator) { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + if (!box) throw new Error(`clumsy: ${locator} has no box (not rendered)`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2, box }; +} + +// V1. Tap the control ADJACENT to `locator` in `direction` ('left' | 'right' +// | 'up' | 'down') instead of it. The neighbour is found by layout, among +// every visible control on the page (not only DOM siblings: the other side's +// ippon button is in another subtree), as the nearest one that sits in that +// direction and overlaps the target's row (left/right) or column (up/down). +// It must be the topmost element at its own centre, so a control covered by +// an overlay is never picked. Throws when nothing is within reach: the +// journey asked for a neighbour that does not exist. +// +// Returns { intended, hit, gapPx }. +export async function tapNeighbour(locator, direction) { + const page = locator.page(); + await locator.scrollIntoViewIfNeeded(); + const found = await locator.evaluate((src, { direction, sel, reach }) => { + // Defined in here because it runs in the page. A short description of + // an element, for the audit row. + const describe = (el) => { + const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().replace(/\s+/g, ' '); + return { tag: el.tagName.toLowerCase(), label: text.slice(0, 80), testid: el.getAttribute('data-testid') || null, + className: typeof el.className === 'string' ? el.className : null, + disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true' }; + }; + const a = src.getBoundingClientRect(); + const horizontal = direction === 'left' || direction === 'right'; + let best = null; + for (const el of document.querySelectorAll(sel)) { + if (el === src || el.contains(src) || src.contains(el)) continue; + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) continue; + if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth) continue; + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const top = document.elementFromPoint(cx, cy); + if (!top || !(top === el || el.contains(top))) continue; + // Overlap on the perpendicular axis, and strictly beyond the target on + // the requested one. + const overlaps = horizontal ? (r.top < a.bottom && r.bottom > a.top) : (r.left < a.right && r.right > a.left); + if (!overlaps) continue; + const gap = { right: r.left - a.right, left: a.left - r.right, down: r.top - a.bottom, up: a.top - r.bottom }[direction]; + if (gap < -1 || gap > reach) continue; + const drift = horizontal ? Math.abs(cy - (a.top + a.height / 2)) : Math.abs(cx - (a.left + a.width / 2)); + if (!best || gap < best.gap || (gap === best.gap && drift < best.drift)) best = { el, gap, drift, cx, cy }; + } + if (!best) return null; + return { intended: describe(src), hit: describe(best.el), gapPx: Math.round(best.gap), x: best.cx, y: best.cy }; + }, { direction, sel: INTERACTIVE, reach: REACH_PX }); + if (!found) throw new Error(`clumsy.tapNeighbour: no control within ${REACH_PX}px ${direction} of ${locator}`); + await page.touchscreen.tap(found.x, found.y); + const { x, y, ...row } = found; + return row; +} + +// V2. Two taps on the same spot inside the app's bounce window +// (DOUBLE_TAP_LIMIT_MS, derived from tap_guard.jsx's TAP_BOUNCE_MS), the +// impatient thumb. Returns { gapMs, withinWindow }, gapMs being the time from +// the start of the first tap to the start of the second, and withinWindow +// always true: a machine too slow to land the taps inside the window THROWS, +// because the row would not have tested what it says. The first tap's effects +// cannot be taken back, so the taps are not retried; the caller's test fails +// with the message above (playwright.config.mjs sets retries to 0). Callers may assert on the outcome unconditionally. +export async function doubleTap(locator) { + const page = locator.page(); + const { x, y } = await centreOf(locator); + const t0 = Date.now(); + await page.touchscreen.tap(x, y); + const gapMs = Date.now() - t0; + await page.touchscreen.tap(x, y); + if (gapMs >= DOUBLE_TAP_LIMIT_MS) { + throw new Error(`clumsy.doubleTap: the two taps were ${gapMs} ms apart (limit ${DOUBLE_TAP_LIMIT_MS} ms, the app's bounce window is ${TAP_BOUNCE_MS} ms): this machine was too slow, so the row did not test the bounce guard`); + } + return { gapMs, withinWindow: true }; +} + +// V2 (second form). Tap `locator`, then tap the same spot again while it reads +// "Saving…". A local server answers in milliseconds, far too fast for any +// thumb, so the write is held for `holdWritesMs` first (every non-GET /api +// request), which is what a venue's wifi does anyway. Returns +// { savingSeen, retapped, labelAfter }, both flags always true: a button that +// never shows "Saving…" within the hold THROWS (the second tap was not made, so +// a check built on this helper would pass without testing anything). Any other +// failure propagates as it is. +export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingText = /Saving…/ } = {}) { + const page = locator.page(); + const { x, y } = await centreOf(locator); + const handle = await locator.elementHandle(); + const hold = async (route) => { + if (route.request().method() === 'GET') return route.continue(); + await new Promise((r) => setTimeout(r, holdWritesMs)); + // While the write was held the page may have navigated or closed (a + // Finish + Start Next re-renders, a journey moves on), which cancels the + // request: continuing a route whose request is gone throws, and there is + // nothing left to let through. + return route.continue().catch(() => {}); + }; + await page.route('**/api/**', hold); + try { + await page.touchscreen.tap(x, y); + try { + await page.waitForFunction( + ([el, src, flags]) => new RegExp(src, flags).test(el.textContent || ''), + [handle, savingText.source, savingText.flags], + { timeout: holdWritesMs, polling: 'raf' }, + ); + } catch (err) { + if (err instanceof errors.TimeoutError) { + throw new Error(`clumsy.retapWhileSaving: ${locator} never read ${savingText} within ${holdWritesMs} ms of the tap (writes were held for that long), so there was no second tap to make`); + } + throw err; + } + const savingSeen = true; + await page.touchscreen.tap(x, y); + // Let the held write go through before the route is removed. + await page.waitForFunction( + ([el, src, flags]) => !el.isConnected || !new RegExp(src, flags).test(el.textContent || ''), + [handle, savingText.source, savingText.flags], + { timeout: holdWritesMs + 15000 }, + ); + const labelAfter = await handle.evaluate((el) => (el.isConnected ? el.textContent.trim() : null)); + return { savingSeen, retapped: savingSeen, labelAfter }; + } finally { + await page.unroute('**/api/**', hold); + await handle.dispose(); + } +} + +// V3. Interrupt the operator mid-action, then bring them back. +// +// reload the page reloads (the iPad restored a discarded tab) +// back the browser Back button, then Forward to return +// hidden the tab goes to the background and comes back (a tab switch, +// the iPad sleeping). Headless Chromium never changes visibility +// by itself (measured: neither bringToFront on another page nor +// CDP Page.setWebLifecycleState moves document.visibilityState), +// so document.hidden and document.visibilityState are overridden +// on the page and a real `visibilitychange` event is dispatched +// for each edge. The app's own listeners (admin.jsx, app.jsx) +// read document.hidden, so they run exactly as on a device; their +// resume path reconnects the event stream, and `resumed` reports +// whether that reconnect request was seen. +// offline the network drops (context.setOffline), then returns +// +// `during(page)` runs while the operator is away (offline: the taps made +// without a network). Returns a record of what was observed. +export async function interrupt(page, kind, { awayMs = 600, during } = {}) { + const before = page.url(); + const away = async () => { + if (during) await during(page); + // The time away IS the scenario (the operator looked at the shiaijo), not + // a wait for the app. + await page.waitForTimeout(awayMs); + }; + switch (kind) { + case 'reload': { + await page.reload(); + return { kind, before, after: page.url() }; + } + case 'back': { + // Whether Back went anywhere is read from the URL, not from goBack()'s + // response: the SPA routes with history.pushState (router.jsx), and a + // same-document step answers null although it did go back. + await page.goBack(); + const landedOn = page.url(); + const wentBack = landedOn !== before; + await away(); + if (wentBack) await page.goForward(); + return { kind, before, landedOn, hadHistory: wentBack, after: page.url() }; + } + case 'hidden': { + const setHidden = (hidden) => page.evaluate((h) => { + if (h) { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + } else { + delete document.hidden; + delete document.visibilityState; + } + document.dispatchEvent(new Event('visibilitychange')); + }, hidden); + await setHidden(true); + await away(); + const reconnect = page.waitForRequest((r) => r.url().includes('/api/events'), { timeout: 5000 }) + .then(() => true, () => false); + await setHidden(false); + return { kind, before, after: page.url(), resumed: await reconnect, method: 'document.hidden override + dispatched visibilitychange' }; + } + case 'offline': { + const context = page.context(); + await context.setOffline(true); + try { + await away(); + } finally { + await context.setOffline(false); + } + return { kind, before, after: page.url() }; + } + default: + throw new Error(`clumsy.interrupt: unknown kind "${kind}"`); + } +} + +// V4's ranking: the most prominent visible button inside `scope` (a Locator), +// the one an operator who does not read taps. Prominence is read from the +// rendered classes: danger over primary over a plain button over a ghost one, +// the larger button breaking a tie. Returns { button, label, prominence, +// otherLabels }, the last being the other visible buttons' labels. Throws when +// `scope` shows no button. +const PROMINENCE = ['ghost', 'plain', 'primary', 'danger']; +const prominenceRank = (cls) => (/\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1); + +export async function loudestButton(scope) { + const buttons = scope.locator('button'); + let best = null; + const labels = []; + for (let i = 0; i < await buttons.count(); i += 1) { + const b = buttons.nth(i); + if (!(await b.isVisible())) continue; + const cls = (await b.getAttribute('class')) || ''; + const box = await b.boundingBox(); + const label = (await b.innerText()).trim(); + labels.push(label); + const score = [prominenceRank(cls), box ? box.width * box.height : 0]; + if (!best || score[0] > best.score[0] || (score[0] === best.score[0] && score[1] > best.score[1])) { + best = { b, label, score }; + } + } + if (!best) throw new Error(`clumsy.loudestButton: no visible button in ${scope}`); + return { + button: best.b, + label: best.label, + prominence: PROMINENCE[best.score[0]], + otherLabels: labels.filter((l) => l !== best.label), + }; +} + +// Wait until the dialog `handle` was pinned to has gone (hidden or detached), +// then let the handle go. A locator would re-resolve, so a second dialog the +// answer opens would be waited on instead of the one that was answered. +async function waitAnswered(handle) { + try { + await handle.waitForElementState('hidden', { timeout: 15_000 }); + } finally { + await handle.dispose(); + } +} + +// V4. Answer the open confirm dialog (confirmDialog in ui.jsx) the way an +// operator who does not read does: tap its loudest button (loudestButton). +// Returns { label, prominence, otherLabels, title, message }, so the audit row +// can say whether the loud button was the safe one. +export async function hastyConfirm(page) { + const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dialog.waitFor({ state: 'visible' }); + const shown = Date.now(); + const handle = await dialog.elementHandle(); + const loud = await loudestButton(dialog.locator('.modal__foot')); + const title = (await dialog.getAttribute('aria-label')) || ''; + const message = ((await dialog.locator('.dialog-msg').allInnerTexts())[0] || '').trim(); + const { x, y } = await centreOf(loud.button); + // A confirm ignores every tap inside the bounce window of its opening, so + // "hasty" here is a hurried operator, never a tap before the guard lets one in. + await dwell(page, shown); + await page.touchscreen.tap(x, y); + await waitAnswered(handle); + const { button, ...row } = loud; + return { ...row, title, message }; +} + +// V4 for the shiaijo page's own confirms (`.shiaijo-move-confirm` in +// admin_shiaijo.jsx: Send back to queue, Move to Shiaijo X). They are not +// confirmDialog, so hastyConfirm cannot see them; the same rule answers them. +// Returns the same fields as hastyConfirm. +export async function hastyShiaijoConfirm(page) { + const dialog = page.locator('.shiaijo-move-confirm[role="dialog"]').last(); + await dialog.waitFor({ state: 'visible' }); + const shown = Date.now(); + const handle = await dialog.elementHandle(); + const loud = await loudestButton(dialog.locator('.shiaijo-move-confirm__actions')); + const title = ((await dialog.locator('.shiaijo-move-confirm__title').allInnerTexts())[0] || '').trim(); + const message = ((await dialog.locator('.shiaijo-move-confirm__body').allInnerTexts())[0] || '').trim(); + // The confirm ignores a tap inside the bounce window of its opening (fixtures/pace.mjs). + await dwell(page, shown); + await loud.button.tap(); + await waitAnswered(handle); + const { button, ...row } = loud; + return { ...row, title, message }; +} + diff --git a/scripts/e2e/fixtures/competition.mjs b/scripts/e2e/fixtures/competition.mjs new file mode 100644 index 000000000..9894db26a --- /dev/null +++ b/scripts/e2e/fixtures/competition.mjs @@ -0,0 +1,49 @@ +// A competition's own admin pages: the roster paste box (Participants & +// seeds), Generate draw, Discard draw and Start competition. +import { expect } from '@playwright/test'; + +const compPath = (id, section = '') => `/admin/competition/${id}${section ? `/${section}` : ''}`; + +// Paste a roster into the Participants page's LinedTextarea and apply it. +// `rows` is a list of [name, dojo] pairs or of ready-made lines. Every row +// needs a dojo, and (name, dojo) must be unique: the roster save refuses +// either, and that refusal is not what this helper is for. +export async function pasteRoster(page, id, rows) { + const lines = rows.map((r) => (Array.isArray(r) ? r.join(', ') : r)); + if (!page.url().endsWith(compPath(id, 'participants'))) await page.goto(compPath(id, 'participants')); + const box = page.locator('.lined-textarea__area'); + await box.fill(lines.join('\n')); + // The page renders TWO identical "Apply changes" buttons, one above the + // box and one under the preview table; either applies. + await page.getByRole('button', { name: 'Apply changes' }).first().tap(); + // Applying the first roster moves the page on to the competition overview; + // the header's count is what says the roster landed. + await expect(page.getByText(new RegExp(`\\b${lines.length} (players|teams)\\b`)).first()).toBeVisible(); + await expect(page.getByText('Not applied yet')).toHaveCount(0); +} + +// Generate the draw from the competition header, and return once the +// competition reads "Draw ready" (the header then offers Discard draw). +export async function generateDraw(page, id) { + const btn = page.getByRole('button', { name: 'Generate draw' }); + if (!(await btn.isVisible())) await page.goto(compPath(id)); + await btn.tap(); + await expect(page.getByRole('button', { name: 'Discard draw' })).toBeVisible(); +} + +// Tap Discard draw. It asks through the app's confirm dialog, which is left +// open for the caller: answering it is the journey's decision (or the clumsy +// operator's, see hastyConfirm in clumsy.mjs). +export async function requestDiscardDraw(page) { + await page.getByRole('button', { name: 'Discard draw' }).tap(); + await expect(page.locator('.modal[role="dialog"] .modal__foot')).toBeVisible(); +} + +// Start the competition. There is no confirm: the tap starts it and lands on +// the Scores tab. +export async function startCompetition(page, id) { + const btn = page.getByRole('button', { name: /^Start competition/ }); + if (!(await btn.isVisible())) await page.goto(compPath(id)); + await btn.tap(); + await expect(page).toHaveURL(new RegExp(`${compPath(id, 'scores')}$`)); +} diff --git a/scripts/e2e/fixtures/devices.mjs b/scripts/e2e/fixtures/devices.mjs new file mode 100644 index 000000000..630097b5c --- /dev/null +++ b/scripts/e2e/fixtures/devices.mjs @@ -0,0 +1,29 @@ +// The two devices every journey runs on. playwright.config.mjs builds its +// projects from these, and a journey that opens a SECOND context (the public +// viewer beside the operator's iPad) spreads the same constant into +// browser.newContext(), so a project and a hand-made context cannot drift. + +// How long an expect() waits (playwright.config.mjs's expect.timeout), and so +// how long the console policy waits for a refused answer's body +// (fixtures/test.mjs): one constant, so the two cannot drift. +export const EXPECT_TIMEOUT_MS = 15_000; + +// iPad Air, landscape: the court table's device. hasTouch + isMobile is what +// makes `(pointer: coarse)` match, measured, so the 44px tap-floor rules in +// styles.css apply as they do on the device. fixtures/test.mjs re-checks it +// for every operator page and falls back to CDP touch emulation if a future +// Chromium stops honouring these two. +export const OPERATOR_DEVICE = { + viewport: { width: 1180, height: 820 }, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 1, +}; + +// A phone, portrait: a spectator or a competitor following the draw. +export const PUBLIC_DEVICE = { + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 1, +}; diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs new file mode 100644 index 000000000..fe2b785f5 --- /dev/null +++ b/scripts/e2e/fixtures/individual.mjs @@ -0,0 +1,197 @@ +// Helpers for the INDIVIDUAL operator journeys (knockout-mixed-individual +// spec): the rosters, a deterministic winner rule, and the few surfaces the +// shared fixtures do not own (the decision panel, playing and correcting a +// bout on the shiaijo page). Which match an editor holds is read by +// fixtures/shiaijo.mjs editorIdentity. The audit log (fixtures/audit.mjs), +// tap-target measurement (fixtures/pointer.mjs) and the hasty confirms +// (fixtures/clumsy.mjs) are shared with the other journeys. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR, awardIppon, finishMatch } from './scoring.mjs'; +import { + UP_NEXT, WAITING_ROWS, completedRowFor, courtDone, courtIdle, inlineEditor, upNextCard, +} from './shiaijo.mjs'; +import { hastyShiaijoConfirm } from './clumsy.mjs'; + +// Twelve entrants for F1 (pools of 3, two through): six dojos, two members +// each, (name, dojo) unique. Names start with distinct letters so the +// "first name alphabetically wins" rule below gives every pool a strict +// order and never a tie-break bout. +export const ROSTER_12 = [ + ['Aoi Sato', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Eri Kato', 'Mumeishi'], ['Finn Hale', 'Hizen'], + ['Goro Abe', 'Gyokusen'], ['Hana Ito', 'Thames'], ['Ivo Nunes', 'Musashi'], + ['Jun Oda', 'Seishinkan'], ['Kai Ueda', 'Mumeishi'], ['Lea Roth', 'Hizen'], +]; +// Six for F1b (two pools of 3): the decision and Pools-tab journeys. +export const ROSTER_6 = [ + ['Mika Endo', 'Gyokusen'], ['Nils Berg', 'Thames'], ['Oto Kudo', 'Musashi'], + ['Pia Lund', 'Seishinkan'], ['Rin Saito', 'Mumeishi'], ['Sam Doyle', 'Hizen'], +]; +// Six more for F1c (two pools of 3): scored from the competition's Pools tab. +export const ROSTER_6B = [ + ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], + ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], +]; +// Small rosters for the ': ...' tests, which seed their own +// competitions so each runs alone. +export const rosterOf = (prefix, n) => Array.from({ length: n }, (_, i) => [ + `${prefix} ${['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay'][i]}`, ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen'][i], +]); +// Four for each knockout-only fixture: two semi-finals, then the final (and +// the bronze match when joint 3rd places are off). +export const ROSTER_4_BRONZE = [ + ['Taro Ono', 'Gyokusen'], ['Uma Patel', 'Thames'], ['Vic Lange', 'Musashi'], ['Wen Zhao', 'Seishinkan'], +]; +export const ROSTER_4_JOINT = [ + ['Xan Moss', 'Gyokusen'], ['Yui Hara', 'Thames'], ['Zoe Quinn', 'Musashi'], ['Ada Pike', 'Seishinkan'], +]; + +// The deterministic winner of a pairing: the name that sorts first. Transitive, +// so a pool's standings come out strict and no tie-break bout is added. +export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); + +// The two names an editor shows, Shiro first. +export async function editorSides(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); + const shiro = (await ed.locator('.sb-side--shiro .numbered-name__text').first().innerText()).trim(); + const aka = (await ed.locator('.sb-side--aka .numbered-name__text').first().innerText()).trim(); + return { shiro, aka }; +} + +// The slot marks a side holds, in slot order ("M", "Ht", ...). A slot reads +// " slot : remove M" (a point), ": Ht" (the judges' decision), +// ": M, kiken" (a point a decision has locked) or ": empty" +// (fixtures/scoring.mjs filledSlots). +export async function slotMarks(page, side, root = INLINE_EDITOR) { + const name = side === 'shiro' ? 'Shiro' : 'Aka'; + const labels = await page.locator(root).first().locator(`[aria-label^="${name} slot "]`).evaluateAll( + (els) => els.map((e) => e.getAttribute('aria-label')), + ); + return labels.map((l) => (l.match(/: (?:remove )?([^,\s]+)(?:, .+)?$/) || [])[1]).filter((v) => v && v !== 'empty'); +} + +// The decision panel (DecisionPrompt, admin_scoring_shared.jsx) is inline in +// the editor, not a dialog. +export const decisionButton = (page, kind, root = INLINE_EDITOR) => page.locator(root).first() + .getByTestId({ 'kiken-voluntary': 'scoring-modal-kiken-voluntary-button', 'kiken-injury': 'scoring-modal-kiken-injury-button', fusenpai: 'scoring-modal-fusenpai-button' }[kind]); +export const decisionPrompt = (page, root = INLINE_EDITOR) => page.locator(root).first().locator('form.decision-prompt'); + +// Open a decision, pick the side, optionally type a reason, and Record. The +// prompt picks no side for the operator and Record stays off until one is +// picked (bc-dsid), so `side` is required. Returns what the prompt showed: the +// side preselected (none), whether Record was off before the pick, and the +// label it read once the side was picked ("Record: AKA withdrew"). +export async function recordDecision(page, kind, { side, reason, root = INLINE_EDITOR } = {}) { + if (!side) throw new Error('recordDecision: pick a side; the prompt preselects none (bc-dsid)'); + await decisionButton(page, kind, root).tap(); + const form = decisionPrompt(page, root); + await expect(form).toBeVisible(); + const checked = form.locator('input[name="decision-side"]:checked'); + const preselected = (await checked.count()) ? await checked.getAttribute('value') : null; + const record = form.locator('button[type="submit"]'); + const recordDisabledBeforePick = await record.isDisabled(); + // The radio covers its pill, so the tap lands where the thumb does. + const pick = form.locator(`input[name="decision-side"][value="${side}"]`); + await pick.tap(); + await expect(pick).toBeChecked(); + if (reason) await form.getByTestId('decision-reason').fill(reason); + const recordLabel = (await record.innerText()).trim(); + await record.tap(); + return { preselected, recordDisabledBeforePick, recordLabel, chosen: side }; +} + +// Is the inline editor holding a RUNNING bout (no PRE-MATCH / CORRECTION pill)? +export async function inlineRunning(page) { + const ed = inlineEditor(page).first(); + if (!(await ed.isVisible().catch(() => false))) return false; + const pill = ed.locator('.editor-head-pill'); + return (await pill.count()) === 0; +} + +// Make sure the shiaijo page has a running bout in its editor, and return its +// pairing. When the court is idle ("Ready when you are") the Up next card's +// Start match is tapped, once. Returns null only when the court has nothing to +// start: idle with no startable Up next card (none, or one whose Start is off, +// e.g. a barred match), or done, held for a moment so a court between two +// renders is not taken for one with nothing left. Throws when Start was tapped +// and no bout runs by the deadline, or the console never settles. +const NOTHING_HELD_MS = 1_500; +export async function ensureRunning(page, { timeout = 15_000 } = {}) { + const start = upNextCard(page).getByRole('button', { name: 'Start match' }); + let tappedAt = null; + let nothingSince = null; + let outcome = null; + await expect(async () => { + if (await inlineRunning(page)) { outcome = 'running'; return; } + const idle = await courtIdle(page).isVisible().catch(() => false); + const done = !idle && await courtDone(page).isVisible().catch(() => false); + const startable = idle && (await start.count()) > 0 && await start.isEnabled({ timeout: 1_000 }).catch(() => false); + if (tappedAt === null && startable) { + // The card can leave under the tap (Finish + Start Next has just started + // the match it shows): a tap that finds it gone did not happen, and the + // next look reads the editor. + if (await start.tap({ timeout: 2_000 }).then(() => true, () => false)) tappedAt = Date.now(); + throw new Error('Start match tapped on the Up next card; waiting for the bout to run'); + } + if (tappedAt !== null) throw new Error(`Start match was tapped ${Date.now() - tappedAt} ms ago and no bout is running`); + if (done || idle) { + nothingSince ??= Date.now(); + if (Date.now() - nothingSince >= NOTHING_HELD_MS) { outcome = 'nothing'; return; } + throw new Error('the court shows nothing to start; checking it stays so'); + } + nothingSince = null; + throw new Error('the court console shows neither a running bout, the idle placeholder nor the done state'); + }).toPass({ timeout, intervals: [250] }); + return outcome === 'running' ? editorSides(page) : null; +} + +// Score the running bout on the shiaijo page for the deterministic winner (one +// men) and Finish (+ Start Next) through the two-tap guard, which returns once +// the write has landed and the editor has let the bout go. Returns the +// pairing. +export async function playRunningBout(page, { waza = 'M' } = {}) { + const pair = await ensureRunning(page); + if (!pair) throw new Error('playRunningBout: nothing running or startable on this court'); + const side = winnerOf(pair); + await awardIppon(page, side, waza); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + return { ...pair, winner: side }; +} + +// Open the correction of a completed bout on the shiaijo page, the way the +// operator now must. The console never holds a live bout and an open +// correction at once (bc-crpn): while a bout runs, Correct is refused with a +// notice at the row and a toast, and the notice says what to do: send the live +// bout back to the queue (its score is kept), then correct. Returns the bout +// that was live (null when the court was idle) and what the refusal said. +export async function correctCompleted(page, pair) { + const done = completedRowFor(page, pair); + const live = (await inlineRunning(page)) ? await editorSides(page) : null; + await done.getByRole('button', { name: 'Correct' }).tap(); + let refusal = null; + if (live) { + const notice = done.locator('.shiaijo-upnext__error[role="alert"]'); + await expect(notice).toBeVisible(); + const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); + refusal = { + notice: (await notice.innerText()).trim(), toast, + editorStillOnLiveBout: (await inlineRunning(page)) && JSON.stringify(await editorSides(page)) === JSON.stringify(live), + }; + expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); + await page.getByRole('button', { name: 'Send back to queue' }).tap(); + refusal.sendBack = await hastyShiaijoConfirm(page); + await expect.poll(() => inlineRunning(page), { timeout: 10_000 }).toBe(false); + await done.getByRole('button', { name: 'Correct' }).tap(); + } + await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); + return { live, refusal }; +} + +// How many POOL bouts still wait in this court's queue (the Up next card and +// the Upcoming rows; not Later, not Completed). A pool bout reads +// "Match n of m" in its header, a knockout bout "Match n". +export async function queuedPoolBouts(page) { + const texts = await page.locator(`${UP_NEXT}, ${WAITING_ROWS}`).allInnerTexts(); + return texts.filter((t) => /Match \d+ of \d+/.test(t)).length; +} diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs new file mode 100644 index 000000000..ccc596841 --- /dev/null +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -0,0 +1,167 @@ +// The kachinuki (winner stays on) score sheet as the court operator drives it +// on /admin/shiaijo/:court: the per-match lineup panel, the current bout's +// ippon buttons, Tie, Encho, Record bout, End match, the fought bouts above +// the current one, and the correction doors (tap a fought bout, Reopen match, +// the court-busy requeue panel, Remove this bout). +// +// Selectors follow admin_scoring_team.jsx and admin_schedule_lineup.jsx. The +// individual board's helpers (fixtures/scoring.mjs) do not fit here: a team +// bout's sides are `.team-sub-match__side--`, not `.sb-side--`. +// The bout row's controls and the lineup panel are the team sheet's +// (fixtures/team.mjs owns their selectors); this file adds what is kachinuki's +// own and names those for the current bout. Every tap goes through +// Locator.tap(), so the coarse-pointer hit test decides what is hit, as it +// does for a thumb. +import { expect } from '@playwright/test'; +import { dwell, settled } from './pace.mjs'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { inlineEditor } from './shiaijo.mjs'; +import * as T from './team.mjs'; + +// Does the page still match (pointer: coarse)? If an interruption or a +// fullPage screenshot lost it, every tap-floor rule in styles.css would be off +// for the rest of the test, so put it back in place (fixtures/pointer.mjs, no +// reload, so the bout is not interrupted a second time) and report whether it +// had been lost, so the audit can say which pointer a measurement was made at. +export async function ensureCoarse(page) { + if (await isCoarse(page)) return { coarseLost: false }; + await restoreCoarse(page); + return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; +} + +export const editor = (page) => inlineEditor(page).filter({ has: page.locator('.team-bouts-scroll') }).first(); + +// ---------------------------------------------------------------- lineup -- + +// The per-match lineup panel (fixtures/team.mjs): opened from the Up next +// card, a side found by the team name it shows, where its lineup comes from, +// and Close. +export const openLineupFromUpNext = T.openPanelFromUpNext; +export const lineupSide = T.panelSideOf; +export const lineupSource = T.sideSource; +export const closeLineupPanel = T.closePanel; + +// Name every position of ONE team's lineup by typing (a typed name over a +// blank numbered slot names that slot's member, bc-dnst), then Save lineup. +// `teamName` picks the side editor by its heading text. Returns once the +// save has landed (the side reads its lineup as this match's own). `save: +// false` types the names and leaves the side unsaved. +export async function typeLineup(page, teamName, names, { save: doSave = true } = {}) { + const side = lineupSide(page, teamName); + await expect(side).toBeVisible(); + const inputs = side.locator('input.pmf__input'); + await expect(inputs).toHaveCount(names.length); + for (let i = 0; i < names.length; i += 1) { + const box = inputs.nth(i); + await box.tap(); + await box.fill(names[i]); + await box.press('Enter'); + await expect(box).toHaveValue(names[i]); + } + if (!doSave) return side; + const save = side.getByRole('button', { name: /^(Save lineup|Saving…)$/ }); + await save.tap(); + // Saved: the side shows the lineup as its own match's, and Save lineup waits + // for the next change. + await expect(lineupSource(side)).toHaveText('Lineup for this match'); + return side; +} + +// ----------------------------------------------------------------- bouts -- + +// Fought bouts, collapsed to read-only rows above the current one. +export const doneRows = (page) => editor(page).locator('.team-sub-match--readonly'); +// The live bout: the one editable row that is not a fought bout reopened for +// correction. +export const currentBout = (page) => editor(page) + .locator('.team-sub-match:not(.team-sub-match--readonly):not(.team-sub-match--correcting)').first(); +// A fought bout opened for correction. +export const correctingBout = (page) => editor(page).locator('.team-sub-match--correcting').first(); + +// A bout row's controls, the team sheet's (fixtures/team.mjs): an ippon +// button (`side` is 'shiro', left, the match's sideB, or 'aka', right, +// sideA), the filled centre slots of one side, and Tie. +export const boutIpponButton = T.rowIppon; +export const boutFilled = T.rowSlots; +export const tieButton = T.rowTie; +export const recordBoutButton = (page) => editor(page).getByRole('button', { name: /^(Record bout|Saving…)$/ }).first(); +export const endMatchButton = (page) => editor(page).getByTestId('kachinuki-end-match-button'); +export const enchoButton = (page) => editor(page).getByTestId('kachinuki-encho-button'); +// Takes one overtime period back; offered beside Encho once the bout is in overtime. +export const enchoUndoButton = (page) => editor(page).getByTestId('kachinuki-encho-undo-button'); +export const removeBoutButton = (page) => editor(page).getByTestId('kachinuki-remove-bout-button'); +export const reopenButton = (page) => page.getByTestId('kachinuki-reopen-button'); +export const syncPill = (page) => T.syncPill(editor(page)); + +// The two fighters on a bout row. The live row carries a name box per side; +// a fought row carries static text. The box shows the name as its value +// while closed, but while its list is open the value is the typed QUERY and +// the name moves to the placeholder (LineupNameInput in +// admin_scoring_shared.jsx: `value={open ? query : value}`, `placeholder= +// {value || "Add player…"}`), so an empty value falls back to the +// placeholder. +export async function boutNames(row) { + const read = async (side) => { + const cell = row.locator(`.team-sub-match__side--${side} .tsm-name`).first(); + const input = cell.locator('input.pmf__input'); + if (await input.count()) { + const v = (await input.inputValue()).trim(); + if (v) return v; + const ph = ((await input.getAttribute('placeholder')) || '').trim(); + return ph === 'Add player…' ? '' : ph; + } + return (await cell.locator('.tsm-name__static').first().innerText()).trim(); + }; + return { shiro: await read('shiro'), aka: await read('aka') }; +} + +// Who a fought row marks as the winner ('shiro' | 'aka' | ''), read from the +// winner class on the name. +export async function doneRowWinner(row) { + if (await row.locator('.team-sub-match__side--shiro .tsm-name__static--win').count()) return 'shiro'; + if (await row.locator('.team-sub-match__side--aka .tsm-name__static--win').count()) return 'aka'; + return ''; +} + +// The centre mark of a row (vs / X / (E)). +export const boutMiddle = async (row) => (await T.rowMiddle(row).first().innerText()).trim(); + +// Award one ippon on the current bout and wait for the board to show it. A second +// ippon on the same side only counts once the first one's bounce window has +// passed (fixtures/pace.mjs), so the helper returns after it. +export async function awardBoutIppon(page, side, waza = 'M') { + const row = currentBout(page); + const before = await boutFilled(row, side).count(); + const tappedAt = Date.now(); + await boutIpponButton(row, side, waza).tap(); + await expect(boutFilled(currentBout(page), side)).toHaveCount(before + 1); + await dwell(page, tappedAt); +} + +// Record bout: one tap. Returns once the bout has become a read-only row (the +// server appended the next pairing, or the tie retired both). +export async function recordBout(page) { + const before = await doneRows(page).count(); + await recordBoutButton(page).tap(); + await expect(doneRows(page)).toHaveCount(before + 1, { timeout: 15_000 }); + await expect(recordBoutButton(page)).not.toHaveText('Saving…'); +} + +// Win the live bout 2-0 (two men) for `side`, then Record bout. +export async function winBout(page, side) { + await awardBoutIppon(page, side, 'M'); + await awardBoutIppon(page, side, 'K'); + await recordBout(page); +} + +// End match: the two-tap guard. Returns the armed label the first tap showed. +export async function endMatch(page) { + const btn = endMatchButton(page); + await btn.tap(); + await expect(btn).toHaveText(/^Tap again/); + const armed = (await btn.innerText()).trim(); + // The second tap counts once the arming tap's bounce window has passed. + await settled(btn); + await btn.tap(); + return armed; +} diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs new file mode 100644 index 000000000..d64241dab --- /dev/null +++ b/scripts/e2e/fixtures/pace.mjs @@ -0,0 +1,68 @@ +// The pace of a deliberate tap. +// +// The app ignores a pointer tap that lands within TAP_BOUNCE_MS (400 ms, +// web-mobile/js/tap_guard.jsx) of one of three things: the layer it just +// opened (a confirm, the sign-in dialog, an edit dialog), the tap that armed a +// two-tap commit (Finish, End match, Save result), and the previous ippon or +// foul tap on the same side of the same bout (acceptTap: the guard is per +// side, so a tap on the other side counts at once). That is how a bouncing +// thumb is told from a second tap, and no person's second tap is ever that +// close. Playwright's is, so a journey that taps into a layer the moment it +// shows, taps an armed button the moment it arms, or strikes twice for one +// side back to back, has the tap swallowed and then fails somewhere later with +// nothing pointing back here. +// +// The dwell below is therefore the operator's own pace, not a wait for the app: +// nothing in the page says the window has passed, so it is timed from the +// moment the journey saw the layer or the arm, or made the tap. TAP_DWELL_MS +// is the screenshot harness's value, kept above the app's window, so both +// harnesses pace alike. +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; + +// The app's own window, read from its one source (tap_guard.jsx is JSX, so it +// cannot be imported here). Failing to find it is an error: a harness that +// guessed would drift from the app unnoticed. +export const TAP_BOUNCE_MS = (() => { + const file = fileURLToPath(new URL('../../../web-mobile/js/tap_guard.jsx', import.meta.url)); + const m = /export const TAP_BOUNCE_MS = (\d+);/.exec(fs.readFileSync(file, 'utf8')); + if (!m) throw new Error(`pace: no TAP_BOUNCE_MS found in ${file}`); + return Number(m[1]); +})(); +if (TAP_DWELL_MS <= TAP_BOUNCE_MS) { + throw new Error(`pace: TAP_DWELL_MS (${TAP_DWELL_MS}) must exceed the app's TAP_BOUNCE_MS (${TAP_BOUNCE_MS})`); +} + +// Wait out what is left of the dwell since `since` (a Date.now() reading taken +// when the layer or the arm was seen, or the tap made). +export async function dwell(page, since) { + const left = TAP_DWELL_MS - (Date.now() - since); + if (left > 0) await page.waitForTimeout(left); +} + +// Wait until `locator` is visible, then out the dwell: for a layer or an armed +// button the journey is about to tap into. +export async function settled(locator) { + await locator.waitFor({ state: 'visible' }); + await dwell(locator.page(), Date.now()); +} + +// page -> Map(side key -> when the last tap on that side finished). +const sideTaps = new WeakMap(); + +// Tap `locator`, a scoring button on one side of a bout, after the dwell since +// the previous tap this page made on the same `side` (any key naming one side +// of one bout: 'shiro', 'bout 2 aka'). Taps on other sides are not held up. +// The tap's time is taken once it returns, which is no earlier than the app's +// own reading of it. +export async function tapOnSide(page, side, locator) { + let taps = sideTaps.get(page); + if (!taps) { + taps = new Map(); + sideTaps.set(page, taps); + } + if (taps.has(side)) await dwell(page, taps.get(side)); + await locator.tap(); + taps.set(side, Date.now()); +} diff --git a/scripts/e2e/fixtures/pointer.mjs b/scripts/e2e/fixtures/pointer.mjs new file mode 100644 index 000000000..07c033ab9 --- /dev/null +++ b/scripts/e2e/fixtures/pointer.mjs @@ -0,0 +1,41 @@ +// The operator's coarse pointer: reading it, putting it back, and measuring a +// tap target under it. +// +// The operator's iPad matches (pointer: coarse), and the tap-floor rules in +// styles.css key on it, so a journey judged at the wrong pointer measures the +// wrong targets. +import { expect } from '@playwright/test'; + +// Does this page match (pointer: coarse)? +export const isCoarse = (page) => page.evaluate(() => matchMedia('(pointer: coarse)').matches); + +// page -> the CDP session that holds its touch emulation. +const touchSessions = new WeakMap(); + +// Turn CDP touch emulation on for `page`, in place, and fail loudly if the +// page still does not match (pointer: coarse). Measured: it takes effect at +// once, on the current document (about:blank before the first navigation +// included), and holds across later navigations, so no reload is needed. The +// session is kept for the page's life: detaching it clears the emulation it +// set (measured), so a detach would undo the restore. +export async function restoreCoarse(page) { + let cdp = touchSessions.get(page); + if (!cdp) { + cdp = await page.context().newCDPSession(page); + touchSessions.set(page, cdp); + } + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); + if (!(await isCoarse(page))) { + throw new Error('page is not (pointer: coarse) even with CDP touch emulation'); + } +} + +// A control's rendered size, for the 44px coarse-pointer floor. The page must +// still match (pointer: coarse) at the moment of measuring, or the 44px floor +// rules are off and the size reads low, so that is asserted first. +export async function tapSize(locator) { + expect(await isCoarse(locator.page()), 'tap targets are measured under (pointer: coarse)').toBe(true); + const box = await locator.boundingBox(); + if (!box) return null; + return { w: Math.round(box.width), h: Math.round(box.height), meets44: box.width >= 44 && box.height >= 44 }; +} diff --git a/scripts/e2e/fixtures/public.mjs b/scripts/e2e/fixtures/public.mjs new file mode 100644 index 000000000..955d1a41f --- /dev/null +++ b/scripts/e2e/fixtures/public.mjs @@ -0,0 +1,26 @@ +// The public surfaces: the competition viewer (/competition/:id) a spectator +// opens on a phone, and the TV board (/display?court=X) the venue projects. +// Neither needs a sign-in; call these on a context that never signed in. +import { expect } from '@playwright/test'; + +export async function openViewer(page, id) { + await page.goto(`/competition/${encodeURIComponent(id)}`); + await expect(page.locator('.viewer__body')).toBeVisible(); +} + +// A row of the viewer's "Recent results" list naming both competitors. +// The list is the `.vsched` block that follows its section title. +export function recentResult(page, { shiro, aka }) { + return page.locator('.section-title', { hasText: /^Recent results$/ }) + .locator('xpath=following-sibling::div[contains(@class, "vsched")][1]') + .locator('button.vsched-item') + .filter({ hasText: shiro }) + .filter({ hasText: aka }); +} + +export const tvBoard = (page) => page.getByTestId('tv-display-root'); + +export async function openTvBoard(page, court) { + await page.goto(`/display?court=${encodeURIComponent(court)}`); + await expect(tvBoard(page)).toContainText(`SHIAIJO ${court}`); +} diff --git a/scripts/e2e/fixtures/scores.mjs b/scripts/e2e/fixtures/scores.mjs new file mode 100644 index 000000000..0a602b486 --- /dev/null +++ b/scripts/e2e/fixtures/scores.mjs @@ -0,0 +1,21 @@ +// A competition's Scores tab (/admin/competition/:id/scores, +// admin_schedule_score_editor.jsx): every match as a row with a Score (or +// Correct) button that opens the OVERLAY score editor, the second mount site +// beside the shiaijo page's inline one. +import { expect } from '@playwright/test'; +import { EDITOR } from '../../screenshots/lib/editor.mjs'; + +export const scoreRows = (page) => page.locator('.score-edit-row'); + +// The first match on `court` whose row offers Score (a scheduled or running +// one; a finished match's row offers Correct), opened in the overlay editor. +// Returns the row it opened, e.g. for reading the pairing. +export async function openScoreEditor(page, id, { court } = {}) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + let rows = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + if (court) rows = rows.filter({ has: page.locator('.score-edit-row__court', { hasText: new RegExp(`^${court}\\b`) }) }); + const row = rows.first(); + await row.getByRole('button', { name: /^Score$/ }).tap(); + await expect(page.locator(EDITOR)).toBeVisible(); + return row; +} diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs new file mode 100644 index 000000000..2c4246df3 --- /dev/null +++ b/scripts/e2e/fixtures/scoring.mjs @@ -0,0 +1,145 @@ +// Scoring an INDIVIDUAL bout on the kendo board (admin_scoring_individual.jsx). +// The board is the same whether it is mounted inline on the shiaijo page +// (INLINE_EDITOR) or as the overlay dialog the Scores tab opens (EDITOR), so +// every helper takes the editor's selector, the inline one by default. The +// team and kachinuki editors score differently and get their own helpers +// (fixtures/team.mjs, fixtures/kachinuki.mjs); they share the Finish check +// below. +// +// The editor selectors and Start come from scripts/screenshots/lib/editor.mjs, +// re-exported here so a journey imports one scoring module. Finish is this +// file's own, not that library's: a journey TAPS, as the operator does on the +// iPad, takes the inline editor as well as the overlay, and must know the +// write LANDED (the library's finishMatch clicks, on the overlay only, and +// returns once the button stops saving). +import { expect } from '@playwright/test'; +import { EDITOR, INLINE_EDITOR, startMatch } from '../../screenshots/lib/editor.mjs'; +import { settled, tapOnSide } from './pace.mjs'; +import { courtIdle, editorIdentity } from './shiaijo.mjs'; + +export { EDITOR, INLINE_EDITOR, startMatch }; + +const SIDE_NAME = { shiro: 'Shiro', aka: 'Aka' }; + +// An ippon button: one waza letter (M, K, D, T, H, and S on naginata) on a +// side of the board. Matched whole, since a substring test would be ambiguous +// the day a two-letter waza appears. +export const ipponButton = (page, side, waza, root = INLINE_EDITOR) => page.locator(root) + .locator(`.sb-side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first(); + +// The autosave indicator on the open editor ("Synced", "Syncing…", +// "Offline", ...; admin_scoring_autosave.jsx). +export const syncState = (page, root = INLINE_EDITOR) => page.locator(root).locator('.sync-pill__label').first(); + +// The slots a side's points fill, outside to inside, that hold a mark. A slot +// is labelled " slot : remove " for a point (a tap clears it), +// " slot : Ht" for the judges' decision, and " slot : +// , " while a decision such as a kiken locks the scoreline; +// neither of the last two clears on a tap. An unfilled slot ends ": empty". +export const filledSlots = (page, side, root = INLINE_EDITOR) => page.locator(root) + .locator(`[aria-label^="${SIDE_NAME[side]} slot "]:not([aria-label$=": empty"])`); + +// Award one ippon and return once the board shows it and the autosave has +// landed. `side` is 'shiro' | 'aka'. A second strike for the same side waits +// out the bounce window of the first (fixtures/pace.mjs). +export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { + const before = await filledSlots(page, side, root).count(); + await tapOnSide(page, `${root} ${side}`, ipponButton(page, side, waza, root)); + await expect(filledSlots(page, side, root)).toHaveCount(before + 1); + await expect(syncState(page, root)).toHaveText('Synced'); +} + +// The Finish button in its first state ("Finish" or "Finish + Start Next →"), +// and the armed state the first tap turns it into. +export const finishButton = (page, root = INLINE_EDITOR) => page.locator(root) + .locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); +export const armedFinishButton = (page, root = INLINE_EDITOR) => page.locator(root) + .locator('button').filter({ hasText: /^Tap again to finish/ }).first(); + +// Tap Finish once: the two-tap guard arms and nothing is submitted yet. Returns +// the armed button once the arm is old enough for a second tap to count. +export async function armFinish(page, root = INLINE_EDITOR) { + await finishButton(page, root).tap(); + await settled(armedFinishButton(page, root)); + return armedFinishButton(page, root); +} + +// What an editor shows when a write did not save: its not-saved banner +// (notSavedText, the refused write's danger state) and an error alert such as +// the team sheet's "a bout has no result". +const NOT_SAVED = '.pending-write-banner--failed, .alert--error'; +const FINISH_LABEL = /^(Finish( \+ Start Next|$)|Tap again to finish|Saving…)/; + +const visibleTexts = (loc) => loc.evaluateAll((els) => els + .filter((e) => e.getClientRects().length > 0) + .map((e) => e.textContent.replace(/\s+/g, ' ').trim())); + +// What an editor reads before a Finish, for finishLanded to compare against: +// the match it holds and any not-saved line already on screen. Then the +// Finish write's answer is listened for: call this BEFORE the tap that +// submits. The write is the PUT /score that completes the match. +export async function beforeFinish(ed) { + const page = ed.page(); + const answer = page.waitForResponse((r) => r.request().method() === 'PUT' && /\/matches\/[^/]+\/score$/.test(r.url()) + && /"status":"completed"/.test(r.request().postData() || ''), { timeout: 15_000 }).then((r) => r, () => null); + return { ...(await editorIdentity(page, ed)), notSaved: await visibleTexts(ed.locator(NOT_SAVED)), answer }; +} + +// One reading of whether the editor has let the finished match go. +async function editorState(ed, before) { + const page = ed.page(); + const fresh = (await visibleTexts(ed.locator(NOT_SAVED)).catch(() => [])).filter((t) => !before.notSaved.includes(t)); + if (fresh.length) return { kind: 'not saved', detail: `the editor says: ${fresh.join(' | ')}` }; + if (!(await ed.isVisible().catch(() => false))) return { kind: 'landed', detail: 'the editor closed' }; + if (await courtIdle(page).isVisible().catch(() => false)) return { kind: 'landed', detail: 'the court is idle' }; + const now = await editorIdentity(page, ed).catch(() => null); + if (!now) return { kind: 'pending', detail: 'the editor is between two matches' }; + if (now.key !== before.key) return { kind: 'landed', detail: `the editor moved on to ${now.key}` }; + const pill = (await ed.locator('.editor-head-pill').allInnerTexts().catch(() => [])).join(' ').trim(); + if (pill) return { kind: 'landed', detail: `the match is no longer running (${pill})` }; + const labels = (await ed.locator('button').allInnerTexts()).map((t) => t.trim()).filter((t) => FINISH_LABEL.test(t)); + if (!labels.length) return { kind: 'landed', detail: 'Finish is no longer offered for the match' }; + return { kind: 'pending', detail: `the editor still holds the match and offers ${labels.join(', ')}` }; +} + +// Return once a Finish's second tap has LANDED, and fail if it did not. Two +// things are read, because neither is enough alone. The server's answer to +// the completing write: none within the expect timeout (queued, or never +// sent), an error status, or `applied: false` (the refusal shape: +// superseded, clock_skew) fails at once with what the server said; a refused +// write is also what makes an editor disarm Finish (writeWasRefused). Then +// the editor: it must let the match go (the overlay closes on a plain +// Finish; the inline editor holds another match or the court goes idle; +// Finish + Start Next holds the next match; or the editor keeps the match +// shown as finished), and it fails if a not-saved banner or a new error +// alert appears. The editor's own label is not taken as the answer: right +// after a landed Finish it can read plain "Finish" on the same match for a +// moment, until the list it is fed from catches up. `ed` is the editor +// Locator, `before` what beforeFinish read ahead of the submitting tap. +export async function finishLanded(ed, before) { + const res = await before.answer; + expect(res, `the Finish write on ${before.key} was answered by the server`).not.toBeNull(); + const body = await res.json().catch(() => null); + const refused = res.status() >= 400 || (body && body.applied === false); + expect(refused, `the server refused the Finish on ${before.key}: ${res.status()} ${JSON.stringify(body)}`).toBe(false); + let state = { kind: 'pending' }; + await expect.poll(async () => { + state = await editorState(ed, before); + return state.kind; + }, { message: `the editor let ${before.key} go after its Finish landed` }).not.toBe('pending'); + expect(state.kind, `after the Finish on ${before.key}: ${state.detail}`).toBe('landed'); + return state; +} + +// Finish in two taps and return once the write has landed (finishLanded). The +// label is "Finish + Start Next →" instead whenever another match waits on the +// same shiaijo, and that form leaves the editor on the next match. Not for the +// engi editor ("Save result" arms too, but is its own button) or a correction +// ("Save correction" does not arm). +export async function finishMatch(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); + const armed = await armFinish(page, root); + const before = await beforeFinish(ed); + await armed.tap(); + return finishLanded(ed, before); +} diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs new file mode 100644 index 000000000..c45d66ea8 --- /dev/null +++ b/scripts/e2e/fixtures/setup.mjs @@ -0,0 +1,49 @@ +// The first-run tournament form, the admin sign-in form and Sign out. +// Every selector for those three lives here. +import { expect } from '@playwright/test'; +import { dwell } from './pace.mjs'; + +const PASSWORD = 'e2e-operator'; + +// A form field by its visible label (each label is tied to its input with +// htmlFor, app.jsx). Anchored at the start: "Number of Shiaijo (courts)" and a +// locked server's "Admin Password (from ...)" carry more than the words. +const field = (page, label) => page.getByLabel(new RegExp(`^${label}`)); + +// The first-run "Welcome to Bracket Creator" form (app.jsx), shown on a +// server with no tournament yet. Creating one signs this page in, so a +// journey that wants the login form next calls signOut() then login(). +export async function createTournament(page, { + name = 'E2E Cup', venue = 'Test Hall', courts = 2, mode = 'Officiated', password = PASSWORD, +} = {}) { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); + await field(page, 'Tournament Name').fill(name); + await field(page, 'Venue').fill(venue); + await field(page, 'Number of Shiaijo').fill(String(courts)); + await page.getByRole('group', { name: 'Tournament type' }).getByRole('button', { name: mode }).tap(); + await field(page, 'Admin Password').fill(password); + await page.getByRole('button', { name: 'Create Tournament' }).tap(); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); +} + +export async function signOut(page) { + await page.getByRole('button', { name: 'Sign out' }).tap(); + await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); +} + +// Sign in through the real form: /admin on a signed-out page opens the +// "Admin sign in" dialog (app.jsx). Never the localStorage shortcut +// scripts/screenshots uses: the form is part of what a journey walks. +export async function login(page, password = PASSWORD) { + await page.goto('/admin'); + const form = page.locator('.modal.auth'); + await expect(form.getByText('Admin sign in')).toBeVisible(); + const shown = Date.now(); + await form.locator('#admin-password').fill(password); + // The dialog ignores a tap inside the bounce window of its opening. + await dwell(page, shown); + await form.getByRole('button', { name: 'Sign in' }).tap(); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); +} diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs new file mode 100644 index 000000000..8acc762fc --- /dev/null +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -0,0 +1,339 @@ +// Two competitions on one shiaijo (bc-shcr) and a match moved between two +// courts (bc-mvsj): the seeding both journeys share, and the court console's +// cross-competition surfaces (the competition picker, the nudge lines, the +// "Another bout is running" alert, the done state) as one readable state. +// +// Seeding is UI-only, as every journey's is (README "Seeding goes through the +// interface"): the first-run form, the create wizard for EVERY competition +// before any roster is pasted (the realistic set-up order, and the one that +// decides each competition's default start time), then the roster paste box, +// Generate draw and Start for each. What the draw produced is read back from +// the Scores tab, never assumed. +import { expect } from '@playwright/test'; +import { createTournament, login } from './setup.mjs'; +import { createCompetition } from './wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from './competition.mjs'; +import { hastyShiaijoConfirm } from './clumsy.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from './devices.mjs'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { INLINE_EDITOR } from './scoring.mjs'; +import { UP_NEXT, WAITING_ROWS, completedRowFor, inlineEditor, waitingRows } from './shiaijo.mjs'; +import * as T from './team.mjs'; + +// A device of its own: an operator iPad (pointer verified coarse, as the +// `page` fixture does for the test's own page) or a spectator phone, on this +// file's server. Each is a separate browser context, so two of them are two +// devices: separate storage, separate event streams. The caller closes +// `page.context()`. +export async function operatorDevice(browser, base) { + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: base }); + const page = await ctx.newPage(); + if (!(await isCoarse(page))) await restoreCoarse(page); + return page; +} +export async function publicDevice(browser, base) { + const ctx = await browser.newContext({ ...PUBLIC_DEVICE, baseURL: base }); + return ctx.newPage(); +} + +// A mark left on a page's window, read back later: still there means the page +// was never reloaded or navigated away in between. +export const markPage = (page, mark) => page.evaluate((m) => { window.__e2eMark = m; }, mark); +export const pageMark = (page) => page.evaluate(() => window.__e2eMark || null); + +// Sign in, creating the tournament first when this file's server has none. +export async function enterTournament(page, { name = 'Shared Court Cup', courts = 2 } = {}) { + await page.goto('/'); + // The first-run form, or the public home of a tournament that exists: + // whichever the first paint shows, however long it takes. + const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); + const home = page.locator('.viewer__body'); + await welcome.or(home).first().waitFor({ state: 'visible' }); + if (await welcome.isVisible()) { + await createTournament(page, { name, courts }); + return { created: true }; + } + await login(page); + return { created: false }; +} + +// What the create wizard offers before a competition is created: its default +// Start time (stacked after the latest same-day competition, admin_setup.jsx) +// and the hint under the shiaijo choice. Read on the wizard page itself, the +// one createCompetition then fills. +// +// The stacked default comes from ONE request, GET /api/competitions// +// schedule/estimate, which the wizard sends exactly when the tournament already +// holds a same-day competition with a start time (pickStackPredecessor); with +// none it keeps the 09:00 default and sends nothing. The caller knows which +// case it is in (`expectEstimate`: a competition was created before this one, +// all on the tournament's date), so the read does not guess from timing: +// - expected: the response is awaited (registered before the page loads, so +// it cannot be missed), the page is given two animation frames to render +// the value the answer set, and the input is then read once two reads +// agree (a competitions refresh can re-send the estimate; the earlier +// request is aborted and the later one wins). +// - not expected: the input is read as soon as the form shows, and an +// estimate request seen meanwhile is an error, never a stale default. +export async function wizardDefaults(page, { expectEstimate = false } = {}) { + const isEstimate = (r) => /\/api\/competitions\/[^/]+\/schedule\/estimate$/.test(new URL(r.url()).pathname); + let sent = 0; + const onRequest = (r) => { if (isEstimate(r)) sent += 1; }; + page.on('request', onRequest); + try { + const answered = expectEstimate + ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: 15_000 }) + : null; + await page.goto('/admin/create-competition'); + await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); + const input = page.locator('#create-start-time'); + await expect(input).toBeVisible(); + if (answered) { + const res = await answered; + await res.finished(); + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + let prev = null; + await expect.poll(async () => { + const v = await input.inputValue(); + const settled = v === prev; + prev = v; + return settled; + }, { intervals: [100], timeout: 5_000 }).toBe(true); + } else if (sent) { + throw new Error('wizardDefaults: the wizard sent a schedule estimate request, but the caller said no same-day competition exists (pass expectEstimate: true)'); + } + const hint = await page.locator('.field__hint', { hasText: 'Different competitions can share' }).first() + .innerText().then((t) => t.trim(), () => null); + return { startTime: await input.inputValue(), sharedCourtHint: hint }; + } finally { + page.off('request', onRequest); + } +} + +// Create every competition first, then paste each roster, generate its draw and +// start it. `specs` is [{ key, opts, roster }] (opts as createCompetition takes +// them). Returns { ids: {key: id}, defaults: {key: wizardDefaults()} }. +export async function seedCompetitions(page, specs) { + const ids = {}; + const defaults = {}; + for (const s of specs) { + defaults[s.key] = await wizardDefaults(page, { expectEstimate: Object.keys(ids).length > 0 }); + ids[s.key] = await createCompetition(page, s.opts); + } + for (const s of specs) { + await pasteRoster(page, ids[s.key], s.roster); + await generateDraw(page, ids[s.key]); + await startCompetition(page, ids[s.key]); + } + return { ids, defaults }; +} + +// Every row of a competition's Scores tab as the operator reads it: time, +// match label, court, both names and the button it offers. The pairings and +// courts the draw produced come from here. +export async function readScoresTab(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + await expect(page.locator('.score-edit-row').first()).toBeVisible(); + return page.locator('.score-edit-row').evaluateAll((rows) => rows.map((r) => { + const txt = (sel) => { + const el = r.querySelector(sel); + return el ? el.textContent.replace(/\s+/g, ' ').trim() : ''; + }; + const names = [...r.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()); + const btn = [...r.querySelectorAll('button')].map((b) => b.textContent.trim()).find((t) => t === 'Score' || t === 'Correct') || ''; + return { + time: txt('.score-edit-row__time'), + label: txt('.score-edit-row__matchno'), + court: txt('.score-edit-row__court').replace(/\s*▾$/, ''), + shiro: names[0] || '', + aka: names[1] || '', + status: r.classList.contains('score-edit-row--running') ? 'running' : (r.classList.contains('score-edit-row--complete') ? 'completed' : 'scheduled'), + button: btn, + }; + })); +} + +// The knockout slots a competition's Bracket tab prints on its cards ("M5 +// SHIAIJO A 09:40"), including the matches still waiting on a feeder, which +// the Scores tab does not list. +export async function bracketSlots(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/bracket`); + // The cards print the court in capitals through CSS, so the rendered text + // (innerText) is what is read. + const read = async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); + await expect.poll(read).toMatch(/M\d+ SHIAIJO \S+ \d\d:\d\d/); + const text = await read(); + return [...text.matchAll(/M(\d+) SHIAIJO (\S+) (\d\d:\d\d)/g)].map((m) => ({ match: `M${m[1]}`, court: m[2], time: m[3] })); +} + +// ---------------------------------------------------------------- court console + +// The competition picker. It renders only while the court hosts more than one +// competition; with one, the officiating name is a plain heading. +export const competitionPicker = (page) => page.getByRole('combobox', { name: 'Select competition to officiate' }); +export const officiatingName = async (page) => (await page.locator('.shiaijo-officiating__name').first().innerText()).replace('▾', '').trim(); + +// Pick a competition with the picker (a native select: chosen, not tapped, as +// the iPad's own picker wheel does it). +export async function pickCompetition(page, name) { + await competitionPicker(page).selectOption({ label: name }); + await expect.poll(() => officiatingName(page)).toBe(name); +} + +// The nudge lines (data-testid shiaijo-nudge): the quiet "also waiting" line +// while the shown competition still has work here, the amber "Switch to" once +// it has none. Each is a button that switches only when tapped. +export const nudges = (page) => page.getByTestId('shiaijo-nudge'); +export async function nudgeLines(page) { + return nudges(page).evaluateAll((els) => els.map((e) => ({ + text: (e.querySelector('.shiaijo-nudge__text') || e).textContent.trim(), + quiet: e.classList.contains('shiaijo-nudge--also'), + amber: e.classList.contains('alert--warn'), + }))); +} + +// Everything the console says about what this court is doing, in one record +// for an audit row: the competition shown and the picker's choices, Up next, +// the queue, the live bout in the editor, the nudge lines, the "Another bout +// is running" alert and the done state. +export async function courtState(page) { + return page.evaluate(({ UP_NEXT, WAITING_ROWS, INLINE_EDITOR }) => { + const text = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : null); + const names = (el) => (el ? [...el.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()) : []); + const picker = document.querySelector('select[aria-label="Select competition to officiate"]'); + const card = document.querySelector(UP_NEXT); + const ed = document.querySelector(INLINE_EDITOR); + const rows = (sel) => [...document.querySelectorAll(sel)].map((r) => ({ head: text(r.querySelector('.shiaijo-qrow__time')), names: names(r) })); + const alsoRunning = document.querySelector('.shiaijo-also-running'); + const empty = document.querySelector('.shiaijo__main > .empty:not(.shiaijo__placeholder)'); + return { + officiating: text(document.querySelector('.shiaijo-officiating__name'))?.replace('▾', '').trim() || null, + pickerOptions: picker ? [...picker.options].map((o) => o.textContent.trim()) : null, + upNext: card ? { head: text(card.querySelector('.shiaijo-upnext__time')), names: names(card) } : null, + upcoming: rows(WAITING_ROWS), + later: rows('.shiaijo-pending .shiaijo-qrow'), + completed: rows('.shiaijo-completed .shiaijo-qrow').length, + editor: ed ? { + eyebrow: text(ed.querySelector('.editor-modal__eyebrow')), + court: ((ed.querySelector('.editor-modal__title') || {}).innerText || '').match(/Shiaijo\s+([A-Z]+)\b/i)?.[1] || null, + title: ((ed.querySelector('.editor-modal__title') || {}).innerText || '').replace(/\s+/g, ' ').trim(), + pill: text(ed.querySelector('.editor-head-pill')), + names: names(ed).slice(0, 2), + } : null, + nudges: [...document.querySelectorAll('[data-testid="shiaijo-nudge"]')].map((e) => ({ + text: text(e.querySelector('.shiaijo-nudge__text')), quiet: e.classList.contains('shiaijo-nudge--also'), + })), + alsoRunning: alsoRunning ? text(alsoRunning) : null, + done: empty ? text(empty) : null, + idle: !!document.querySelector('.shiaijo__placeholder'), + }; + }, { UP_NEXT, WAITING_ROWS, INLINE_EDITOR }); +} + +// The bout running in the console's editor, individual or team alike (the +// individual board's own side wrappers are not on the team sheet): its eyebrow, +// court and pairing, or null when the editor holds no running bout. +export async function liveBout(page) { + const s = await courtState(page); + if (!s.editor || s.editor.pill || s.editor.names.length < 2) return null; + return { eyebrow: s.editor.eyebrow, court: s.editor.court, shiro: s.editor.names[0], aka: s.editor.names[1] }; +} + +// Correct a completed bout on the court console while a bout of EITHER +// competition is live there (individual.mjs correctCompleted reads the live +// bout off the individual board only). Correct is refused while a bout is live +// (bc-crpn): the notice at the row says so, the operator sends the live bout +// back to the queue (its score is kept) and taps Correct again. Returns the +// live bout and what the refusal said. +export async function correctAcross(page, pair) { + const done = completedRowFor(page, pair); + const live = await liveBout(page); + await done.getByRole('button', { name: 'Correct' }).tap(); + let refusal = null; + if (live) { + const notice = done.locator('.shiaijo-upnext__error[role="alert"]'); + await expect(notice).toBeVisible(); + refusal = { notice: (await notice.innerText()).trim(), toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | ') }; + expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); + await page.getByRole('button', { name: 'Send back to queue' }).tap(); + refusal.sendBack = await hastyShiaijoConfirm(page); + await expect.poll(() => liveBout(page), { timeout: 10_000 }).toBeNull(); + await done.getByRole('button', { name: 'Correct' }).tap(); + } + await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); + return { live, refusal }; +} + +// A waiting row of the queue (Upcoming, not the Up next card) naming both +// competitors of a pairing. +export const upcomingRow = (page, { shiro, aka }) => waitingRows(page) + .filter({ hasText: shiro }).filter({ hasText: aka }).first(); + +// Open a row's court control (CourtPicker: "A ▾") and choose `court`. On the +// court console that opens the move confirm, left open for the caller; on the +// Scores tab and the schedule page the move is made at once. +export async function chooseCourt(row, court) { + await row.locator('button[aria-haspopup="listbox"]').first().tap(); + const list = row.page().getByRole('listbox', { name: 'Choose shiaijo' }); + await expect(list).toBeVisible(); + await list.getByRole('option', { name: court, exact: true }).tap(); +} + +// The matches the tournament-wide Score editor lists as running ("Now") on +// `court`, from a page that may be anywhere (it is navigated there): the head +// table's count of what is being fought, independent of the court console. +export async function runningOnCourt(page, court) { + await page.goto('/admin/score-editor'); + await expect(page.getByRole('heading', { level: 1, name: 'Score editor' })).toBeVisible(); + const now = page.getByRole('group', { name: 'Match status' }).getByRole('button', { name: 'Now' }); + await now.tap(); + await expect(now).toHaveAttribute('aria-pressed', 'true'); + // Wait for the list to have settled on a row or on its empty state, then for two + // consecutive reads to agree: a read straight after the tap could see the list + // before the tournament loaded and make a zero-length assertion pass vacuously. + await expect(page.locator('.score-edit-row').first().or(page.getByText('No matches', { exact: true }))).toBeVisible(); + const read = () => page.locator('.score-edit-row').evaluateAll((els) => els.map((r) => { + const names = [...r.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()); + const ct = r.querySelector('.score-edit-row__court'); + const comp = r.querySelector(':scope > div:first-child > div:last-child'); + return { + court: ct ? ct.textContent.replace(/\s*▾$/, '').trim() : '', + comp: comp ? comp.textContent.trim() : '', + label: (r.querySelector('.score-edit-row__matchno') || {}).textContent || '', + shiro: names[0] || '', aka: names[1] || '', + status: r.classList.contains('score-edit-row--running') ? 'running' : 'other', + }; + })); + let rows = await read(); + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(250); + const again = await read(); + const same = JSON.stringify(again) === JSON.stringify(rows); + rows = again; + if (same) break; + } + return rows.filter((r) => r.court === court && r.status === 'running'); +} + +// ---------------------------------------------------------------- team match + +// Play the running TEAM match on the court console for its Shiro team (one men +// on every numbered bout) and Finish (+ Start Next) through the two-tap guard. +// Starts the Up next team match first when the court is idle. Returns the +// pairing played. +export async function playTeamMatch(page, { teamSize = 3 } = {}) { + const ed = inlineEditor(page); + if (!(await T.boutRow(ed, 1).isVisible().catch(() => false)) || (await ed.locator('.editor-head-pill').count()) > 0) { + await T.startUpNextTeam(page); + } + const names = await ed.locator('.numbered-name__text').allInnerTexts(); + const pair = { shiro: names[0].trim(), aka: names[1].trim() }; + for (let n = 1; n <= teamSize; n += 1) { + const row = T.boutRow(ed, n); + if ((await T.rowSlots(row, 'shiro').count()) === 0 && (await T.rowSlots(row, 'aka').count()) === 0) { + await T.boutIppon(ed, n, 'shiro', 'M'); + } + } + await T.finishTeam(ed); + return pair; +} diff --git a/scripts/e2e/fixtures/shiaijo.mjs b/scripts/e2e/fixtures/shiaijo.mjs new file mode 100644 index 000000000..ffd7cbc58 --- /dev/null +++ b/scripts/e2e/fixtures/shiaijo.mjs @@ -0,0 +1,78 @@ +// The court operator's page, /admin/shiaijo/:court (admin_shiaijo.jsx): the +// queue on the left (Up next, Upcoming, Completed) and the inline score +// editor on the right. Every selector for the court console's own surfaces +// lives here; the editor's insides belong to the scoring fixtures. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; + +export async function openShiaijo(page, court) { + await page.goto(`/admin/shiaijo/${encodeURIComponent(court)}`); + await expect(page.getByRole('heading', { level: 1, name: `Shiaijo ${court}` })).toBeVisible(); +} + +// The queue's parts, as selectors for a caller that combines them (or reads +// them inside the page) and as locators. WAITING_ROWS is Upcoming: the rows +// still to be played, not Later and not Completed. +export const UP_NEXT = '.shiaijo-upnext__card'; +export const WAITING_ROWS = '.shiaijo__queue .shiaijo-group:not(.shiaijo-pending) .shiaijo-qrow:not(.shiaijo-qrow--complete)'; +export const upNextCard = (page) => page.locator(UP_NEXT); +export const waitingRows = (page) => page.locator(WAITING_ROWS); +export const completedRows = (page) => page.locator('.shiaijo-completed .shiaijo-qrow'); +export const inlineEditor = (page) => page.locator(INLINE_EDITOR); +// "Ready when you are": nothing selected while matches remain. And the done +// state, " is complete on Shiaijo X", once none remain. +export const courtIdle = (page) => page.locator('.shiaijo__placeholder'); +export const courtDone = (page) => page.locator('.shiaijo__main > .empty:not(.shiaijo__placeholder)'); + +// The two names a match row shows, Shiro first. `.numbered-name__text` is the +// name alone, without the competitor number chip beside it. +export async function sides(row) { + const names = row.locator('.numbered-name__text'); + return { shiro: (await names.nth(0).textContent()).trim(), aka: (await names.nth(1).textContent()).trim() }; +} + +// Which match an editor holds, as the operator reads it: its eyebrow ("MEN +// INDIVIDUAL · POOL A · MATCH 1 OF 3"), its title line ("Shiaijo B · 10:20", +// which says the court) and the first two names it shows. `key` joins them, +// so two readings tell whether the editor moved to another match. The inline +// editor by default; pass EDITOR for the overlay the Scores tab opens, or any +// editor Locator. +export async function editorIdentity(page, root = INLINE_EDITOR) { + const ed = typeof root === 'string' ? page.locator(root).first() : root.first(); + const first = async (sel) => ((await ed.locator(sel).allInnerTexts())[0] || '').trim(); + const eyebrow = await first('.editor-modal__eyebrow'); + // The title's first line: the autosave pill beside it is not the match. + const title = (await first('.editor-modal__title')).split('\n')[0].trim(); + const names = (await ed.locator('.numbered-name__text').allInnerTexts()).slice(0, 2).map((t) => t.trim()); + return { + eyebrow, title, names, + court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null, + key: [eyebrow, title, ...names].join(' | '), + }; +} + +// Start the Up next match the way the operator does, from its card, and +// return the pairing once the inline editor holds it. Only the card's own +// "Start match" is used: every Upcoming row carries one too. +export async function startUpNext(page) { + const card = upNextCard(page); + const pair = await sides(card); + await card.getByRole('button', { name: 'Start match' }).tap(); + const editor = inlineEditor(page); + await expect(editor.locator('.sb-side--shiro')).toContainText(pair.shiro); + await expect(editor.locator('.sb-side--aka')).toContainText(pair.aka); + return pair; +} + +// The newest Completed row whose names match a pairing (the Completed list is +// oldest first, so the newest is its tail). +export function completedRowFor(page, { shiro, aka }) { + return completedRows(page).filter({ hasText: shiro }).filter({ hasText: aka }).last(); +} + +// The newest completed match with its score string, e.g. "M vs –". +export async function lastCompleted(page) { + const row = completedRows(page).last(); + await expect(row).toBeVisible(); + return { ...(await sides(row)), result: (await row.locator('.shiaijo-qrow__result').innerText()).trim() }; +} diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs new file mode 100644 index 000000000..1e2eb8537 --- /dev/null +++ b/scripts/e2e/fixtures/shots.mjs @@ -0,0 +1,46 @@ +// Per-step screenshots: /-.png, numbered in the +// order they were taken so the folder reads as the walkthrough. They are for +// a human to judge (and for the PR body), never compared by a test. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; + +// scripts/e2e, the directory playwright.config.mjs sits in. +const E2E_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url))); + +// Where every journey's folder lives. With E2E_RESULTS_DIR set (a second run +// at the same time, see README.md) it is inside that run's own results folder, +// resolved the way Playwright resolves outputDir (relative to the config's +// directory), so two runs never empty each other's folders; Playwright empties +// that folder once, when the run starts, before any test writes. Unset, it is +// output/. +const JOURNEYS_ROOT = process.env.E2E_RESULTS_DIR + ? path.resolve(E2E_DIR, process.env.E2E_RESULTS_DIR, 'journeys') + : path.join(E2E_DIR, 'output'); + +// The one owner of a journey's output folder: its screenshots and its +// audit.json (fixtures/audit.mjs). `journey` may name a subfolder +// ('knockout-mixed-individual/J1'). +export const journeyDir = (journey) => path.join(JOURNEYS_ROOT, journey); + +// Empty the journey's folder and return its camera. Call once per spec file +// (in beforeAll), so a folder never mixes two runs. +export function journeyShots(journey) { + const dir = journeyDir(journey); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + let n = 0; + return async (page, step, { fullPage = false } = {}) => { + n += 1; + const file = path.join(dir, `${String(n).padStart(2, '0')}-${step}.png`); + const coarse = fullPage && await isCoarse(page); + await page.screenshot({ path: file, fullPage, animations: 'disabled', caret: 'hide' }); + // A fullPage shot resizes the viewport and, restoring it, drops the touch + // emulation: measured, (pointer: coarse) reads false afterwards, so every + // tap-floor rule switches off for the rest of the test. Put it back in + // place (fixtures/pointer.mjs). + if (coarse) await restoreCoarse(page); + return file; + }; +} diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs new file mode 100644 index 000000000..4918a1077 --- /dev/null +++ b/scripts/e2e/fixtures/team.mjs @@ -0,0 +1,306 @@ +// TEAM (non-kachinuki) competitions: naming team members, the three lineup entry +// points, and scoring a bout inside a team encounter. +// +// the Lineups page /admin/competition/:id/lineups (admin_lineup.jsx) +// writes one team's STARTING lineup, or, through its +// "Lineup for" select, one match's own lineup +// the match panel "Enter lineup" / "Lineup" on the shiaijo queue +// (admin_schedule_lineup.jsx), writes the MATCH lineup +// the score sheet row the name box on each bout row of the team editor +// (admin_scoring_team.jsx), writes the MATCH lineup +// +// The shared scoring helpers (fixtures/scoring.mjs) scope to the INDIVIDUAL +// board's `.sb-side--*` wrappers, which the team sheet does not have, so the +// team sheet's own selectors live here, for the kachinuki sheet as well +// (fixtures/kachinuki.mjs reads its bout rows through them), and so does the +// lineup panel. Every helper performs a real tap on a real control; none of +// them asserts app behaviour beyond "the tap landed", except Finish, which +// checks the write landed (fixtures/scoring.mjs finishLanded). +import { expect } from '@playwright/test'; +import { createTournament, login } from './setup.mjs'; +import { settled, tapOnSide } from './pace.mjs'; +import { beforeFinish, finishLanded } from './scoring.mjs'; +import { inlineEditor, upNextCard } from './shiaijo.mjs'; + +// FIK position names for a five-person team, in order; any other size uses +// bare numbers (admin_lineup.jsx positionsForSize). +export const FIK5 = ['Senpo', 'Jiho', 'Chuken', 'Fukusho', 'Taisho']; +export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from({ length: teamSize }, (_, i) => String(i + 1))); + +// Sign in, creating the tournament first if this server has none yet. Every +// test in a file can call it, whichever runs first or in which worker. +export async function enterAdmin(page, { courts = 16 } = {}) { + await page.goto('/'); + // The first-run form, or the public home of a tournament that exists: + // whichever the first paint shows, however long it takes. + const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); + const home = page.locator('.viewer__body'); + await welcome.or(home).first().waitFor({ state: 'visible' }); + if (await welcome.isVisible()) { + await createTournament(page, { name: 'Team Cup', courts }); + return; + } + await login(page); +} + +// ---------------------------------------------------------------- Lineups page + +const overlineField = (page, text) => page.locator('label') + .filter({ has: page.locator('.overline', { hasText: new RegExp(`^${text}$`, 'i') }) }).first(); + +export const lineupsForm = (page) => page.getByTestId('lineup-form-root'); + +// The Lineups page's own name for a team's lineup before any match has one of +// its own: what the "Lineup for" select offers first and the form's overline +// reads for it. +export const STARTING_LINEUP = 'Starting lineup'; + +// Open the Lineups page on `team` (its display name) and on the lineup the +// "Lineup for" select names: the team's "Starting lineup" (the default), or the +// label of one of its matches as the select lists it (e.g. "Pool A · Match 1"). +export async function openLineups(page, id, { team, lineupFor = STARTING_LINEUP } = {}) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/lineups`); + await expect(lineupsForm(page)).toBeVisible(); + if (team) { + const sel = overlineField(page, 'Team').locator('select'); + await sel.selectOption({ label: team }); + await expect(lineupsForm(page).locator('h2')).toContainText(team); + } + if (lineupFor !== STARTING_LINEUP) { + await overlineField(page, 'Lineup for').locator('select').selectOption({ label: lineupFor }); + } + // The form's overline names the lineup it edits. + await expect(lineupsForm(page).locator('.overline').first()).toHaveText(lineupFor); + // The team members load separately from the lineup; wait for their list. + await expect(lineupsForm(page).getByText('Team members', { exact: true })).toBeVisible(); +} + +// The choices of the Lineups page's "Lineup for" select, in order: "Starting +// lineup" first, then the team's matches by the labels the select shows. +export const lineupForOptions = async (page) => (await overlineField(page, 'Lineup for').locator('option').allInnerTexts()) + .map((t) => t.trim()); + +// The position's { setPass(e.target.value); setError(""); }} placeholder="••••••••" autoComplete="new-password" />
Enter a new password to change it. Leave blank to keep the current one.
)} - {/* Elevated (destructive-ops) password: spec 004 / mp-e21. */} - {locked ? ( -
- -
- This server is in locked mode. The destructive-ops password comes from TOURNAMENT_ADMIN_PASSWORD_HASH and can only be changed by restarting the server with a new hash. If unset, destructive actions (delete competition, discard draw, roster changes, import) return 503. -
-
- ) : ( -
- - {/* The label names the new password; the current one, shown - only for a rotation, is named by its own words. */} - {elevatedConfigured && ( - setAdminCurrent(e.target.value)} placeholder="Current destructive-ops password" autoComplete="off" style={{ marginBottom: 8 }} /> - )} -
- setAdminNew(e.target.value)} placeholder={elevatedConfigured ? "New destructive-ops password" : "Set destructive-ops password"} autoComplete="new-password" /> - -
-
- A separate password required for destructive actions (delete competition, discard draw, roster add/edit, import). Leave unset to gate those behind the main password only. {elevatedConfigured && "Changing it requires the current destructive-ops password."} -
-
- )} {/* mp-scf: branding (colors + logo). Appears before sponsors so the brand-identity section leads the page. onThemeChange propagates @@ -709,14 +653,13 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM )} {/* Action bar is the page's final commit, sitting below every card it (partly) persists. Branding colors + main password ride along on - this Save; logo, sponsors and the destructive-ops password - self-save via their own buttons. */} + this Save; logo and sponsors self-save via their own buttons. */}
{/* mp-sspn: make the split persistence model explicit so the single */} {/* Save button's scope isn't a guess. */}
Saves the tournament details, schedule blocks, public information, branding colors, and admin password. - The logo, sponsors, and destructive-ops password save on their own buttons. + The logo and sponsors save on their own buttons.
{dirty && Unsaved changes} @@ -1470,7 +1413,7 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi { setNaginata(on); setTwoThirdPlaces(twoThirdPlacesForNaginata(on)); }} hint={HINT_NAGINATA} /> - + {tournamentHasCheckIn(tournament) && }
@@ -1577,8 +1520,6 @@ function AdminImportPage({ tournament, onBack, onImported, onLogout, onViewerMod const doImport = async () => { if (!files.length) return; if (!(await window.confirmDialog({ message: "Are you sure you want to import these competitions? This will add new competitions to the tournament.", confirmLabel: "Import competitions" }))) return; - const admin = await window.promptAdminPassword(); - if (admin === null) return; setLoading(true); setError(null); try { @@ -1586,7 +1527,7 @@ function AdminImportPage({ tournament, onBack, onImported, onLogout, onViewerMod files.forEach(f => fd.append("files", f, f.webkitRelativePath || f.name)); // Use the centralized API wrapper (api.jsx) so auth + error handling // stay consistent with the rest of the admin UI. - const body = await window.API.importCompetitions(fd, password, admin); + const body = await window.API.importCompetitions(fd, password); // mountedRef gates the post-await setStates so a navigate-back // during the upload doesn't fire setResults / setTimeout on a // torn-down component. importedTimerRef has its own unmount diff --git a/web-mobile/js/admin_shell.jsx b/web-mobile/js/admin_shell.jsx index 947836228..45e26a622 100644 --- a/web-mobile/js/admin_shell.jsx +++ b/web-mobile/js/admin_shell.jsx @@ -8,6 +8,7 @@ import { heldWritesText, heldWriteLine, heldWriteDiscardConfirm, matchLabel, heldLineupLabel, HELD_WRITES_TITLE, HELD_WRITES_EMPTY, HELD_WRITE_DISCARD_ONE_LABEL } from './write_result.jsx'; import { scoreRowMatchLabel } from './pool_ids.jsx'; import { publishHeight } from './published_height.jsx'; +import { tournamentHasCheckIn } from './competition_shape.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA, useLayoutEffect: useLayoutEffectA } = React; @@ -647,7 +648,7 @@ function AdminDashboard({ tournament, password, onOpenCompetition, onCreateCompe
- {onOpenRegistration && ( + {onOpenRegistration && tournamentHasCheckIn(t) && ( {isTeamMatch(upNext) && ( @@ -1994,7 +2007,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, label="Upcoming" subGroup matches={upcomingQueueMatches} courts={courts} onMoveCourt={requestMoveCourt} onMove={moveMatch} onEnterLineup={(m) => setLineupKey(matchKey(m))} - onPick={pickMatch} noticeFor={(m) => startNoticeFor(matchKey(m))} + onPick={tapStart} noticeFor={(m) => startNoticeFor(matchKey(m))} onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} scheduled={filteredScheduled} password={password} @@ -2608,7 +2621,7 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE queue keeping its score (bc-sbq), then this match starts for scoring. bc-cse: never offered on a barred row -- BarredMatchNotice above owns its one-tap resolution instead, since the server would just refuse a Start. */} - {onPick && !barred && } + {onPick && !barred && }
)} {notice && !isComplete &&
{notice}
} diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 945b3e94c..105d3e412 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -1523,6 +1523,18 @@ async function _flushQueue() { // open: that fetch lands it or queues it anew, so sending the copy // too would send the write twice. if (_heldByOpenFetch(key, descriptor)) continue; + // Out of the queue AND out of storage at once, not at the end of + // the pass: a page that goes away while a later entry is still + // out (a reload, the tab closed) would leave this one stored, and + // the next load would send it again. A Finish sent twice is + // refused the second time (an equal stamp over a finished match + // asks for a correction reason), so the operator would be told a + // recorded result was not. + const leaveQueue = () => { + if (!_dequeue(key, descriptor)) return false; + _persistQueue(); + return true; + }; const { compID, matchID, payload, password, terminal, kind, method, url } = descriptor; // Running score writes (terminal=false) don't store method/url and // always PUT to the score endpoint. Terminal entries carry their own @@ -1541,7 +1553,7 @@ async function _flushQueue() { // channel above only reaches an operator who happens to have // this exact match open. _notifyQueueAlert({ kind: 'unreadable', count: 1, terminalCount: 1, compID, matchID }); - _dequeue(key, descriptor); + leaveQueue(); continue; } effectiveMethod = method; @@ -1608,7 +1620,7 @@ async function _flushQueue() { // the server state is right), here it is a failure // AND the local bracket is wrong. if (kind === 'override') _notifyBracketResync({ compID, matchID, reason: CLOCK_SKEW_REASON }); - _dequeue(key, descriptor); + leaveQueue(); continue; } await _restampQueuedEntryForSkew(descriptor); @@ -1725,7 +1737,7 @@ async function _flushQueue() { noteDefaultWinStands(body); lastSupersededMatch = { compID, matchID }; } - if (_dequeue(key, descriptor)) { + if (leaveQueue()) { // A result kept in the match's history did not land // (announced above). writeWasSuperseded is every // applied:false answer, a clock-skew refusal included @@ -1788,7 +1800,7 @@ async function _flushQueue() { anyFailed = true; } else if (res.status === 401) { // bc-qttl: an auth failure PARKS the write, it does not drop it. - // 401 is precisely "invalid tournament password", the one 4xx + // 401 is precisely "invalid admin password", the one 4xx // where a later retry can genuinely succeed: the tournament // password can be rotated mid-event, or the operator can simply // sign in again. Discarding here would throw away a finished @@ -1837,7 +1849,7 @@ async function _flushQueue() { detail: refusal.reason, ...(refusal.sentence ? { sentence: true } : {}), }); - _dequeue(key, descriptor); + leaveQueue(); } else { // Non-retryable response: 400 validation, 413, generic 409 // conflict, etc. NOTE neither auth-ish status reaches here any @@ -1878,7 +1890,7 @@ async function _flushQueue() { detail: refusal.reason, ...(refusal.sentence ? { sentence: true } : {}), }); - _dequeue(key, descriptor); + leaveQueue(); } } catch (_) { // fetch rejected (network down), or the request or its body @@ -2539,16 +2551,6 @@ function _ensureConnected() { }; } -// adminHdr returns the X-Admin-Password header object for the elevated -// (destructive-ops) password (spec 004 / mp-e21), or an empty object when no -// admin password is supplied. Destructive methods spread it into their -// headers so a request can carry BOTH X-Tournament-Password (main) and -// X-Admin-Password (elevated). When the gate is inactive on the server -// (file mode, no admin pw set) callers pass "" and the header is omitted. -function adminHdr(adminPassword) { - return adminPassword ? { 'X-Admin-Password': adminPassword } : {}; -} - // parseSkippedCompetitionsHeader parses the server's X-Skipped-Competitions // header value into [{id, reason}, ...]. The wire format is // ": | : " (internal/mobileapp/handlers_print.go, @@ -2775,9 +2777,11 @@ const API = { return null; } }, - // Reset the tournament password. Unauthenticated by design: the + // Reset the admin password. Unauthenticated by design: the // server enforces "is this endpoint enabled" via the verifier's - // ResetEnabled() (locked mode returns 404). Throws on non-2xx so + // ResetEnabled() (locked mode returns 404). In file mode it answers + // only a request made on the server machine (loopback peer and + // loopback Host), else 403 reset_local_only. Throws on non-2xx so // the caller can surface the server's error message (including the // 404 "reset disabled" case if the SPA's cached authConfig was // stale). @@ -2799,6 +2803,9 @@ const API = { const err = await res.json().catch(() => ({})); const e = new Error(err.error || `Failed to reset password (Status ${res.status})`); e.status = res.status; + // The refusal code (reset_local_only) lets the form word the + // case; absent on a bare error. + if (err.code) e.code = err.code; throw e; } // Backend returns 204 No Content: calling .json() on an empty @@ -2806,29 +2813,6 @@ const API = { // overridePoolRanks etc. above). return true; }, - // Set or rotate the elevated (destructive-ops) admin password (spec 004 - // / mp-e21). File mode only: locked mode 404s (credential is the - // TOURNAMENT_ADMIN_PASSWORD_HASH env var). `password` is the MAIN - // tournament password (gates the endpoint via AuthMiddleware). - // `currentAdminPassword` is required only when an admin password is - // already set (rotation); on first-time set (TOFU) the server ignores it. - // The endpoint never echoes any password back. - async setAdminPassword(newPassword, currentAdminPassword, password) { - const body = { newPassword }; - if (currentAdminPassword) body.currentPassword = currentAdminPassword; - const res = await fetch('/api/auth/admin-password', { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, - body: JSON.stringify(body) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - const e = new Error(err.error || `Failed to set admin password (Status ${res.status})`); - e.status = res.status; - throw e; - } - return res.json(); - }, // `bounded` gives up after FETCH_TIMEOUT_MS, the body read included // (_fetchJson), instead of waiting on a connection that never answers. // The organiser's refresh after a write asks for it (admin.jsx @@ -2920,7 +2904,7 @@ const API = { } return res.json(); }, - async updateCompetition(id, config, password, adminPassword) { + async updateCompetition(id, config, password) { // Go's domain.Player uses json:"metadata" (array); the JS layer uses the // friendlier danGrade string. Convert before encoding so the backend can // unmarshal the dan grade into Player.Metadata. @@ -2944,11 +2928,7 @@ const API = { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'X-Tournament-Password': password, - // Roster-bearing updates (non-null players) hit the server's - // elevated-password gate (spec 004); the caller threads the - // admin password for those. Settings-only saves omit it. - ...adminHdr(adminPassword) + 'X-Tournament-Password': password }, body: JSON.stringify(body) }); @@ -3062,10 +3042,10 @@ const API = { return []; } }, - async discardDraw(id, password, adminPassword) { + async discardDraw(id, password) { const res = await fetch(`/api/competitions/${id}/draw`, { method: 'DELETE', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password } }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -3083,12 +3063,11 @@ const API = { // (league, Swiss). 400 when the bracket reads fine, and 400 with a reason // for any competition that draws its bracket DIRECTLY, where the file is the // only record of the draw and rebuilding would invent different pairings - // rather than restore these. Elevated-gated like discardDraw: it discards - // recorded results. - async quarantineBracket(id, password, adminPassword) { + // rather than restore these. + async quarantineBracket(id, password) { const res = await fetch(`/api/competitions/${id}/bracket/quarantine`, { method: 'POST', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password } }); const body = await res.json().catch(() => ({})); if (!res.ok) { @@ -3104,12 +3083,11 @@ const API = { // on status === "completed") never becomes reachable. 400 when the // competition isn't in "pools" or "knockout" status (already completed, or // not started yet), or a naginata 3rd-place match is still unscored; 404 - // when the competition doesn't exist. Elevated-gated (irreversible), so it - // takes the admin password like discardDraw. - async completeCompetition(id, password, adminPassword) { + // when the competition doesn't exist. + async completeCompetition(id, password) { const res = await fetch(`/api/competitions/${id}/complete`, { method: 'POST', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password } }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -3841,10 +3819,10 @@ const API = { ...(Array.isArray(verdict.heldGroups) ? { heldGroups: verdict.heldGroups } : {}), }; }, - async resetOverrides(compID, password, adminPassword) { + async resetOverrides(compID, password) { const res = await fetch(`/api/competitions/${compID}/overrides`, { method: 'DELETE', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password } }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -4046,10 +4024,10 @@ const API = { const body = await res.json().catch(() => ({})); return body.result ?? body; }, - async importCompetitions(formData, password, adminPassword) { + async importCompetitions(formData, password) { const res = await fetch('/api/tournament/import', { method: 'POST', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) }, + headers: { 'X-Tournament-Password': password }, body: formData }); if (!res.ok) { @@ -4058,24 +4036,29 @@ const API = { } return res.json(); }, - async deleteCompetition(id, password, adminPassword) { + // Deleting a competition asks for the password again: `confirmPassword` is + // what the operator typed in that moment, sent beside the sign-in password. + // A refusal of it is a 401 that carries a `confirm_password_*` code; it is + // thrown as an Error with that `code` and nothing else (no queue, no parking, + // no sign-out), so the dialog that asked can show the sentence and ask again. + async deleteCompetition(id, password, confirmPassword) { const res = await fetch(`/api/competitions/${id}`, { method: 'DELETE', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password, 'X-Confirm-Password': confirmPassword ?? '' } }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to delete competition"); + throw Object.assign(new Error(err.error || "Failed to delete competition"), { code: err.code }); } return true; }, // Returns the updated competition JSON (including the new `status`) so // callers can apply it to local state immediately instead of waiting for // the SSE refresh / tournament refetch to land. - async invalidateCompetition(id, password, adminPassword) { + async invalidateCompetition(id, password) { const res = await fetch(`/api/competitions/${id}/invalidate`, { method: 'POST', - headers: { 'X-Tournament-Password': password, ...adminHdr(adminPassword) } + headers: { 'X-Tournament-Password': password } }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -4085,16 +4068,12 @@ const API = { }, // Replace the full fighting-spirit awards list for a competition. // awards: array of { title, recipientName, recipientDojo? }. - // Requires X-Admin-Password only when the elevated gate is active - // (locked mode); in file mode the gate is optional. adminPassword is - // sent when provided and ignored server-side when the gate is off. - async updateCompetitionAwards(id, awards, password, adminPassword) { + async updateCompetitionAwards(id, awards, password) { const res = await fetch(`/api/competitions/${id}/awards`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'X-Tournament-Password': password, - ...adminHdr(adminPassword) + 'X-Tournament-Password': password }, body: JSON.stringify({ fightingSpiritAwards: awards }) }); @@ -4435,7 +4414,7 @@ const API = { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Failed to ${checkedIn ? 'check in' : 'undo check-in'} participant`); + throw new Error(err.message || err.error || `Failed to ${checkedIn ? 'check in' : 'undo check-in'} participant`); } return res.json(); }, @@ -4455,14 +4434,14 @@ const API = { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || 'Failed to check in participants'); + throw new Error(err.message || err.error || 'Failed to check in participants'); } return res.json(); }, - async addParticipant(compID, payload, password, adminPassword) { + async addParticipant(compID, payload, password) { const res = await fetch(`/api/competitions/${encodeURIComponent(compID)}/participants`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password, ...adminHdr(adminPassword) }, + headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, body: JSON.stringify(payload) }); if (!res.ok) { @@ -4471,10 +4450,10 @@ const API = { } return res.json(); }, - async replaceParticipant(compID, pid, payload, password, adminPassword) { + async replaceParticipant(compID, pid, payload, password) { const res = await fetch(`/api/competitions/${encodeURIComponent(compID)}/participants/${encodeURIComponent(pid)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password, ...adminHdr(adminPassword) }, + headers: { 'Content-Type': 'application/json', 'X-Tournament-Password': password }, body: JSON.stringify(payload) }); if (!res.ok) { @@ -4602,7 +4581,7 @@ const API = { // resolves each by chusen (drawing lots), recorded via overridePoolRanks. async chusenCandidates(compID, password) { // Lives in the admin-gated competition router, so it needs the - // tournament password header (unlike the public league candidates GET). + // admin password header (unlike the public league candidates GET). const res = await fetch(`/api/competitions/${encodeURIComponent(compID)}/chusen-candidates`, { headers: { 'X-Tournament-Password': password || '' } }); diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx index c5a912a44..4a00d83cc 100644 --- a/web-mobile/js/app.jsx +++ b/web-mobile/js/app.jsx @@ -3,12 +3,12 @@ import { applyPatch as patchCompetitionData, checkSeqGap, keepNewerDetail, keepNewerTournament } from './patch.jsx'; import { createTimerPool } from './timer_pool.jsx'; -import { setCachedAuthConfig } from './admin_helpers.jsx'; import { LS_NOTIFICATIONS_ENABLED } from './notification_keys.jsx'; import { bridge, setSnapshotProvider, setDisplayCourt, getLastBroadcastAt, applyPatchToTree, mergeSnapshotIntoTree, deriveLinkState, freshnessMs } from './court_bridge.jsx'; import { BRANDING_DEFAULTS } from './admin_branding.jsx'; import { queuedWritesNoun, supersededAlertText, displacedAlertText } from './write_result.jsx'; import { useOpenedTapGuard } from './tap_guard.jsx'; +import { SELF_RUN_NO_CHECK_IN } from './competition_shape.jsx'; const { useState: useS, useEffect: useE, useRef: useR, useCallback: useC } = React; @@ -386,7 +386,7 @@ export function queueAlertMessage(alert) { case "storage_full": return "Browser storage is full, so unsaved results can no longer be kept safely. Let them sync before reloading this tab."; case "discarded": - return `${n} unsaved ${noun} ${one ? "was" : "were"} discarded because the tournament password changed.`; + return `${n} unsaved ${noun} ${one ? "was" : "were"} discarded because the admin password changed.`; case "sent": return `${n} ${noun} sent.`; // Held finishes that landed and moved a later change of their match to @@ -1008,11 +1008,6 @@ function App() { }); }, []); - // Mirror authConfig into the admin_helpers cache so promptAdminPassword() - // (spec 004) can read the elevated-password bits at destructive call sites - // without prop-drilling authConfig through every admin component. - useE(() => { setCachedAuthConfig(authConfig); }, [authConfig]); - useE(() => { // Track every pending jittered timer so the cleanup can cancel them when // viewerCompId / mode changes. Without this, a timer queued for @@ -1735,8 +1730,8 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth
{reauth ? "Sign in to save" : "Admin sign in"}
{reauth - ? "Results saved on this device have not reached the server because the tournament password changed. Enter the current password to save them." - : "Enter the tournament password to manage brackets, schedules and results."} + ? "Results saved on this device have not reached the server because the admin password changed. Enter the current password to save them." + : "Enter the admin password to manage brackets, schedules and results."}
@@ -1766,7 +1761,7 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth // re-authenticated): see resetPassword's tournament // broadcast. Cheaper to ask twice than to surprise an // operator who clicked the wrong link. - if (await window.confirmDialog({ message: "Reset the tournament password? This will sign out all other admins.", confirmLabel: "Reset password", danger: true })) { + if (await window.confirmDialog({ message: "Reset the admin password? This will sign out all other admins.", confirmLabel: "Reset password", danger: true })) { onForgotPassword(); } }} @@ -1784,16 +1779,13 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth // validateCreateTournament: pure validation for the CreateTournament form. // Returns "" when all fields are valid, or the first error message string. // Extracted so it can be unit-tested without mounting the full component. -export function validateCreateTournament({ name, pass, date, courts, isSelfRun, locked, adminPass, maxCourts }) { +export function validateCreateTournament({ name, pass, date, courts, maxCourts }) { const trimmedName = typeof name === "string" ? name.trim() : ""; if (!trimmedName) return "Tournament Name is required."; if (!pass) return "Admin Password is required."; if (!date || (typeof window.dmyToIso === "function" && !window.dmyToIso(date))) { return "Date is required."; } - if (isSelfRun && !locked && !adminPass) { - return "Self-run tournaments require a Destructive-ops password to protect delete and import actions."; - } if (!Number.isInteger(courts) || courts < 1 || courts > (maxCourts || window.MAX_COURTS)) { return `Number of courts must be a whole number between 1 and ${maxCourts || window.MAX_COURTS}.`; } @@ -1834,11 +1826,6 @@ function CreateTournament({ onCreated, authConfig }) { // Chosen once at creation; read-only after that. Default officiated // means existing deployments are unaffected. const [mode, setMode] = useS("officiated"); - // Destructive-ops (admin) password: required when creating a - // self-run tournament in file mode: the main auth gate is skipped in - // self-run, so destructive actions must fall back to this credential. - // Not shown in locked mode (the env-var hash is the credential). - const [adminPass, setAdminPass] = useS(""); const [saving, setSaving] = useS(false); const [error, setError] = useS(""); // submit's catch sets setSaving(false) post-await; on success the @@ -1863,8 +1850,8 @@ function CreateTournament({ onCreated, authConfig }) { const trimmedVenue = venue.trim(); // Explicit validation now that noValidate disables native HTML5 // browser blocking. validateCreateTournament covers name, password, - // self-run adminPass, and courts range in one pass. - const validationErr = validateCreateTournament({ name, pass, date, courts, isSelfRun, locked, adminPass, maxCourts: window.MAX_COURTS }); + // date and courts range in one pass. + const validationErr = validateCreateTournament({ name, pass, date, courts, maxCourts: window.MAX_COURTS }); if (validationErr) { setError(validationErr); return; @@ -1877,16 +1864,6 @@ function CreateTournament({ onCreated, authConfig }) { courts: Array.from({ length: courts }, (_, i) => String.fromCharCode(65 + i)), mode, }; - // Self-run in file mode: send the destructive-ops password as a - // transient `adminPassword` field on the SAME POST. The server reads - // it via a second body bind (Tournament.AdminPassword is json:"-" so - // it can't be bound directly) and persists it atomically with the - // tournament: so the self-run fail-open guard never sees a self-run - // tournament without an admin credential. In locked mode the server - // ignores this field (the env-var bcrypt hash is authoritative). - if (isSelfRun && !locked && adminPass) { - config.adminPassword = adminPass; - } // In locked mode, the typed password IS the env-var admin // credential: pass it as authPassword so api_client sends // X-Tournament-Password. In file mode authPassword is undefined @@ -1894,23 +1871,6 @@ function CreateTournament({ onCreated, authConfig }) { // lets it through unauthenticated). const t = await window.API.createTournament(config, locked ? pass : undefined); if (!mountedRef.current) return; - // Refresh the elevated-password auth config so promptAdminPassword() - // reads the correct gate state before the admin view mounts (mp-7h7). - // For a self-run tournament the admin password is set atomically in the - // same POST, so the /api/auth-config response changes: elevatedRequired - // flips from false to true. Without this refresh, the stale cached value - // would cause promptAdminPassword() to return "" and destructive actions - // would 401 without ever prompting the operator. - try { - const freshCfg = await window.API.fetchAuthConfig(); - if (mountedRef.current && freshCfg && typeof freshCfg === "object") { - setCachedAuthConfig(freshCfg); - } - } catch (_) { - // Non-fatal: the cache update is best-effort; the operator can still - // retry destructive actions which will re-prompt after a 401. - } - if (!mountedRef.current) return; onCreated(t, pass); } catch (err) { if (mountedRef.current) { @@ -1997,8 +1957,8 @@ function CreateTournament({ onCreated, authConfig }) {
{mode === "officiated" - ? "An operator manages scoring and bracket progression. All admin actions require the tournament password. This is the standard setup." - : "No dedicated operator. Participants self-report scores; all constructive actions (scoring, check-in) are public. Destructive actions (delete competition, discard draw, import roster) still require a separate admin password. Cannot be changed after creation."} + ? "An operator manages scoring and bracket progression. All admin actions require the admin password. This is the standard setup." + : `No dedicated operator. Participants self-report scores. Scoring is public, and so are starting and completing a competition. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser setup, corrections and destructive actions such as deleting and importing need the admin password, and deleting a competition asks for it again. Cannot be changed after creation.`}
@@ -2014,34 +1974,14 @@ function CreateTournament({ onCreated, authConfig }) { />
{locked && isSelfRun - ? "This server is in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it authorises this bootstrap and organiser-setup mutations. Destructive actions (delete competition, discard draw, import) require the separate TOURNAMENT_ADMIN_PASSWORD_HASH credential." + ? "This server is in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it authorises this bootstrap and every organiser action afterwards." : locked ? "This server is running in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it's used both to authorize this bootstrap and as your admin credential afterwards." : isSelfRun - ? "Used to authorise tournament setup. In self-run mode, scoring and check-in are public so participants don't need this." + ? "Used to authorise tournament setup. In self-run mode, scoring is public so participants don't need this." : "This password will be required to manage the tournament."}
- {/* In self-run + file mode, require a destructive-ops password at - creation so delete/invalidate/import can't be called anonymously. - Locked mode uses TOURNAMENT_ADMIN_PASSWORD_HASH (no UI input). */} - {isSelfRun && !locked && ( -
- - setAdminPass(e.target.value)} - placeholder="Password to protect delete / import actions" - required - /> -
- A separate password required for destructive actions (delete competition, discard draw, roster add/edit, import). Since this tournament is self-run, scoring is public: this password is the only thing protecting irreversible actions. -
-
- )} {error && (
/reset; +// 403 reset_local_only otherwise), and the operator hardens production +// deployments by passing // --lock-password (mobile-app CLI) which 404s the reset endpoint AND // switches auth to a bcrypt env-var hash. // @@ -102,6 +105,10 @@ function ResetPasswordForm({ authConfig, onBack, onSuccess, originatorId }) { // flipped --lock-password while the SPA was open). if (e2.status === 404) { setErr("Password reset has been disabled by the operator."); + } else if (e2.code === "reset_local_only") { + // File mode answers only a browser on the machine running the + // server (handlers_reset.go); the same sentence the server sends. + setErr("Reset the password on the machine running the server."); } else { setErr(e2.message || "Failed to reset password. Please try again."); } @@ -113,7 +120,7 @@ function ResetPasswordForm({ authConfig, onBack, onSuccess, originatorId }) { return (
-

Reset tournament password

+

Reset admin password

Set a new admin password. The new password will replace the current one for everyone: any logged-in admins will need to sign in again. diff --git a/web-mobile/js/ui.jsx b/web-mobile/js/ui.jsx index 56c951bf3..d3e0e8700 100644 --- a/web-mobile/js/ui.jsx +++ b/web-mobile/js/ui.jsx @@ -253,7 +253,7 @@ function formatLabelShort(format) { // confirmDialog(opts)/promptDialog(opts) return a Promise resolved by the // user's choice, backed by a single mounted once at the app // root. They replace native window.confirm/window.prompt across the admin -// SPA so destructive/elevated prompts use the app's themed, accessible modal +// SPA so destructive confirms and prompts use the app's themed, accessible modal // (Escape to cancel, focus management, consistent styling) instead of browser // chrome that can't be styled, labelled, or (for prompt) mask a password. // @@ -300,7 +300,7 @@ function confirmDialog(opts = {}) { function promptDialog(opts = {}) { const o = typeof opts === "string" ? { message: opts } : (opts || {}); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { _setDialogReq({ kind: "prompt", title: o.title || "Enter a value", @@ -308,9 +308,16 @@ function promptDialog(opts = {}) { defaultValue: o.defaultValue != null ? String(o.defaultValue) : "", placeholder: o.placeholder || "", password: !!o.password, + danger: !!o.danger, + // submit(value) judges the typed value before the dialog closes. It + // resolves a string to keep the dialog open with that sentence shown (the + // operator types again), or nothing to accept the value; a rejection + // closes the dialog and rejects the promptDialog() promise with it. + submit: typeof o.submit === "function" ? o.submit : null, confirmLabel: o.confirmLabel || "OK", cancelLabel: o.cancelLabel || "Cancel", _resolve: resolve, + _reject: reject, }); }); } @@ -320,6 +327,10 @@ function promptDialog(opts = {}) { function DialogHost() { const [req, setReq] = React.useState(_dialogReq); const [value, setValue] = React.useState(""); + // A prompt with a `submit` judge: the sentence it refused with, and whether + // a judgement is out (the dialog neither closes nor submits twice meanwhile). + const [error, setError] = React.useState(""); + const [busy, setBusy] = React.useState(false); const inputRef = React.useRef(null); const triggerRef = React.useRef(null); const trapRef = React.useRef(null); @@ -327,7 +338,7 @@ function DialogHost() { const { openedRef, onClickCapture } = useOpenedTapGuard(); React.useEffect(() => { - const fn = (r) => { setReq(r); if (r && r.kind === "prompt") setValue(r.defaultValue || ""); }; + const fn = (r) => { setReq(r); setError(""); setBusy(false); if (r && r.kind === "prompt") setValue(r.defaultValue || ""); }; _dialogListeners.add(fn); // Pick up a request created before this host finished mounting. if (_dialogReq) fn(_dialogReq); @@ -387,18 +398,44 @@ function DialogHost() { } }, [openedRef]); - const close = (result) => { + const close = (result, failure) => { const r = req; setReq(null); + setBusy(false); if (_dialogReq && _dialogReq._resolve === (r && r._resolve)) _dialogReq = null; - if (r && r._resolve) r._resolve(result); + if (failure && r && r._reject) r._reject(failure); + else if (r && r._resolve) r._resolve(result); }; if (!req) return null; // req is guaranteed truthy past the guard, so no need to handle the null case. const cancelResult = req.kind === "confirm" ? false : null; - const onCancel = () => close(cancelResult); - const onConfirm = () => close(req.kind === "confirm" ? true : (value || null)); + // A judgement still out (a password being checked) is not cancelled: closing + // then would leave the operator unsure whether the action went through. + const onCancel = () => { if (!busy) close(cancelResult); }; + const onConfirm = async () => { + if (busy) return; + if (req.kind === "prompt" && req.submit) { + setBusy(true); + setError(""); + let refusal; + try { + refusal = await req.submit(value); + } catch (e) { + close(null, e); + return; + } + if (refusal) { + setBusy(false); + setError(String(refusal)); + if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } + return; + } + close(value || null); + return; + } + close(req.kind === "confirm" ? true : (value || null)); + }; // Escape-to-cancel is handled ON the dialog container (not via the global // useEscapeToClose window listener). The dialog frequently opens on top of a @@ -434,14 +471,16 @@ function DialogHost() { // fallback): otherwise screen readers announce a bare text field. aria-label={req.message || req.title} autoComplete={req.password ? "current-password" : "off"} - onChange={(e) => setValue(e.target.value)} + aria-invalid={error ? "true" : undefined} + onChange={(e) => { setValue(e.target.value); if (error) setError(""); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onConfirm(); } }} /> )} + {error &&

{error}
}
- - + +
diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx index fd556d5e3..074acbb67 100644 --- a/web-mobile/js/viewer_competition.jsx +++ b/web-mobile/js/viewer_competition.jsx @@ -81,7 +81,7 @@ export function ViewerCompetition({ tournament, competition, pools, poolMatches, // still feeds the "your next match" opponent-side logic. const [watchlist, setWatchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const compRoster = useMemo(() => buildRoster([c]), [c]); + const compRoster = useMemo(() => buildRoster([c], tournament), [c, tournament]); const rosterById = useMemo(() => new Map(compRoster.map((p) => [p.id, p])), [compRoster]); // Restrict the filter bar chips to entries that are relevant to THIS competition: // player entries only shown when the player is in compRoster; dojo entries only diff --git a/web-mobile/js/viewer_home.jsx b/web-mobile/js/viewer_home.jsx index c26b47f36..331e4dbe4 100644 --- a/web-mobile/js/viewer_home.jsx +++ b/web-mobile/js/viewer_home.jsx @@ -127,7 +127,7 @@ export function ViewerHome({ tournament, onSelectCompetition, onAdminClick, onOp // single "followed player" is migrated into the list once (see useWatchlist). const [watchlist, setWatchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const roster = useMemo(() => buildRoster(t.competitions), [t.competitions]); + const roster = useMemo(() => buildRoster(t.competitions, t), [t]); // Derived beside the roster because it qualifies it: a non-empty roster does // not mean every competition's participants loaded, and only the panel's // "not in this tournament" claim depends on the difference. diff --git a/web-mobile/js/viewer_schedule.jsx b/web-mobile/js/viewer_schedule.jsx index 1adee8d2a..9f0590795 100644 --- a/web-mobile/js/viewer_schedule.jsx +++ b/web-mobile/js/viewer_schedule.jsx @@ -164,7 +164,7 @@ export function PlayerMultiFilter({ tournament, picked, setPicked, dojoText, set // the app already names for the operator (helper.MissingParticipantIDsMessage // tells them to save the roster once), and one bogus row standing in for // every id-less competitor was not a better answer than none. - const roster = useMemo(() => buildRoster(tournament.competitions), [tournament]); + const roster = useMemo(() => buildRoster(tournament.competitions, tournament), [tournament]); const q = query.trim().toLowerCase(); // Memoised on the two things it reads, which saves the roster scan on the @@ -408,7 +408,7 @@ export function ScheduleViewer({ tournament, tweaks }) { // fight the user's edits). const [watchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const schedRoster = useMemo(() => buildRoster(tournament.competitions), [tournament]); + const schedRoster = useMemo(() => buildRoster(tournament.competitions, tournament), [tournament]); const initialPicked = useMemo(() => { return resolveWatchedPlayers(watchlist, schedRoster).map((p) => ({ id: p.id, name: p.name || "", dojo: p.dojo || "" })); }, [watchlist, schedRoster]); diff --git a/web-mobile/js/viewer_watchlist_core.jsx b/web-mobile/js/viewer_watchlist_core.jsx index b55ae1604..d02082582 100644 --- a/web-mobile/js/viewer_watchlist_core.jsx +++ b/web-mobile/js/viewer_watchlist_core.jsx @@ -21,6 +21,7 @@ import { competitorKey } from './competitor_identity.jsx'; import { resultRecencyDesc } from './result_recency.jsx'; import { parseWatchlistTokens, resolveFreshTokens, WATCHLIST_PARAM } from './watchlist_link.jsx'; import { isBarredMatch } from './ineligible_match.jsx'; +import { checkInApplies } from './competition_shape.jsx'; const { useState } = React; @@ -622,12 +623,14 @@ export function rosterFullyLoaded(competitions) { } // checkedIn=true wins if any check-in-enabled competition has the player checked in. -export function buildRoster(competitions) { +// `tournament` (optional) is asked whether check-in applies at all: a self-run +// tournament has none, so nobody in its roster is ever checked in. +export function buildRoster(competitions, tournament) { const map = new Map(); (competitions || []).forEach((c) => { (c.players || []).forEach((p) => { if (!p || !p.id) return; - const checkedIn = !!c.checkInEnabled && !!p.checkedIn; + const checkedIn = checkInApplies(tournament, c.checkInEnabled) && !!p.checkedIn; const existing = map.get(p.id); if (!existing) { // `comps` is the competition names this record covers, for the