//go:build integration package inttest import ( "context" "errors" "testing" "github.com/google/uuid" "scrabble/backend/internal/account" ) // emailOf returns the external id of the account's email identity, or "" when it has none. func emailOf(t *testing.T, store *account.Store, id uuid.UUID) string { t.Helper() ids, err := store.Identities(context.Background(), id) if err != nil { t.Fatalf("identities %s: %v", id, err) } for _, i := range ids { if i.Kind == account.KindEmail { return i.ExternalID } } return "" } // TestUnlinkProviderKeepsOthers detaches one provider from a multi-identity account and // refuses to remove the last remaining identity. func TestUnlinkProviderKeepsOthers(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision telegram: %v", err) } email := "unlink-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { t.Fatalf("attach email: %v", err) } // Removing a kind the account does not hold reports not-found. if err := store.RemoveIdentity(ctx, acc.ID, account.KindVK); !errors.Is(err, account.ErrNotFound) { t.Fatalf("remove absent vk = %v, want ErrNotFound", err) } // Detach Telegram: the email identity remains. if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { t.Fatalf("remove telegram: %v", err) } ids, err := store.Identities(ctx, acc.ID) if err != nil { t.Fatalf("identities: %v", err) } if len(ids) != 1 || ids[0].Kind != account.KindEmail { t.Fatalf("identities after unlink = %+v, want only email", ids) } // The email is now the last identity, so unlinking it is refused. if err := store.RemoveIdentity(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrLastIdentity) { t.Fatalf("remove last email = %v, want ErrLastIdentity", err) } } // TestChangeEmailReplaces switches an account's confirmed email to a free address, // freeing the old one. func TestChangeEmailReplaces(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) } oldAddr := "old-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil { t.Fatalf("attach old email: %v", err) } newAddr := "new-" + uuid.NewString() + "@example.com" if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil { t.Fatalf("request change: %v", err) } code := sixDigit.FindString(mailer.lastBody) if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, code); err != nil { t.Fatalf("confirm change: %v", err) } if got := emailOf(t, store, acc.ID); got != newAddr { t.Fatalf("email after change = %q, want %q", got, newAddr) } if !identityConfirmed(t, account.KindEmail, newAddr) { t.Error("the new email identity must be confirmed") } // The old address is freed: another account can now claim it. other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision other: %v", err) } if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, oldAddr, true); err != nil { t.Fatalf("old address should be free after change, got: %v", err) } } // TestChangeEmailRefusesTaken refuses (without merging) a new address already confirmed by // another account, leaving the caller's email untouched. func TestChangeEmailRefusesTaken(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 caller: %v", err) } mine := "mine-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, mine, true); err != nil { t.Fatalf("attach caller email: %v", err) } other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision other: %v", err) } taken := "taken-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, taken, true); err != nil { t.Fatalf("attach other email: %v", err) } if err := svc.RequestChangeCode(ctx, acc.ID, taken); err != nil { t.Fatalf("request change: %v", err) } code := sixDigit.FindString(mailer.lastBody) if _, err := svc.ConfirmChange(ctx, acc.ID, taken, code); !errors.Is(err, account.ErrEmailTaken) { t.Fatalf("confirm change to taken = %v, want ErrEmailTaken", err) } if got := emailOf(t, store, acc.ID); got != mine { t.Errorf("caller email after refused change = %q, want unchanged %q", got, mine) } } // TestChangeEmailViaDeeplink switches the email through the one-tap confirm token. func TestChangeEmailViaDeeplink(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, "old-"+uuid.NewString()+"@example.com", true); err != nil { t.Fatalf("attach old email: %v", err) } newAddr := "dl-" + uuid.NewString() + "@example.com" if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil { t.Fatalf("request change: %v", err) } res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)) if err != nil { t.Fatalf("confirm by token: %v", err) } if res.IsLogin() || res.NeedsMerge || res.Account != acc.ID { t.Fatalf("deeplink change result = %+v, want a plain change for %s", res, acc.ID) } if got := emailOf(t, store, acc.ID); got != newAddr { t.Fatalf("email after deeplink change = %q, want %q", got, newAddr) } }