9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.
The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
437 lines
15 KiB
Go
437 lines
15 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/accountdelete"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/server"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
|
|
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
|
|
t.Helper()
|
|
var dn sql.NullString
|
|
err := testDB.QueryRowContext(context.Background(),
|
|
"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
|
|
Scan(&dn, &deletedAt)
|
|
if err != nil {
|
|
t.Fatalf("read deleted fields %s: %v", accountID, err)
|
|
}
|
|
return dn.String, deletedAt
|
|
}
|
|
|
|
// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
|
|
// account, scrubs the live name while retaining the real one, and frees the creds for a
|
|
// new account to reuse.
|
|
func TestAnonymizeAndTombstone(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
email := "del-" + uuid.NewString() + "@example.com"
|
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
|
t.Fatalf("attach email: %v", err)
|
|
}
|
|
before, err := store.GetByID(ctx, acc.ID)
|
|
if err != nil {
|
|
t.Fatalf("load before: %v", err)
|
|
}
|
|
|
|
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
// The live identities are gone.
|
|
if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
|
|
t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
|
|
}
|
|
// Both credentials are journalled with reason=delete.
|
|
got := retainedRows(t, acc.ID)
|
|
if len(got) != 2 {
|
|
t.Fatalf("retained rows = %+v, want 2", got)
|
|
}
|
|
for _, r := range got {
|
|
if r.reason != "delete" {
|
|
t.Errorf("retained reason = %q, want delete", r.reason)
|
|
}
|
|
}
|
|
// The live name is scrubbed; the real one is retained; deleted_at is set.
|
|
after, err := store.GetByID(ctx, acc.ID)
|
|
if err != nil {
|
|
t.Fatalf("load after: %v", err)
|
|
}
|
|
if after.DisplayName != accountdelete.AnonymizedName {
|
|
t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
|
|
}
|
|
name, deletedAt := deletedFields(t, acc.ID)
|
|
if name != before.DisplayName {
|
|
t.Errorf("retained name = %q, want %q", name, before.DisplayName)
|
|
}
|
|
if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
|
|
t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
|
|
}
|
|
// The credentials are free: a new account can claim the same email.
|
|
other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
|
|
if err != nil {
|
|
t.Fatalf("provision other: %v", err)
|
|
}
|
|
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
|
|
t.Fatalf("email should be free after deletion, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
|
|
// journal and the tombstone dossier.
|
|
func TestDeletionDossierReaders(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
|
|
t.Fatalf("attach email: %v", err)
|
|
}
|
|
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
rets, err := store.RetainedIdentities(ctx, acc.ID)
|
|
if err != nil || len(rets) != 2 {
|
|
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
|
|
}
|
|
for _, r := range rets {
|
|
if r.Reason != "delete" {
|
|
t.Errorf("retained reason = %q, want delete", r.Reason)
|
|
}
|
|
}
|
|
info, err := store.DeletionInfo(ctx, acc.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeletionInfo: %v", err)
|
|
}
|
|
if info.DeletedAt == nil {
|
|
t.Error("DeletionInfo.DeletedAt should be set")
|
|
}
|
|
if info.DeletedDisplayName != "Иван" {
|
|
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
|
|
}
|
|
}
|
|
|
|
// listHasID reports whether the user list contains accountID.
|
|
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
|
|
for _, it := range items {
|
|
if it.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
|
|
// shown only under the Deleted scope.
|
|
func TestUserListDeletedFilter(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
|
|
if err != nil {
|
|
t.Fatalf("provision live: %v", err)
|
|
}
|
|
goneTg := "tg-" + uuid.NewString()
|
|
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
|
|
if err != nil {
|
|
t.Fatalf("provision gone: %v", err)
|
|
}
|
|
goneEmail := "gone-" + uuid.NewString() + "@example.com"
|
|
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
|
|
t.Fatalf("attach gone email: %v", err)
|
|
}
|
|
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
|
|
if err != nil {
|
|
t.Fatalf("list people: %v", err)
|
|
}
|
|
if listHasID(people, gone.ID) {
|
|
t.Error("a deleted account must not appear in the default People list")
|
|
}
|
|
if !listHasID(people, live.ID) {
|
|
t.Error("a live account must appear in the default People list")
|
|
}
|
|
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
|
|
if err != nil {
|
|
t.Fatalf("list deleted: %v", err)
|
|
}
|
|
if !listHasID(deleted, gone.ID) {
|
|
t.Error("a deleted account must appear in the Deleted list")
|
|
}
|
|
if listHasID(deleted, live.ID) {
|
|
t.Error("a live account must not appear in the Deleted list")
|
|
}
|
|
|
|
// A search spans both lists and reaches the retention journal: a deleted account is
|
|
// still found by the email and external id it held (both moved to retained_identities
|
|
// on deletion, out of the live identities table).
|
|
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
|
|
if err != nil {
|
|
t.Fatalf("search by email: %v", err)
|
|
}
|
|
if !listHasID(byEmail, gone.ID) {
|
|
t.Error("a deleted account must be found by the email it held (retention journal)")
|
|
}
|
|
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
|
|
if err != nil {
|
|
t.Fatalf("search by external id: %v", err)
|
|
}
|
|
if !listHasID(byExt, gone.ID) {
|
|
t.Error("a deleted account must be found by the external id it held (retention journal)")
|
|
}
|
|
}
|
|
|
|
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
|
|
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
|
|
func TestConfirmCodeClearsGuest(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
mailer := &capturingMailer{}
|
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
|
|
|
guest, err := store.ProvisionGuest(ctx, "", "")
|
|
if err != nil {
|
|
t.Fatalf("provision guest: %v", err)
|
|
}
|
|
email := "cc-" + uuid.NewString() + "@example.com"
|
|
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
|
|
t.Fatalf("request code: %v", err)
|
|
}
|
|
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
|
t.Fatalf("confirm code: %v", err)
|
|
}
|
|
after, err := store.GetByID(ctx, guest.ID)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if after.IsGuest {
|
|
t.Error("confirming an email must clear the guest flag")
|
|
}
|
|
}
|
|
|
|
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
|
// opponent.
|
|
func TestDropAllRobotGames(t *testing.T) {
|
|
ctx := context.Background()
|
|
gsvc := newGameService()
|
|
robots := newRobotService(t, gsvc)
|
|
if err := robots.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool: %v", err)
|
|
}
|
|
mm := newMatchmaker(t, robots, time.Minute, 0)
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
user := provisionAccount(t)
|
|
other := provisionAccount(t)
|
|
|
|
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
|
|
if err != nil {
|
|
t.Fatalf("start vs AI: %v", err)
|
|
}
|
|
humanGame, err := gsvc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create human game: %v", err)
|
|
}
|
|
|
|
n, err := deleter.DropAllRobotGames(ctx, user)
|
|
if err != nil {
|
|
t.Fatalf("drop: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
|
|
}
|
|
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
|
|
t.Error("the vs-AI game should be dropped")
|
|
}
|
|
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
|
|
t.Errorf("the human game should be kept, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a
|
|
// friend request nor a block may target it — the client hides both controls on a deleted
|
|
// seat, and the server refuses them for an older client. The seat's deleted mark that drives
|
|
// that hiding is resolved by Store.DeletedIDs.
|
|
func TestDeletedAccountRefusesSocialActions(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
svc := newSocialService()
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
_, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate
|
|
viewer, gone := seats[0], seats[1]
|
|
|
|
if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil {
|
|
t.Fatalf("friend request before deletion = %v, want nil", err)
|
|
}
|
|
if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
// Deletion dropped the pending request with the rest of the account's social rows, so
|
|
// this is a fresh request against the tombstone — exactly what an older client sends
|
|
// after the 🤝 reappears on the finished game's scoreboard.
|
|
if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
|
|
t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err)
|
|
}
|
|
if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
|
|
t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err)
|
|
}
|
|
// The live opponent stays actionable — the guard is per-account, not a blanket switch.
|
|
live := provisionAccount(t)
|
|
if err := svc.Block(ctx, viewer, live); err != nil {
|
|
t.Errorf("block of a live account = %v, want nil", err)
|
|
}
|
|
|
|
deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone})
|
|
if err != nil {
|
|
t.Fatalf("deleted ids: %v", err)
|
|
}
|
|
if !deleted[gone] {
|
|
t.Error("the deleted account must be marked deleted")
|
|
}
|
|
if deleted[viewer] {
|
|
t.Error("a live account must not be marked deleted")
|
|
}
|
|
}
|
|
|
|
// TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted
|
|
// opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge)
|
|
// on the client; the viewer's own live seat stays unmarked.
|
|
func TestGameStateMarksDeletedSeat(t *testing.T) {
|
|
ctx := context.Background()
|
|
games := newGameService()
|
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
|
g, err := games.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create game: %v", err)
|
|
}
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zaptest.NewLogger(t),
|
|
DB: testDB,
|
|
Accounts: account.NewStore(testDB),
|
|
Games: games,
|
|
})
|
|
viewer, gone := seats[0], seats[1]
|
|
|
|
if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] {
|
|
t.Fatalf("before deletion no seat may be marked deleted, got %v", marks)
|
|
}
|
|
if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
marks := stateSeatDeleted(t, srv, g.ID, viewer)
|
|
if marks[0] {
|
|
t.Error("the viewer's own live seat must not be marked deleted")
|
|
}
|
|
if !marks[1] {
|
|
t.Error("the deleted opponent's seat must be marked deleted")
|
|
}
|
|
}
|
|
|
|
// stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed
|
|
// by seat.
|
|
func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool {
|
|
t.Helper()
|
|
rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Game struct {
|
|
Seats []struct {
|
|
Seat int `json:"seat"`
|
|
Deleted bool `json:"deleted"`
|
|
} `json:"seats"`
|
|
} `json:"game"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode game state: %v", err)
|
|
}
|
|
out := map[int]bool{}
|
|
for _, s := range body.Game.Seats {
|
|
out[s.Seat] = s.Deleted
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
|
|
// deeplink in the mail); a platform-only account has no email and cannot request a code.
|
|
func TestDeleteStepUpEmail(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
mailer := &capturingMailer{}
|
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
|
|
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
|
|
t.Fatalf("attach email: %v", err)
|
|
}
|
|
if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
|
|
t.Fatalf("HasEmail = (%v, %v), want true", has, err)
|
|
}
|
|
if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
|
|
t.Fatalf("request delete code: %v", err)
|
|
}
|
|
if strings.Contains(mailer.lastBody, "/confirm/") {
|
|
t.Error("a delete email must not carry a one-tap deeplink")
|
|
}
|
|
code := sixDigit.FindString(mailer.lastBody)
|
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
|
|
t.Error("a wrong delete code must be rejected")
|
|
}
|
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
|
|
t.Fatalf("verify correct delete code: %v", err)
|
|
}
|
|
|
|
noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision no-email: %v", err)
|
|
}
|
|
if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
|
|
t.Error("HasEmail must be false for a platform-only account")
|
|
}
|
|
if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
|
|
t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
|
|
}
|
|
}
|