store: users saved in one transaction, the last global kept inside it (audit M7, M9)

M7: DeleteUser counted the global users and then deleted; the handler's
demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1)
serialises statements, not pairs: two globals deleting or demoting each
other at once could leave none. Now:
- DeleteUser: a transaction that deletes first (taking the write lock) and
  then requires a global left (requireAGlobal) - ErrLastGlobal rolls back.
- SaveUser: likewise for a demotion, the count inside the transaction after
  the write. The handler's early count stays only to answer before hashing a
  password; SaveUser decides, and ErrLastGlobal maps to the same message.
- CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM
  users), one statement.

M9: submitUserUpdate made five writes in a row (UpdateUser, sessions,
SetUserRole, ClearUserReach with its error only logged, SetUserReach).
store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one
transaction (a global user's reach cleared); sessions end after it commits.
The create path had the same flaw - user, reach and then e-mail in three
writes - and now goes through AddUser, one transaction; CreateUser stays as
a wrapper for its many test callers. SetUserRole is gone; writeUserReach is
the reach write inside a transaction.

Tests: store - two globals demoting, and deleting, each other from two
goroutines: exactly one refused, one global left; a refused SaveUser (taken
name, a reach to no domain, nothing assigned) changes nothing; promoted to
global loses the reach; AddUser with a bad reach leaves no user; four setups
at once create one user. Handlers (the users pages, direct): the whole
change saved, back to domain with a reach; the last global not demoted and
the typed rename not written; a domain user with nothing assigned refused,
nothing changed; create with e-mail and reach.

CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62.
Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of
./internal/web/handlers ok; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 15:25:41 +03:00
co-authored by Claude Opus 5.5
parent 60c7b85f84
commit f9e75de8d1
7 changed files with 420 additions and 106 deletions
+2 -2
View File
@@ -663,10 +663,10 @@ has already run is never edited — the next file is added; the published
installation; look). Tests: `Ops`/`ops` refused; the map written lower-
case. Then verify Postfix's compare on the stand once (`MAIL FROM` as the
other case) so the doc's claim is seen, not assumed. — **Opus**
- [ ] **Last global in one transaction**: `DeleteUser`/`SetUserRole` take a
- [x] **Last global in one transaction**: `DeleteUser`/`SetUserRole` take a
`tx`, count and act inside it; `CreateGlobalUser` likewise. A concurrency
test with two goroutines. — **Opus**
- [ ] **`submitUserUpdate` in one transaction** (store method that takes the
- [x] **`submitUserUpdate` in one transaction** (store method that takes the
whole change). The users pages get direct handler tests while they are
rewired - a row of review-2026-08-followups, placed here 2026-10-14. — **Opus**
- [ ] **Import validates like the form**: route `DMARCRua`, IPs, limit mode,