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.
This commit is contained in:
@@ -16,6 +16,8 @@ func TestRenderConfirmationEmail(t *testing.T) {
|
|||||||
{"login en", purposeLogin, "en", "sign-in"},
|
{"login en", purposeLogin, "en", "sign-in"},
|
||||||
{"link ru", purposeLink, "ru", "подтвержд"},
|
{"link ru", purposeLink, "ru", "подтвержд"},
|
||||||
{"link en", purposeLink, "en", "confirmation"},
|
{"link en", purposeLink, "en", "confirmation"},
|
||||||
|
{"change ru", purposeChange, "ru", "новый"},
|
||||||
|
{"change en", purposeChange, "en", "new"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user