2026-07-07 23:15:37 +08:00
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
2026-07-11 01:01:54 +08:00
|
|
|
"strings"
|
2026-07-07 23:15:37 +08:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 01:01:54 +08:00
|
|
|
func TestDeviceLimitsUseConfiguredValuesAndSafeDefaults(t *testing.T) {
|
|
|
|
|
configured := NewService(NewMemoryStore(), Config{
|
|
|
|
|
Region: RegionGlobal,
|
|
|
|
|
TokenSecret: "test-secret",
|
|
|
|
|
DeviceLimits: DeviceLimits{Desktop: 5, Mobile: 3},
|
|
|
|
|
})
|
|
|
|
|
if limits := configured.DeviceLimits(); limits.Desktop != 5 || limits.Mobile != 3 {
|
|
|
|
|
t.Fatalf("unexpected configured limits %#v", limits)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
defaults := NewService(NewMemoryStore(), Config{Region: RegionGlobal, TokenSecret: "test-secret"})
|
|
|
|
|
if limits := defaults.DeviceLimits(); limits.Desktop != 2 || limits.Mobile != 1 {
|
|
|
|
|
t.Fatalf("unexpected default limits %#v", limits)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDesktopDeviceLimitRevokesOldestSession(t *testing.T) {
|
|
|
|
|
service := NewService(NewMemoryStore(), Config{
|
|
|
|
|
Region: RegionGlobal,
|
|
|
|
|
TokenSecret: "test-secret",
|
|
|
|
|
TokenTTL: time.Hour,
|
|
|
|
|
CodeTTL: time.Minute,
|
|
|
|
|
DevReturnCode: true,
|
|
|
|
|
DeviceLimits: DeviceLimits{Desktop: 1, Mobile: 1},
|
|
|
|
|
})
|
|
|
|
|
now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
|
|
|
|
|
service.now = func() time.Time { return now }
|
|
|
|
|
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
|
|
|
|
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
|
|
|
|
Platform: `"macOS"`,
|
|
|
|
|
Mobile: "?0",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
login := func() Session {
|
|
|
|
|
t.Helper()
|
|
|
|
|
code, err := service.RequestGlobalEmailCode(desktopCtx, "limit@example.com", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
session, err := service.LoginGlobalEmailCode(desktopCtx, "limit@example.com", code.DevCode)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return session
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := login()
|
|
|
|
|
firstIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected first desktop session to authenticate")
|
|
|
|
|
}
|
|
|
|
|
now = now.Add(time.Second)
|
|
|
|
|
second := login()
|
|
|
|
|
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected newest desktop session to authenticate")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
|
|
|
|
t.Fatal("expected oldest desktop session to be revoked at the configured limit")
|
|
|
|
|
}
|
|
|
|
|
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
|
|
|
|
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID || !devices[0].Current {
|
|
|
|
|
t.Fatalf("expected only the newest desktop session, got %#v (first=%s)", devices, firstIdentity.SessionID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestListDevicesReconcilesSessionsCreatedBeforeLimitEnforcement(t *testing.T) {
|
|
|
|
|
service := NewService(NewMemoryStore(), Config{
|
|
|
|
|
Region: RegionGlobal,
|
|
|
|
|
TokenSecret: "test-secret",
|
|
|
|
|
TokenTTL: time.Hour,
|
|
|
|
|
CodeTTL: time.Minute,
|
|
|
|
|
DevReturnCode: true,
|
|
|
|
|
DeviceLimits: DeviceLimits{Desktop: 2, Mobile: 1},
|
|
|
|
|
})
|
|
|
|
|
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
service.now = func() time.Time { return now }
|
|
|
|
|
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
|
|
|
|
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
|
|
|
|
Platform: `"macOS"`,
|
|
|
|
|
Mobile: "?0",
|
|
|
|
|
})
|
|
|
|
|
login := func() Session {
|
|
|
|
|
t.Helper()
|
|
|
|
|
code, err := service.RequestGlobalEmailCode(desktopCtx, "reconcile@example.com", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
session, err := service.LoginGlobalEmailCode(desktopCtx, "reconcile@example.com", code.DevCode)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return session
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := login()
|
|
|
|
|
now = now.Add(time.Second)
|
|
|
|
|
second := login()
|
|
|
|
|
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected newest session to authenticate")
|
|
|
|
|
}
|
|
|
|
|
service.cfg.DeviceLimits.Desktop = 1
|
|
|
|
|
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
|
|
|
|
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID {
|
|
|
|
|
t.Fatalf("expected list reconciliation to preserve only the current session, got %#v", devices)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
|
|
|
|
t.Fatal("expected pre-enforcement excess session to be revoked")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 23:15:37 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 01:01:54 +08:00
|
|
|
func TestAccountDevicesTrackAndRevokeOtherSessions(t *testing.T) {
|
|
|
|
|
store := NewMemoryStore()
|
|
|
|
|
service := NewService(store, Config{
|
2026-07-07 23:15:37 +08:00
|
|
|
Region: RegionGlobal,
|
|
|
|
|
TokenSecret: "test-secret",
|
2026-07-11 01:01:54 +08:00
|
|
|
TokenTTL: time.Hour,
|
2026-07-07 23:15:37 +08:00
|
|
|
CodeTTL: time.Minute,
|
|
|
|
|
DevReturnCode: true,
|
|
|
|
|
})
|
2026-07-11 01:01:54 +08:00
|
|
|
now := time.Date(2026, 7, 11, 9, 0, 0, 0, time.UTC)
|
|
|
|
|
service.now = func() time.Time { return now }
|
2026-07-07 23:15:37 +08:00
|
|
|
|
2026-07-11 01:01:54 +08:00
|
|
|
desktopMetadata := RequestMetadata{
|
|
|
|
|
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
|
|
|
Platform: `"macOS"`,
|
|
|
|
|
Mobile: "?0",
|
|
|
|
|
}
|
|
|
|
|
desktopLoginCtx := ContextWithRequestMetadata(context.Background(), desktopMetadata)
|
|
|
|
|
code, err := service.RequestGlobalEmailCode(desktopLoginCtx, "user@example.com", "")
|
2026-07-07 23:15:37 +08:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-07-11 01:01:54 +08:00
|
|
|
desktopSession, err := service.LoginGlobalEmailCode(desktopLoginCtx, "user@example.com", code.DevCode)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
desktopIdentity, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected desktop session to authenticate")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now = now.Add(2 * time.Minute)
|
|
|
|
|
mobileMetadata := RequestMetadata{
|
|
|
|
|
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
|
|
|
|
|
Platform: `"iOS"`,
|
|
|
|
|
Mobile: "?1",
|
|
|
|
|
}
|
|
|
|
|
mobileLoginCtx := ContextWithRequestMetadata(context.Background(), mobileMetadata)
|
|
|
|
|
code, err = service.RequestGlobalEmailCode(mobileLoginCtx, "user@example.com", "")
|
2026-07-07 23:15:37 +08:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-07-11 01:01:54 +08:00
|
|
|
mobileSession, err := service.LoginGlobalEmailCode(mobileLoginCtx, "user@example.com", code.DevCode)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
mobileIdentity, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected mobile session to authenticate")
|
|
|
|
|
}
|
|
|
|
|
mobileCtx := ContextWithSessionID(mobileLoginCtx, mobileIdentity.SessionID)
|
|
|
|
|
|
|
|
|
|
devices, err := service.ListDevices(mobileCtx, mobileSession.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) != 2 {
|
|
|
|
|
t.Fatalf("expected two devices, got %#v", devices)
|
|
|
|
|
}
|
|
|
|
|
if !devices[0].Current || devices[0].ID != mobileIdentity.SessionID || devices[0].DeviceType != "mobile" || devices[0].System != "iOS" {
|
|
|
|
|
t.Fatalf("expected current mobile device first, got %#v", devices[0])
|
|
|
|
|
}
|
|
|
|
|
if devices[1].Current || devices[1].ID != desktopIdentity.SessionID || devices[1].DeviceType != "desktop" || devices[1].System != "macOS" {
|
|
|
|
|
t.Fatalf("expected secondary desktop device, got %#v", devices[1])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removed, err := service.RemoveOtherDevices(mobileCtx, mobileSession.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if removed != 1 {
|
|
|
|
|
t.Fatalf("expected one removable device, got %d", removed)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken); ok {
|
|
|
|
|
t.Fatal("expected removed desktop session to be rejected")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); !ok {
|
|
|
|
|
t.Fatal("expected current mobile session to remain valid")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
devices, err = service.ListDevices(mobileCtx, mobileSession.User.ID)
|
2026-07-07 23:15:37 +08:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) != 1 || !devices[0].Current {
|
2026-07-11 01:01:54 +08:00
|
|
|
t.Fatalf("expected only the current device after removal, got %#v", devices)
|
|
|
|
|
}
|
|
|
|
|
removed, err = service.Logout(mobileCtx, mobileSession.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if removed != 1 {
|
|
|
|
|
t.Fatalf("expected logout to revoke one current session, got %d", removed)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); ok {
|
|
|
|
|
t.Fatal("expected logged-out session to be rejected")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLegacyBearerMigratesToRevocableDeviceSession(t *testing.T) {
|
|
|
|
|
store := NewMemoryStore()
|
|
|
|
|
service := NewService(store, Config{Region: RegionGlobal, TokenSecret: "test-secret", TokenTTL: time.Hour})
|
|
|
|
|
now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
|
|
|
|
|
service.now = func() time.Time { return now }
|
|
|
|
|
user, err := store.UpsertUser(context.Background(), User{ID: newID(), Region: RegionGlobal, Email: "legacy@example.com", Status: "active"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
legacyToken, _, err := signToken(user.ID, "test-secret", time.Hour, now)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
ctx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
|
|
|
|
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
|
|
|
|
|
})
|
|
|
|
|
identity, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken)
|
|
|
|
|
if !ok || identity.UserID != user.ID || !strings.HasPrefix(identity.SessionID, "legacy:") {
|
|
|
|
|
t.Fatalf("expected migrated legacy identity, got %#v / %v", identity, ok)
|
|
|
|
|
}
|
|
|
|
|
authenticatedCtx := ContextWithSessionID(ctx, identity.SessionID)
|
|
|
|
|
devices, err := service.ListDevices(authenticatedCtx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) != 1 || !devices[0].Current || devices[0].System != "Windows" {
|
|
|
|
|
t.Fatalf("unexpected migrated device %#v", devices)
|
|
|
|
|
}
|
|
|
|
|
if _, err := service.Logout(authenticatedCtx, user.ID); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken); ok {
|
|
|
|
|
t.Fatal("expected revoked legacy token to stay revoked")
|
2026-07-07 23:15:37 +08:00
|
|
|
}
|
2026-07-11 01:01:54 +08:00
|
|
|
|
|
|
|
|
nonUserToken, _, err := signToken(newID(), "test-secret", time.Hour, now)
|
2026-07-07 23:15:37 +08:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-07-11 01:01:54 +08:00
|
|
|
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+nonUserToken); ok {
|
|
|
|
|
t.Fatal("expected signed token for a missing user to be rejected")
|
2026-07-07 23:15:37 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|