package logic import ( "context" "errors" "strings" "testing" "spark-server/internal/infrastructure/id" smslogic "spark-server/internal/module/sms/logic" smsmodel "spark-server/internal/module/sms/model" userlogic "spark-server/internal/module/user/logic" usermodel "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/reqctx" ) func TestLoginServiceValidatesAndIssues(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } svc := testLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"}) ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"}) _, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"}) app, ok := errs.AsAppError(err) if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "Please enter the verification code") { t.Fatalf("validate %+v", err) } enCtx := reqctx.With(t.Context(), &reqctx.RequestContext{Language: "en"}) _, err = svc.Login(enCtx, &LoginInput{Email: "test01@spark.com"}) app, ok = errs.AsAppError(err) if !ok || !strings.Contains(app.Msg, "Please enter the verification code") { t.Fatalf("en validate %+v", err) } tok, err := svc.Login(ctx, &LoginInput{ Email: "test01@spark.com", VerifyCode: "any", AppID: testAppID, DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007", }) if err != nil { t.Fatal(err) } if tok.UserID != 11 || tok.DeviceID != testIOSDeviceID || tok.AppID != uint32(testAppID) || tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" { t.Fatalf("%+v", tok) } } func TestLoginServiceRefreshLogout(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } users := StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"} kicker := &kickRec{} svc := testLoginService(j, users) svc.Sessions = kicker ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"}) tok, err := svc.Login(ctx, &LoginInput{ Email: "test01@spark.com", VerifyCode: "any", AppID: testAppID, DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007", }) if err != nil { t.Fatal(err) } refreshed, err := svc.Refresh(ctx, RefreshInput{RefreshToken: tok.RefreshToken}) if err != nil || refreshed.UserID != 11 { t.Fatalf("refresh %+v %v", refreshed, err) } p, err := j.Authenticate(ctx, refreshed.AccessToken) if err != nil { t.Fatal(err) } outCtx := reqctx.With(ctx, &reqctx.RequestContext{ UserID: p.UserID, DeviceID: p.DeviceID, AppID: p.AppID, JTI: p.JTI, TokenExpires: p.ExpiresAt, }) if err := svc.Logout(outCtx); err != nil { t.Fatal(err) } if kicker.n != 1 { t.Fatalf("kick %d", kicker.n) } if _, err := (*LoginService)(nil).Refresh(ctx, RefreshInput{RefreshToken: "x"}); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("nil refresh %v", err) } if err := svc.Logout(ctx); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("logout ctx %v", err) } } func TestLoginServiceUnauthorized(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret"}) if err != nil { t.Fatal(err) } in := LoginInput{ Email: "ada@example.com", VerifyCode: "000000", AppID: testAppID, DeviceType: 6, IDFV: "web-test-idfv", } svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11}) svc.Devices = fixedDeviceRegistrar{id: 1} if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("bad code: %v", err) } in.VerifyCode = "123456" svc.Devices = nil if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("nil devices: %v", err) } } type kickRec struct{ n int } func (k *kickRec) KickDevice(int64, uint64) { k.n++ } func TestLoginRejectsInvalidAppID(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret"}) if err != nil { t.Fatal(err) } svc := testLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 1}) ctx := t.Context() _, err = svc.Login(ctx, &LoginInput{ Email: "test01@spark.com", VerifyCode: "x", AppID: 0, DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007", }) if err == nil { t.Fatal("expected missing app_id") } _, err = svc.Login(ctx, &LoginInput{ Email: "test01@spark.com", VerifyCode: "x", AppID: testAppID, DeviceType: 1, }) if err == nil { t.Fatal("expected missing idfv") } } func TestJWTTripleModeTwoApps(t *testing.T) { ctx := context.Background() pairs := NewMemoryPairsMode(PairKeyTriple) j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Pairs: pairs, PairKeyMode: PairKeyTriple}) if err != nil { t.Fatal(err) } a, err := j.Issue(ctx, 1, testIOSDeviceID, 1) if err != nil { t.Fatal(err) } b, err := j.Issue(ctx, 1, testIOSDeviceID, 2) if err != nil { t.Fatal(err) } if a.RefreshToken == b.RefreshToken { t.Fatal("different apps should have different refresh pairs") } if _, err := j.Refresh(ctx, a.RefreshToken); err != nil { t.Fatal(err) } if _, err := j.Refresh(ctx, b.RefreshToken); err != nil { t.Fatal(err) } } func TestStaticAuthenticate(t *testing.T) { s := Static{Token: "abc", UserID: 1, DeviceID: 2} p, err := s.Authenticate(t.Context(), "abc") if err != nil || p.UserID != 1 || p.DeviceID != 2 { t.Fatalf("%+v %v", p, err) } if _, err := s.Authenticate(t.Context(), "no"); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("bad token %v", err) } } func TestLoginServiceRecordsContextDevice(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } repo := usermodel.NewMemoryRepo() idgen, err := id.New(1) if err != nil { t.Fatal(err) } store := smsmodel.NewMemoryStore() smsSvc := smslogic.NewService(store, nil, nil, nil) users := userlogic.NewService(repo, smsSvc, idgen, nil) svc := testLoginService(j, users) ctx := reqctx.With(t.Context(), &reqctx.RequestContext{ RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0", }) tok, err := svc.Login(ctx, &LoginInput{ Email: "test07@spark.com", VerifyCode: "x", AppID: testAppID, DeviceType: 2, DeviceName: "Pixel", AndroidID: "spark-test-android-9", }) if err != nil { t.Fatal(err) } dev, err := repo.GetDevice(ctx, tok.UserID, testAndroidDeviceID) if err != nil { t.Fatal(err) } if dev.DeviceID != testAndroidDeviceID || dev.LastLoginAt.IsZero() { t.Fatalf("%+v", dev) } logs := repo.LoginLogs() if len(logs) == 0 || logs[len(logs)-1].DeviceType != "android" || logs[len(logs)-1].AppVersion != "2.1.0" || logs[len(logs)-1].Language != "en" { t.Fatalf("logs %+v", logs) } }