v0.13.3 account delete api, web client and toolchain update
This commit is contained in:
1 parent
cc03dc0294
commit
ffbf603bc8
16 files changed
+672
-57
No files matched your search
@@ -284,6 +284,37 @@ func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueWait queues fn on the ordered writer and waits for its own outcome,
|
||||
// for a write that must be ordered after queued gameplay writes and must also
|
||||
// report success only once it is durable.
|
||||
func (s *Store) enqueueWait(ctx context.Context, operation string, fn func(context.Context, *sql.Tx) error) error {
|
||||
s.enqueueMu.RLock()
|
||||
if s.closed.Load() {
|
||||
s.enqueueMu.RUnlock()
|
||||
return ErrStoreClosed
|
||||
}
|
||||
if !s.healthStatus.Load() {
|
||||
s.enqueueMu.RUnlock()
|
||||
return ErrStorageDegraded
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
select {
|
||||
case s.writeChan <- writeRequest{operation: operation, run: fn, barrier: done}:
|
||||
s.enqueueMu.RUnlock()
|
||||
case <-ctx.Done():
|
||||
s.enqueueMu.RUnlock()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Flush waits until every write queued before this call has completed. Replay
|
||||
// reads use this barrier to provide read-after-write consistency while normal
|
||||
// gameplay retains the low-latency async write path.
|
||||
|
||||
@@ -837,6 +837,75 @@ func TestClaimsSnapshotPlayerNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountClearsClaimsAndNames(t *testing.T) {
|
||||
store := openStore(t, pgtest.DSN(t))
|
||||
now := time.Now().UTC()
|
||||
alice, bob := uuid.NewString(), uuid.NewString()
|
||||
for id, name := range map[string]string{alice: "alice", bob: "bob"} {
|
||||
if err := store.CreateUser(UserRecord{UserID: id, Username: name, PasswordHash: "hash", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := store.CreateSession(SessionRecord{
|
||||
SessionID: uuid.NewString(), UserID: alice, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Alice against Bob, and Alice alone against the computer.
|
||||
shared, solo := uuid.NewString(), uuid.NewString()
|
||||
for _, record := range []GameRecord{
|
||||
{GameID: shared, InitialFEN: "initial",
|
||||
WhitePlayerID: alice, WhiteType: 1, WhiteClaimedBy: alice,
|
||||
BlackPlayerID: bob, BlackType: 1, BlackClaimedBy: bob, StartTimeUTC: now},
|
||||
{GameID: solo, InitialFEN: "initial",
|
||||
WhitePlayerID: uuid.NewString(), WhiteType: 2,
|
||||
BlackPlayerID: alice, BlackType: 1, BlackClaimedBy: alice, StartTimeUTC: now},
|
||||
} {
|
||||
if err := store.RecordNewGame(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.DeleteAccount(alice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GetUserByID(alice); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("account still present: %v", err)
|
||||
}
|
||||
var sessions int
|
||||
if err := store.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, alice).Scan(&sessions); err != nil || sessions != 0 {
|
||||
t.Fatalf("sessions left: %d, %v", sessions, err)
|
||||
}
|
||||
|
||||
record, _, err := store.GetGameHistory(shared)
|
||||
if err != nil || record.WhiteClaimedBy != "" || record.WhiteName != "" ||
|
||||
record.BlackClaimedBy != bob || record.BlackName != "bob" {
|
||||
t.Fatalf("shared game after deletion: white %q/%q black %q/%q, %v",
|
||||
record.WhiteClaimedBy, record.WhiteName, record.BlackClaimedBy, record.BlackName, err)
|
||||
}
|
||||
// Nobody claims the solo game now, so the inactivity purge takes it.
|
||||
if err := store.DeleteAnonymousGames(now.Add(time.Minute), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Flush(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := store.GetGameHistory(solo); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("solo game survived the anonymous purge: %v", err)
|
||||
}
|
||||
if _, _, err := store.GetGameHistory(shared); err != nil {
|
||||
t.Fatalf("a game another user claims must stay: %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteAccount(alice); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("second deletion: %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
if !store.IsHealthy() {
|
||||
t.Fatal("deleting a missing account degraded the writer")
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -124,6 +125,52 @@ func (s *Store) DeleteUser(userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccount removes an account at its owner's request. Unlike DeleteUser,
|
||||
// which keeps the claim IDs and name snapshots for the record, it first clears
|
||||
// the user's claims and names from every stored game, so nothing in the
|
||||
// database still names the person, and a game nobody else claimed becomes
|
||||
// anonymous and falls to the inactivity purge. Sessions cascade.
|
||||
//
|
||||
// It runs on the ordered game writer, behind every gameplay write already
|
||||
// queued, so a queued claim cannot land on a game after it was cleared. It
|
||||
// returns sql.ErrNoRows when no such user exists.
|
||||
func (s *Store) DeleteAccount(userID string) error {
|
||||
if !validUUID(userID) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
// Set inside the transaction, read after the writer reports back: the
|
||||
// barrier channel orders the two. A missing row is an answer, not a
|
||||
// failed write, so it must not degrade the writer.
|
||||
var deleted int64
|
||||
// Behind the queue, then its own transaction.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
|
||||
defer cancel()
|
||||
err := s.enqueueWait(ctx, "delete_account", func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE games SET
|
||||
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
|
||||
white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END,
|
||||
black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END,
|
||||
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END
|
||||
WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err = result.RowsAffected()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
slog.Debug("storage account deleted", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates user password hash
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
|
||||
|
||||
Reference in new issue
Block a user