Files
scrabble-game/backend/internal/inttest/link_change_test.go
T
Ilia Denisov 912096a0f1 test(account): unlink guard + change-email replace/refuse/deeplink
Integration: unlinking a provider keeps the other identities and refuses removing
the last one; change-email replaces the address (freeing the old), refuses a taken
address without merging, and works through the one-tap deeplink token. Unit: the
change-email template renders localised ru/en copy.
2026-07-03 09:59:42 +02:00

175 lines
6.2 KiB
Go

//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)
}
}