From 912096a0f1791cff13935fb37ad3d8fb9e4e04ee Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 09:59:42 +0200 Subject: [PATCH] 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. --- .../internal/account/emailtemplate_test.go | 2 + backend/internal/inttest/link_change_test.go | 174 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 backend/internal/inttest/link_change_test.go diff --git a/backend/internal/account/emailtemplate_test.go b/backend/internal/account/emailtemplate_test.go index f648528..95d0ba0 100644 --- a/backend/internal/account/emailtemplate_test.go +++ b/backend/internal/account/emailtemplate_test.go @@ -16,6 +16,8 @@ func TestRenderConfirmationEmail(t *testing.T) { {"login en", purposeLogin, "en", "sign-in"}, {"link ru", purposeLink, "ru", "подтвержд"}, {"link en", purposeLink, "en", "confirmation"}, + {"change ru", purposeChange, "ru", "новый"}, + {"change en", purposeChange, "en", "new"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/backend/internal/inttest/link_change_test.go b/backend/internal/inttest/link_change_test.go new file mode 100644 index 0000000..d3d8c0b --- /dev/null +++ b/backend/internal/inttest/link_change_test.go @@ -0,0 +1,174 @@ +//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) + } +}