package service import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "os" "strings" "testing" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/testdb" "golang.org/x/crypto/bcrypt" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) func TestBuildDCAuthCanonicalExact(t *testing.T) { got := BuildDCAuthCanonical( "affiliate_dash", "challenge-id-1", "challenge-value", "D1-ABCDEFGH", 1700000000, ) want := "dcauth.v2\naffiliate_dash\nchallenge-id-1\nchallenge-value\nD1-ABCDEFGH\n1700000000" if got != want { t.Fatalf("canonical mismatch:\nwant %q\n got %q", want, got) } } func TestIsSupportedDCAuthClientVersion(t *testing.T) { cases := []struct { version string want bool }{ {"2.0.0", true}, {"2", true}, {"10.1.2", true}, {"1.9.9", false}, {"0.1.0", false}, {"v2.0.0", false}, {"", false}, } for _, tc := range cases { if got := IsSupportedDCAuthClientVersion(tc.version); got != tc.want { t.Errorf("IsSupportedDCAuthClientVersion(%q) = %v, want %v", tc.version, got, tc.want) } } } func TestNormalizeAPIBase(t *testing.T) { cases := []struct { name string input string want string wantErr bool }{ {name: "http origin", input: "http://localhost:15173", want: "http://localhost:15173"}, {name: "https with trailing slash", input: "https://admin.example.com/", want: "https://admin.example.com"}, {name: "case normalization", input: "HTTPS://Admin.Example.com", want: "https://admin.example.com"}, {name: "path rejected", input: "https://admin.example.com/api", wantErr: true}, {name: "query rejected", input: "https://admin.example.com?x=1", wantErr: true}, {name: "scheme rejected", input: "ftp://admin.example.com", wantErr: true}, {name: "empty rejected", input: " ", wantErr: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := NormalizeAPIBase(tc.input) if tc.wantErr { if err == nil { t.Fatalf("expected error, got %q", got) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { t.Fatalf("got %q, want %q", got, tc.want) } }) } } func TestDCAuthChallengeSingleConsumption(t *testing.T) { db := loginAuthTestDB(t) user := model.User{ Username: "dcauth-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "unused", Role: model.RoleAdmin, Status: 1, } if err := db.Create(&user).Error; err != nil { t.Fatalf("create user: %v", err) } svc := NewDCAuthService(db) prepared, err := svc.CreateChallenge(user.ID, "http://localhost:15173") if err != nil { t.Fatalf("create challenge: %v", err) } publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } deviceID := "D1-ABCDEFGH" timestamp := time.Now().Unix() canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp) signature := ed25519.Sign(privateKey, []byte(canonical)) if err := svc.Authorize(DCAuthorizeRequest{ App: DCAuthApp, ChallengeID: prepared.ChallengeID, Challenge: prepared.Challenge, DeviceID: deviceID, PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), Signature: base64.RawURLEncoding.EncodeToString(signature), Timestamp: timestamp, ClientVersion: "2.0.0", }); err != nil { t.Fatalf("authorize: %v", err) } status, err := svc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken) if err != nil { t.Fatalf("challenge status: %v", err) } if status.Status != model.DeviceChallengeStatusAuthorized || status.DeviceTicket == "" { t.Fatalf("expected authorized ticket, got %+v", status) } consumed, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket) if err != nil { t.Fatalf("consume ticket: %v", err) } if consumed.Status != model.DeviceChallengeStatusConsumed || consumed.DeviceID != deviceID { t.Fatalf("unexpected consumed challenge: %+v", consumed) } if _, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil { t.Fatal("second consumption must fail") } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 401 { t.Fatalf("expected 401 on second consumption, got %v", err) } } func TestDCAuthBindingMismatchRejected(t *testing.T) { db := loginAuthTestDB(t) user := model.User{ Username: "bind-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "unused", Role: model.RoleAdmin, Status: 1, } if err := db.Create(&user).Error; err != nil { t.Fatalf("create user: %v", err) } boundKey, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate bound key: %v", err) } otherKey, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate other key: %v", err) } now := time.Now() if err := db.Create(&model.UserDevice{ UserID: user.ID, DeviceID: "D1-BOUNDDEV", PublicKey: base64.RawURLEncoding.EncodeToString(boundKey), ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive, BoundAt: now, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create bound device: %v", err) } svc := NewDCAuthService(db) err = svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{ DeviceID: "D1-OTHERDEV", PublicKey: base64.RawURLEncoding.EncodeToString(otherKey), ClientVersion: "2.0.0", }) if err == nil { t.Fatal("device mismatch must be rejected") } authErr, ok := AsAuthError(err) if !ok || authErr.Status != 401 { t.Fatalf("expected 401 AuthError, got %v", err) } if !strings.Contains(authErr.Message, "当前设备与账号绑定设备不一致") { t.Fatalf("unexpected message: %s", authErr.Message) } // 同一设备与公钥应通过。 if err := svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{ DeviceID: "D1-BOUNDDEV", PublicKey: base64.RawURLEncoding.EncodeToString(boundKey), ClientVersion: "2.0.0", }); err != nil { t.Fatalf("matching device should pass: %v", err) } } func TestSecureLoginPrepareCompleteReusesJWT(t *testing.T) { db := loginAuthTestDB(t) password := "pass123456" hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) if err != nil { t.Fatalf("hash password: %v", err) } user := model.User{ Username: "secure-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: string(hash), Phone: "13900139000", Role: model.RoleAdmin, Status: 1, } if err := db.Create(&user).Error; err != nil { t.Fatalf("create user: %v", err) } now := time.Now() if err := db.Create(&model.LoginSMSCode{ UserID: user.ID, Phone: user.Phone, Purpose: model.LoginSMSPurposeLogin, CodeHash: HashSMSCode("123456"), ExpiresAt: now.Add(5 * time.Minute), CreatedAt: now, }).Error; err != nil { t.Fatalf("create sms code: %v", err) } jm := jwt.NewManager("test-jwt-secret") smsSvc := NewLoginSMSService(db, SMSConfig{Provider: "mock"}) deviceSvc := NewDCAuthService(db) authSvc := NewAuthService(db, jm, nil, WithLoginSecurity(smsSvc, deviceSvc)) prepared, err := authSvc.PrepareLogin(user.Username, password, "123456", "http://localhost:15173") if err != nil { t.Fatalf("prepare login: %v", err) } if prepared.App != DCAuthApp || prepared.PhoneMasked != "139****9000" { t.Fatalf("unexpected prepared result: %+v", prepared) } publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } deviceID := "D1-SECUREDEV" timestamp := time.Now().Unix() canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp) if err := deviceSvc.Authorize(DCAuthorizeRequest{ App: DCAuthApp, ChallengeID: prepared.ChallengeID, Challenge: prepared.Challenge, DeviceID: deviceID, PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(canonical))), Timestamp: timestamp, ClientVersion: "2.0.0", }); err != nil { t.Fatalf("authorize: %v", err) } status, err := deviceSvc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken) if err != nil || status.DeviceTicket == "" { t.Fatalf("challenge status: %+v %v", status, err) } result, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket) if err != nil { t.Fatalf("complete login: %v", err) } if result.Token == "" || result.User == nil || result.User.ID != user.ID { t.Fatalf("unexpected login result: %+v", result) } claims, err := jm.Parse(result.Token) if err != nil || claims.UserID != user.ID { t.Fatalf("issued JWT should be reusable: %+v %v", claims, err) } var device model.UserDevice if err := db.Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).First(&device).Error; err != nil { t.Fatalf("device should be auto-bound: %v", err) } if device.DeviceID != deviceID { t.Fatalf("unexpected bound device: %+v", device) } if _, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil { t.Fatal("ticket must be single-use") } if _, err := authSvc.Login(user.Username, password); err == nil { t.Fatal("password-only login must be rejected once phone is bound") } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 403 { t.Fatalf("expected 403 for bound account legacy login, got %v", err) } } func TestBindUserPhoneAndResetDevice(t *testing.T) { db := loginAuthTestDB(t) first := model.User{Username: "bindphone-a-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1} second := model.User{Username: "bindphone-b-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1} if err := db.Create(&first).Error; err != nil { t.Fatalf("create first user: %v", err) } if err := db.Create(&second).Error; err != nil { t.Fatalf("create second user: %v", err) } actor := first.ID svc := NewUserService(db, nil) updated, err := svc.BindUserPhone(first.ID, "13700137000", actor) if err != nil { t.Fatalf("bind phone: %v", err) } if updated.Phone != "13700137000" || updated.PhoneBoundBy == nil || *updated.PhoneBoundBy != actor { t.Fatalf("unexpected bound user: %+v", updated) } if _, err := svc.BindUserPhone(second.ID, "13700137000", actor); err == nil { t.Fatal("duplicate phone must be rejected") } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 409 { t.Fatalf("expected 409 duplicate phone, got %v", err) } now := time.Now() device := model.UserDevice{ UserID: first.ID, DeviceID: "D1-RESETDEV", PublicKey: "test-public-key", ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive, BoundAt: now, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&device).Error; err != nil { t.Fatalf("create device: %v", err) } reset, err := svc.ResetUserDevice(first.ID, actor) if err != nil || !reset { t.Fatalf("reset device: reset=%v err=%v", reset, err) } var stored model.UserDevice if err := db.First(&stored, device.ID).Error; err != nil { t.Fatalf("reload device: %v", err) } if stored.Status != model.UserDeviceStatusRevoked || stored.RevokedAt == nil { t.Fatalf("device should be revoked: %+v", stored) } if reset, err := svc.ResetUserDevice(first.ID, actor); err != nil || reset { t.Fatalf("second reset should be a no-op: reset=%v err=%v", reset, err) } } func TestLoginSMSCodeLocksAfterFiveFailures(t *testing.T) { db := loginAuthTestDB(t) user := model.User{ Username: "sms-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "unused", Phone: "13800138000", Role: model.RoleAdmin, Status: 1, } if err := db.Create(&user).Error; err != nil { t.Fatalf("create user: %v", err) } now := time.Now() row := model.LoginSMSCode{ UserID: user.ID, Phone: user.Phone, Purpose: model.LoginSMSPurposeLogin, CodeHash: HashSMSCode("123456"), ExpiresAt: now.Add(5 * time.Minute), CreatedAt: now, } if err := db.Create(&row).Error; err != nil { t.Fatalf("create sms code: %v", err) } svc := NewLoginSMSService(db, SMSConfig{}) for attempt := 1; attempt <= 4; attempt++ { err := svc.VerifyLoginCode(user.ID, "000000") authErr, ok := AsAuthError(err) if !ok || authErr.Code != "sms_code_invalid" { t.Fatalf("attempt %d: expected sms_code_invalid, got %v", attempt, err) } } err := svc.VerifyLoginCode(user.ID, "000000") authErr, ok := AsAuthError(err) if !ok || authErr.Code != "sms_code_locked" { t.Fatalf("fifth attempt should lock the code, got %v", err) } var stored model.LoginSMSCode if err := db.First(&stored, row.ID).Error; err != nil { t.Fatalf("reload sms code: %v", err) } if stored.FailedAttemptCount != 5 || stored.ConsumedAt == nil { t.Fatalf("expected locked row with 5 failures, got %+v", stored) } if err := svc.VerifyLoginCode(user.ID, "123456"); err == nil { t.Fatal("correct code must not work after lock") } } // loginAuthTestDB 在 PostgreSQL 可用时创建隔离 schema;不可用时跳过集成断言。 func loginAuthTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { dsn = os.Getenv("DATABASE_URL") } if dsn == "" { dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable" } probe, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { t.Skipf("跳过数据库集成测试:PostgreSQL 不可用: %v", err) } sqlDB, err := probe.DB() if err != nil { t.Skipf("跳过数据库集成测试:获取连接失败: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := sqlDB.PingContext(ctx); err != nil { _ = sqlDB.Close() t.Skipf("跳过数据库集成测试:PostgreSQL ping 失败: %v", err) } _ = sqlDB.Close() return testdb.New(t) }