package auth import ( "context" "errors" "testing" "time" "github.com/google/uuid" ) type fakeEmailSender struct { messages []EmailMessage } func (s *fakeEmailSender) SendLoginCode(_ context.Context, message EmailMessage) error { s.messages = append(s.messages, message) return nil } type fakeHumanVerifier struct { tokens []string err error } func (v *fakeHumanVerifier) Verify(_ context.Context, token string) error { v.tokens = append(v.tokens, token) return v.err } func TestChinaSMSLoginCreatesUUIDUserAndBearerToken(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionChina, TokenSecret: "test-secret", TokenTTL: time.Hour, CodeTTL: time.Minute, DevReturnCode: true, }) code, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "") if err != nil { t.Fatal(err) } session, err := service.LoginChinaSMSCode(ctx, "13800138000", "+86", code.DevCode) if err != nil { t.Fatal(err) } if _, err := uuid.Parse(session.User.ID); err != nil { t.Fatalf("expected uuid user id, got %q", session.User.ID) } if session.User.Region != RegionChina { t.Fatalf("expected china user, got %#v", session.User) } userID, ok := service.UserIDFromBearer(ctx, "Bearer "+session.AccessToken) if !ok || userID != session.User.ID { t.Fatalf("expected bearer token to resolve %s, got %s / %v", session.User.ID, userID, ok) } } func TestManualRegionRejectsOtherRegionLogin(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: time.Minute, DevReturnCode: true, }) _, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "") if !errors.Is(err, ErrInvalidRegion) { t.Fatalf("expected invalid region, got %v", err) } } func TestGlobalEmailCodeLogin(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: time.Minute, DevReturnCode: true, }) code, err := service.RequestGlobalEmailCode(ctx, "USER@example.com", "") if err != nil { t.Fatal(err) } session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode) if err != nil { t.Fatal(err) } if session.User.Email != "user@example.com" || session.User.Region != RegionGlobal { t.Fatalf("unexpected session user %#v", session.User) } } func TestAccountProfileUpdatesPersistInStore(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: time.Minute, DevReturnCode: true, }) code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "") if err != nil { t.Fatal(err) } session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode) if err != nil { t.Fatal(err) } name := "Liang Xu" avatarURL := "https://cdn.example.com/avatar.webp" user, err := service.UpdateProfile(ctx, session.User.ID, ProfilePatch{Name: &name, AvatarURL: &avatarURL}) if err != nil { t.Fatal(err) } if user.Name != name || user.AvatarURL != avatarURL { t.Fatalf("unexpected profile %#v", user) } profile, err := service.GetProfile(ctx, session.User.ID) if err != nil { t.Fatal(err) } if profile.Name != name || profile.AvatarURL != avatarURL { t.Fatalf("profile was not persisted %#v", profile) } } func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: time.Minute, DevReturnCode: true, }) code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "") if err != nil { t.Fatal(err) } session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode) if err != nil { t.Fatal(err) } devices, err := service.ListDevices(ctx, session.User.ID) if err != nil { t.Fatal(err) } if len(devices) != 1 || !devices[0].Current { t.Fatalf("expected one current device, got %#v", devices) } removed, err := service.RemoveOtherDevices(ctx, session.User.ID) if err != nil { t.Fatal(err) } if removed != 0 { t.Fatalf("expected no removable devices, got %d", removed) } } func TestGlobalEmailCodeSendsConfiguredEmail(t *testing.T) { ctx := context.Background() sender := &fakeEmailSender{} service := NewServiceWithCodeStoreAndEmail(NewMemoryStore(), NewMemoryStore(), sender, Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: 10 * time.Minute, DevReturnCode: true, EmailFromName: "Moteva Login", EmailFromEmail: "hello@example.com", BrandName: "Moteva", }) code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "") if err != nil { t.Fatal(err) } if code.ExpiresIn != 600 { t.Fatalf("expected 10 minute ttl, got %d", code.ExpiresIn) } if len(sender.messages) != 1 { t.Fatalf("expected one email, got %d", len(sender.messages)) } message := sender.messages[0] if message.FromEmail != "hello@example.com" || message.ToEmail != "user@example.com" || message.Code != code.DevCode { t.Fatalf("unexpected email message %#v", message) } if code.Email == nil || code.Email.Subject != "Welcome to Moteva!" { t.Fatalf("expected dev email preview, got %#v", code.Email) } } func TestGlobalEmailCodeRequiresSenderOutsideDevMode(t *testing.T) { ctx := context.Background() service := NewService(NewMemoryStore(), Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: 10 * time.Minute, DevReturnCode: false, }) _, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "") if !errors.Is(err, ErrProviderNotReady) { t.Fatalf("expected provider not ready, got %v", err) } } func TestGlobalEmailCodeRequiresHumanVerificationBeforeEmail(t *testing.T) { ctx := context.Background() sender := &fakeEmailSender{} verifier := &fakeHumanVerifier{} service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: 10 * time.Minute, DevReturnCode: true, TurnstileRequired: true, }) _, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "") if !errors.Is(err, ErrHumanVerificationRequired) { t.Fatalf("expected human verification required, got %v", err) } if len(sender.messages) != 0 { t.Fatalf("expected no email before human verification, got %d", len(sender.messages)) } code, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "turnstile-token") if err != nil { t.Fatal(err) } if code.DevCode == "" || len(sender.messages) != 1 { t.Fatalf("expected verified email code, got code=%q messages=%d", code.DevCode, len(sender.messages)) } if len(verifier.tokens) != 1 || verifier.tokens[0] != "turnstile-token" { t.Fatalf("expected verifier token, got %#v", verifier.tokens) } } func TestGlobalEmailCodeStopsWhenHumanVerificationFails(t *testing.T) { ctx := context.Background() sender := &fakeEmailSender{} verifier := &fakeHumanVerifier{err: ErrHumanVerificationInvalid} service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{ Region: RegionGlobal, TokenSecret: "test-secret", CodeTTL: 10 * time.Minute, DevReturnCode: true, TurnstileRequired: true, }) _, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "bad-token") if !errors.Is(err, ErrHumanVerificationInvalid) { t.Fatalf("expected human verification invalid, got %v", err) } if len(sender.messages) != 0 { t.Fatalf("expected no email after failed human verification, got %d", len(sender.messages)) } } func TestChinaWechatLoginWithBoundPhoneAuthenticatesDirectly(t *testing.T) { ctx := context.Background() store := NewMemoryStore() identity := WechatIdentity{OpenID: "wechat-open-1", UnionID: "wechat-union-1"} if _, err := store.UpsertUser(ctx, User{ ID: uuid.NewString(), Region: RegionChina, Phone: "13800138000", CountryCode: "+86", WechatOpenID: identity.OpenID, WechatUnionID: identity.UnionID, Status: "active", }); err != nil { t.Fatal(err) } service := NewService(store, Config{ Region: RegionChina, TokenSecret: "test-secret", TokenTTL: time.Hour, CodeTTL: time.Minute, }) service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) { return identity, nil } state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now()) if err != nil { t.Fatal(err) } session, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "") if err != nil { t.Fatal(err) } if session.Status != SessionStatusAuthenticated || session.AccessToken == "" { t.Fatalf("expected authenticated session, got %#v", session) } if session.User.Phone != "13800138000" || session.User.WechatOpenID != identity.OpenID { t.Fatalf("unexpected session user %#v", session.User) } } func TestChinaWechatFirstLoginRequiresPhoneBindingThenFutureLoginIsDirect(t *testing.T) { ctx := context.Background() identity := WechatIdentity{OpenID: "wechat-open-2", UnionID: "wechat-union-2"} service := NewService(NewMemoryStore(), Config{ Region: RegionChina, TokenSecret: "test-secret", TokenTTL: time.Hour, CodeTTL: time.Minute, DevReturnCode: true, }) service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) { return identity, nil } state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now()) if err != nil { t.Fatal(err) } pending, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "") if err != nil { t.Fatal(err) } if pending.Status != SessionStatusPhoneBindingRequired { t.Fatalf("expected phone binding state, got %#v", pending) } if pending.AccessToken != "" || pending.BindingToken == "" || pending.BindingExpiresIn == 0 { t.Fatalf("unexpected pending session %#v", pending) } code, err := service.RequestChinaSMSCode(ctx, "13800138001", "+86", PurposeWechatBind) if err != nil { t.Fatal(err) } bound, err := service.LoginChinaWechat(ctx, "", "", pending.BindingToken, "13800138001", "+86", code.DevCode) if err != nil { t.Fatal(err) } if bound.Status != SessionStatusAuthenticated || bound.AccessToken == "" { t.Fatalf("expected authenticated bound session, got %#v", bound) } if bound.User.Phone != "13800138001" || bound.User.WechatUnionID != identity.UnionID { t.Fatalf("unexpected bound user %#v", bound.User) } nextState, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now()) if err != nil { t.Fatal(err) } direct, err := service.LoginChinaWechat(ctx, "qr-code-again", nextState, "", "", "", "") if err != nil { t.Fatal(err) } if direct.Status != SessionStatusAuthenticated || direct.User.ID != bound.User.ID { t.Fatalf("expected future qr login to be direct for %s, got %#v", bound.User.ID, direct) } }