| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167 |
- //go:build unit
- package service_test
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "sync"
- "testing"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/authidentity"
- "github.com/Wei-Shaw/sub2api/ent/enttest"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/Wei-Shaw/sub2api/internal/repository"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
- "entgo.io/ent/dialect"
- entsql "entgo.io/ent/dialect/sql"
- _ "modernc.org/sqlite"
- )
- type emailBindDefaultSubAssignerStub struct {
- calls []*service.AssignSubscriptionInput
- }
- func (s *emailBindDefaultSubAssignerStub) AssignOrExtendSubscription(
- _ context.Context,
- input *service.AssignSubscriptionInput,
- ) (*service.UserSubscription, bool, error) {
- cloned := *input
- s.calls = append(s.calls, &cloned)
- return &service.UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, false, nil
- }
- type flakyEmailBindDefaultSubAssignerStub struct {
- err error
- calls []*service.AssignSubscriptionInput
- }
- func (s *flakyEmailBindDefaultSubAssignerStub) AssignOrExtendSubscription(
- _ context.Context,
- input *service.AssignSubscriptionInput,
- ) (*service.UserSubscription, bool, error) {
- cloned := *input
- s.calls = append(s.calls, &cloned)
- return nil, false, s.err
- }
- func newAuthServiceForEmailBind(
- t *testing.T,
- settings map[string]string,
- emailCache service.EmailCache,
- defaultSubAssigner service.DefaultSubscriptionAssigner,
- ) (*service.AuthService, service.UserRepository, *dbent.Client) {
- return newAuthServiceForEmailBindWithRefreshCache(t, settings, emailCache, defaultSubAssigner, nil)
- }
- func newAuthServiceForEmailBindWithRefreshCache(
- t *testing.T,
- settings map[string]string,
- emailCache service.EmailCache,
- defaultSubAssigner service.DefaultSubscriptionAssigner,
- refreshTokenCache service.RefreshTokenCache,
- ) (*service.AuthService, service.UserRepository, *dbent.Client) {
- t.Helper()
- dbName := fmt.Sprintf("file:auth_service_email_bind_%d?mode=memory&cache=shared", time.Now().UnixNano())
- db, err := sql.Open("sqlite", dbName)
- require.NoError(t, err)
- t.Cleanup(func() { _ = db.Close() })
- _, err = db.Exec("PRAGMA foreign_keys = ON")
- require.NoError(t, err)
- _, err = db.Exec(`
- CREATE TABLE IF NOT EXISTS user_provider_default_grants (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- provider_type TEXT NOT NULL,
- grant_reason TEXT NOT NULL DEFAULT 'first_bind',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, provider_type, grant_reason)
- )`)
- require.NoError(t, err)
- drv := entsql.OpenDB(dialect.SQLite, db)
- client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
- t.Cleanup(func() { _ = client.Close() })
- repo := repository.NewUserRepository(client, db)
- cfg := &config.Config{
- JWT: config.JWTConfig{
- Secret: "test-bind-email-secret",
- ExpireHour: 1,
- },
- Default: config.DefaultConfig{
- UserBalance: 3.5,
- UserConcurrency: 2,
- },
- }
- settingRepo := &emailBindSettingRepoStub{values: settings}
- settingSvc := service.NewSettingService(settingRepo, cfg)
- var emailSvc *service.EmailService
- if emailCache != nil {
- emailSvc = service.NewEmailService(settingRepo, emailCache)
- }
- svc := service.NewAuthService(client, repo, nil, refreshTokenCache, cfg, settingSvc, emailSvc, nil, nil, nil, defaultSubAssigner, nil, nil)
- return svc, repo, client
- }
- func TestAuthServiceBindEmailIdentity_UpdatesEmailAndAppliesFirstBindDefaults(t *testing.T) {
- assigner := &emailBindDefaultSubAssignerStub{}
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyAuthSourceDefaultEmailBalance: "8.5",
- service.SettingKeyAuthSourceDefaultEmailConcurrency: "4",
- service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
- service.SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "true",
- }, cache, assigner)
- ctx := context.Background()
- user, err := client.User.Create().
- SetEmail("legacy-user" + service.LinuxDoConnectSyntheticEmailDomain).
- SetUsername("legacy-user").
- SetPasswordHash("old-hash").
- SetBalance(2.5).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, " NewEmail@Example.com ", "123456", "new-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- require.Equal(t, "newemail@example.com", updatedUser.Email)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "newemail@example.com", storedUser.Email)
- require.Equal(t, 11.0, storedUser.Balance)
- require.Equal(t, 5, storedUser.Concurrency)
- require.True(t, svc.CheckPassword("new-password", storedUser.PasswordHash))
- identityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("newemail@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, identityCount)
- require.Len(t, assigner.calls, 1)
- require.Equal(t, user.ID, assigner.calls[0].UserID)
- require.Equal(t, int64(11), assigner.calls[0].GroupID)
- require.Equal(t, 30, assigner.calls[0].ValidityDays)
- require.Equal(t, 1, countProviderGrantRecords(t, client, user.ID, "email", "first_bind"))
- }
- func TestAuthServiceBindEmailIdentity_RejectsExistingEmailOnAnotherUser(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- sourceUser, err := client.User.Create().
- SetEmail("source-user" + service.OIDCConnectSyntheticEmailDomain).
- SetUsername("source-user").
- SetPasswordHash("old-hash").
- SetBalance(1).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- _, err = client.User.Create().
- SetEmail("taken@example.com").
- SetUsername("taken-user").
- SetPasswordHash("hash").
- SetBalance(1).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- updatedUser, err := svc.BindEmailIdentity(ctx, sourceUser.ID, "taken@example.com", "123456", "new-password")
- require.ErrorIs(t, err, service.ErrEmailExists)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, sourceUser.ID)
- require.NoError(t, err)
- require.Equal(t, "source-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email)
- require.Equal(t, 0, countProviderGrantRecords(t, client, sourceUser.ID, "email", "first_bind"))
- }
- func TestAuthServiceBindEmailIdentity_RejectsAliasOfExistingEmailOnAnotherUser(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC().Add(-10 * time.Minute),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- sourceUser := createEmailBindTestUser(
- t,
- client,
- "source-user"+service.OIDCConnectSyntheticEmailDomain,
- "source-user",
- "old-hash",
- )
- createEmailBindTestUser(t, client, "zck.ioio123@gmail.com", "inbox-owner", "hash")
- err := svc.SendEmailIdentityBindCode(ctx, sourceUser.ID, "zckioio123+new@gmail.com")
- require.ErrorIs(t, err, service.ErrEmailExists)
- require.Empty(t, cache.setEmails)
- updatedUser, err := svc.BindEmailIdentity(
- ctx,
- sourceUser.ID,
- "zckioio123+new@gmail.com",
- "123456",
- "new-password",
- )
- require.ErrorIs(t, err, service.ErrEmailExists)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, sourceUser.ID)
- require.NoError(t, err)
- require.Equal(t, "source-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email)
- require.Equal(t, "old-hash", storedUser.PasswordHash)
- }
- func TestAuthServiceBindEmailIdentity_AllowsOnlyOneConcurrentAliasVariant(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- unique := fmt.Sprintf("%d", time.Now().UnixNano())
- first := createEmailBindTestUser(
- t,
- client,
- "first-"+unique+service.OIDCConnectSyntheticEmailDomain,
- "first-"+unique,
- "old-hash",
- )
- second := createEmailBindTestUser(
- t,
- client,
- "second-"+unique+service.OIDCConnectSyntheticEmailDomain,
- "second-"+unique,
- "old-hash",
- )
- start := make(chan struct{})
- results := make(chan error, 2)
- go func() {
- <-start
- _, err := svc.BindEmailIdentity(ctx, first.ID, "inbox-"+unique+"+one@gmail.com", "123456", "new-password")
- results <- err
- }()
- go func() {
- <-start
- _, err := svc.BindEmailIdentity(ctx, second.ID, "inbox-"+unique+"+two@gmail.com", "123456", "new-password")
- results <- err
- }()
- close(start)
- var successes, conflicts int
- for range 2 {
- err := <-results
- switch {
- case err == nil:
- successes++
- case errors.Is(err, service.ErrEmailExists):
- conflicts++
- default:
- t.Fatalf("unexpected bind error: %v", err)
- }
- }
- require.Equal(t, 1, successes)
- require.Equal(t, 1, conflicts)
- boundCount, err := client.User.Query().
- Where(dbuser.EmailIn(
- "inbox-"+unique+"+one@gmail.com",
- "inbox-"+unique+"+two@gmail.com",
- )).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, boundCount)
- }
- func TestAuthServiceBindEmailIdentity_RejectsNewAliasWhenAnotherUserSharesCurrentUserInbox(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- hashedPassword, err := svc.HashPassword("current-password")
- require.NoError(t, err)
- currentUser := createEmailBindTestUser(t, client, "inbox+own@gmail.com", "current", hashedPassword)
- createEmailBindTestUser(t, client, "inbox+legacy@gmail.com", "legacy", "hash")
- updatedUser, err := svc.BindEmailIdentity(
- ctx,
- currentUser.ID,
- "inbox+new@gmail.com",
- "123456",
- "current-password",
- )
- require.ErrorIs(t, err, service.ErrEmailExists)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, currentUser.ID)
- require.NoError(t, err)
- require.Equal(t, "inbox+own@gmail.com", storedUser.Email)
- }
- func TestAuthServiceBindEmailIdentity_RollsBackWhenFirstBindDefaultsFail(t *testing.T) {
- assigner := &flakyEmailBindDefaultSubAssignerStub{err: errors.New("temporary assign failure")}
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyAuthSourceDefaultEmailBalance: "8.5",
- service.SettingKeyAuthSourceDefaultEmailConcurrency: "4",
- service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
- service.SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "true",
- }, cache, assigner)
- ctx := context.Background()
- originalEmail := "legacy-rollback" + service.LinuxDoConnectSyntheticEmailDomain
- user, err := client.User.Create().
- SetEmail(originalEmail).
- SetUsername("legacy-rollback").
- SetPasswordHash("old-hash").
- SetBalance(2.5).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "rollback@example.com", "123456", "new-password")
- require.ErrorContains(t, err, "apply email first bind defaults")
- require.ErrorContains(t, err, "temporary assign failure")
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, originalEmail, storedUser.Email)
- require.Equal(t, "old-hash", storedUser.PasswordHash)
- require.Equal(t, 2.5, storedUser.Balance)
- require.Equal(t, 1, storedUser.Concurrency)
- identityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("rollback@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 0, identityCount)
- require.Len(t, assigner.calls, 1)
- require.Equal(t, 0, countProviderGrantRecords(t, client, user.ID, "email", "first_bind"))
- }
- func TestAuthServiceBindEmailIdentity_RejectsReservedEmail(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- user, err := client.User.Create().
- SetEmail("source-user@example.com").
- SetUsername("source-user").
- SetPasswordHash("old-hash").
- SetBalance(1).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "reserved"+service.LinuxDoConnectSyntheticEmailDomain, "123456", "new-password")
- require.ErrorIs(t, err, service.ErrEmailReserved)
- require.Nil(t, updatedUser)
- }
- func TestAuthServiceBindEmailIdentity_ReplacesBoundEmailAndSkipsFirstBindDefaults(t *testing.T) {
- assigner := &emailBindDefaultSubAssignerStub{}
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyAuthSourceDefaultEmailBalance: "8.5",
- service.SettingKeyAuthSourceDefaultEmailConcurrency: "4",
- service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
- service.SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "true",
- }, cache, assigner)
- ctx := context.Background()
- hashedPassword, err := svc.HashPassword("current-password")
- require.NoError(t, err)
- user, err := client.User.Create().
- SetEmail("current@example.com").
- SetUsername("bound-user").
- SetPasswordHash(hashedPassword).
- SetBalance(7.5).
- SetConcurrency(3).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- require.NoError(t, client.AuthIdentity.Create().
- SetUserID(user.ID).
- SetProviderType("email").
- SetProviderKey("email").
- SetProviderSubject("current@example.com").
- SetVerifiedAt(time.Now().UTC()).
- SetMetadata(map[string]any{"source": "test"}).
- Exec(ctx))
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "new@example.com", "123456", "current-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- require.Equal(t, "new@example.com", updatedUser.Email)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "new@example.com", storedUser.Email)
- require.Equal(t, 7.5, storedUser.Balance)
- require.Equal(t, 3, storedUser.Concurrency)
- require.True(t, svc.CheckPassword("current-password", storedUser.PasswordHash))
- newIdentityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("new@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, newIdentityCount)
- oldIdentityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("current@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 0, oldIdentityCount)
- require.Empty(t, assigner.calls)
- require.Equal(t, 0, countProviderGrantRecords(t, client, user.ID, "email", "first_bind"))
- }
- func TestAuthServiceBindEmailIdentity_RejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
- ctx := context.Background()
- hashedPassword, err := svc.HashPassword("current-password")
- require.NoError(t, err)
- user, err := client.User.Create().
- SetEmail("current@example.com").
- SetUsername("bound-user").
- SetPasswordHash(hashedPassword).
- SetBalance(1).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(ctx)
- require.NoError(t, err)
- require.NoError(t, client.AuthIdentity.Create().
- SetUserID(user.ID).
- SetProviderType("email").
- SetProviderKey("email").
- SetProviderSubject("current@example.com").
- SetVerifiedAt(time.Now().UTC()).
- SetMetadata(map[string]any{"source": "test"}).
- Exec(ctx))
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "new@example.com", "123456", "wrong-password")
- require.ErrorIs(t, err, service.ErrPasswordIncorrect)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "current@example.com", storedUser.Email)
- require.True(t, svc.CheckPassword("current-password", storedUser.PasswordHash))
- oldIdentityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("current@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, oldIdentityCount)
- newIdentityCount, err := client.AuthIdentity.Query().
- Where(
- authidentity.UserIDEQ(user.ID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ("new@example.com"),
- ).
- Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 0, newIdentityCount)
- }
- func TestAuthServiceBindEmailIdentity_RevokesExistingAccessAndRefreshTokens(t *testing.T) {
- ctx := context.Background()
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- refreshTokenCache := newEmailBindRefreshTokenCacheStub()
- userRepo := newEmailBindUserRepoStub(&service.User{
- ID: 41,
- Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain,
- Username: "legacy-user",
- PasswordHash: "old-hash",
- Role: service.RoleUser,
- Status: service.StatusActive,
- TokenVersion: 4,
- })
- cfg := &config.Config{
- JWT: config.JWTConfig{
- Secret: "test-bind-email-secret",
- ExpireHour: 1,
- AccessTokenExpireMinutes: 60,
- RefreshTokenExpireDays: 7,
- },
- }
- emailService := service.NewEmailService(nil, cache)
- svc := service.NewAuthService(nil, userRepo, nil, refreshTokenCache, cfg, nil, emailService, nil, nil, nil, nil, nil, nil)
- oldTokenPair, err := svc.GenerateTokenPair(ctx, &service.User{
- ID: 41,
- Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain,
- Role: service.RoleUser,
- Status: service.StatusActive,
- TokenVersion: 4,
- }, "")
- require.NoError(t, err)
- updatedUser, err := svc.BindEmailIdentity(ctx, 41, "new@example.com", "123456", "new-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- storedUser, err := userRepo.GetByID(ctx, 41)
- require.NoError(t, err)
- require.Equal(t, "new@example.com", storedUser.Email)
- require.True(t, svc.CheckPassword("new-password", storedUser.PasswordHash))
- _, err = svc.RefreshToken(ctx, oldTokenPair.AccessToken)
- require.ErrorIs(t, err, service.ErrTokenRevoked)
- _, err = svc.RefreshTokenPair(ctx, oldTokenPair.RefreshToken)
- require.True(t, errors.Is(err, service.ErrTokenRevoked) || errors.Is(err, service.ErrRefreshTokenInvalid))
- }
- func TestAuthServiceEmailIdentityBinding_RejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
- ctx := context.Background()
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
- }, cache, nil)
- user := createEmailBindTestUser(t, client, "legacy-user"+service.OIDCConnectSyntheticEmailDomain, "legacy-user", "old-hash")
- err := svc.SendEmailIdentityBindCode(ctx, user.ID, "intruder@gmail.com")
- require.ErrorIs(t, err, service.ErrEmailSuffixNotAllowed)
- require.Empty(t, cache.setEmails)
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "intruder@gmail.com", "123456", "new-password")
- require.ErrorIs(t, err, service.ErrEmailSuffixNotAllowed)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "legacy-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email)
- }
- func TestAuthServiceBindEmailIdentity_AllowsEmailInsideRegistrationSuffixWhitelist(t *testing.T) {
- ctx := context.Background()
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
- }, cache, nil)
- user := createEmailBindTestUser(t, client, "legacy-qq"+service.LinuxDoConnectSyntheticEmailDomain, "legacy-qq", "old-hash")
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, " Member@QQ.com ", "123456", "new-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- require.Equal(t, "member@qq.com", updatedUser.Email)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "member@qq.com", storedUser.Email)
- }
- func TestAuthServiceBindEmailIdentity_RegistrationSuffixWhitelistWildcard(t *testing.T) {
- ctx := context.Background()
- t.Run("allows wildcard suffix", func(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: `["*.edu.cn"]`,
- }, cache, nil)
- user := createEmailBindTestUser(t, client, "legacy-student"+service.OIDCConnectSyntheticEmailDomain, "legacy-student", "old-hash")
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "student@cs.edu.cn", "123456", "new-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- require.Equal(t, "student@cs.edu.cn", updatedUser.Email)
- })
- t.Run("rejects outside wildcard suffix", func(t *testing.T) {
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: `["*.edu.cn"]`,
- }, cache, nil)
- user := createEmailBindTestUser(t, client, "legacy-wildcard"+service.OIDCConnectSyntheticEmailDomain, "legacy-wildcard", "old-hash")
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "foo@gmail.com", "123456", "new-password")
- require.ErrorIs(t, err, service.ErrEmailSuffixNotAllowed)
- require.Nil(t, updatedUser)
- storedUser, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, "legacy-wildcard"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email)
- })
- }
- func TestAuthServiceBindEmailIdentity_AllowsAnyEmailWhenRegistrationSuffixWhitelistEmpty(t *testing.T) {
- ctx := context.Background()
- cache := &emailBindCacheStub{
- data: &service.VerificationCodeData{
- Code: "123456",
- CreatedAt: time.Now().UTC(),
- ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
- },
- }
- svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
- }, cache, nil)
- user := createEmailBindTestUser(t, client, "legacy-empty"+service.LinuxDoConnectSyntheticEmailDomain, "legacy-empty", "old-hash")
- updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "anyone@gmail.com", "123456", "new-password")
- require.NoError(t, err)
- require.NotNil(t, updatedUser)
- require.Equal(t, "anyone@gmail.com", updatedUser.Email)
- }
- func createEmailBindTestUser(t *testing.T, client *dbent.Client, email, username, passwordHash string) *dbent.User {
- t.Helper()
- user, err := client.User.Create().
- SetEmail(email).
- SetUsername(username).
- SetPasswordHash(passwordHash).
- SetBalance(1).
- SetConcurrency(1).
- SetRole(service.RoleUser).
- SetStatus(service.StatusActive).
- Save(context.Background())
- require.NoError(t, err)
- return user
- }
- type emailBindSettingRepoStub struct {
- values map[string]string
- }
- func (s *emailBindSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
- panic("unexpected Get call")
- }
- func (s *emailBindSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
- if v, ok := s.values[key]; ok {
- return v, nil
- }
- return "", service.ErrSettingNotFound
- }
- func (s *emailBindSettingRepoStub) Set(context.Context, string, string) error {
- panic("unexpected Set call")
- }
- func (s *emailBindSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
- out := make(map[string]string, len(keys))
- for _, key := range keys {
- if v, ok := s.values[key]; ok {
- out[key] = v
- }
- }
- return out, nil
- }
- func (s *emailBindSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
- panic("unexpected SetMultiple call")
- }
- func (s *emailBindSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
- panic("unexpected GetAll call")
- }
- func (s *emailBindSettingRepoStub) Delete(context.Context, string) error {
- panic("unexpected Delete call")
- }
- type emailBindCacheStub struct {
- data *service.VerificationCodeData
- err error
- setEmails []string
- }
- func (s *emailBindCacheStub) GetVerificationCode(context.Context, string) (*service.VerificationCodeData, error) {
- if s.err != nil {
- return nil, s.err
- }
- return s.data, nil
- }
- func (s *emailBindCacheStub) SetVerificationCode(_ context.Context, email string, _ *service.VerificationCodeData, _ time.Duration) error {
- s.setEmails = append(s.setEmails, email)
- return nil
- }
- func (s *emailBindCacheStub) DeleteVerificationCode(context.Context, string) error {
- return nil
- }
- func (s *emailBindCacheStub) GetNotifyVerifyCode(context.Context, string) (*service.VerificationCodeData, error) {
- return nil, nil
- }
- func (s *emailBindCacheStub) SetNotifyVerifyCode(context.Context, string, *service.VerificationCodeData, time.Duration) error {
- return nil
- }
- func (s *emailBindCacheStub) DeleteNotifyVerifyCode(context.Context, string) error {
- return nil
- }
- func (s *emailBindCacheStub) GetPasswordResetToken(context.Context, string) (*service.PasswordResetTokenData, error) {
- return nil, nil
- }
- func (s *emailBindCacheStub) SetPasswordResetToken(context.Context, string, *service.PasswordResetTokenData, time.Duration) error {
- return nil
- }
- func (s *emailBindCacheStub) DeletePasswordResetToken(context.Context, string) error {
- return nil
- }
- func (s *emailBindCacheStub) IsPasswordResetEmailInCooldown(context.Context, string) bool {
- return false
- }
- func (s *emailBindCacheStub) SetPasswordResetEmailCooldown(context.Context, string, time.Duration) error {
- return nil
- }
- func (s *emailBindCacheStub) GetNotifyCodeUserRate(context.Context, int64) (int64, error) {
- return 0, nil
- }
- func (s *emailBindCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) {
- return 0, nil
- }
- type emailBindRefreshTokenCacheStub struct {
- mu sync.Mutex
- tokens map[string]*service.RefreshTokenData
- userSets map[int64]map[string]struct{}
- families map[string]map[string]struct{}
- }
- func newEmailBindRefreshTokenCacheStub() *emailBindRefreshTokenCacheStub {
- return &emailBindRefreshTokenCacheStub{
- tokens: make(map[string]*service.RefreshTokenData),
- userSets: make(map[int64]map[string]struct{}),
- families: make(map[string]map[string]struct{}),
- }
- }
- func (s *emailBindRefreshTokenCacheStub) StoreRefreshToken(_ context.Context, tokenHash string, data *service.RefreshTokenData, _ time.Duration) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- cloned := *data
- s.tokens[tokenHash] = &cloned
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) GetRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- data, ok := s.tokens[tokenHash]
- if !ok {
- return nil, service.ErrRefreshTokenNotFound
- }
- cloned := *data
- return &cloned, nil
- }
- func (s *emailBindRefreshTokenCacheStub) DeleteRefreshToken(_ context.Context, tokenHash string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- delete(s.tokens, tokenHash)
- for _, tokenSet := range s.userSets {
- delete(tokenSet, tokenHash)
- }
- for _, tokenSet := range s.families {
- delete(tokenSet, tokenHash)
- }
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- for tokenHash := range s.userSets[userID] {
- delete(s.tokens, tokenHash)
- for _, tokenSet := range s.families {
- delete(tokenSet, tokenHash)
- }
- }
- delete(s.userSets, userID)
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) DeleteTokenFamily(_ context.Context, familyID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- for tokenHash := range s.families[familyID] {
- delete(s.tokens, tokenHash)
- for _, tokenSet := range s.userSets {
- delete(tokenSet, tokenHash)
- }
- }
- delete(s.families, familyID)
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) AddToUserTokenSet(_ context.Context, userID int64, tokenHash string, _ time.Duration) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.userSets[userID] == nil {
- s.userSets[userID] = make(map[string]struct{})
- }
- s.userSets[userID][tokenHash] = struct{}{}
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) AddToFamilyTokenSet(_ context.Context, familyID string, tokenHash string, _ time.Duration) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.families[familyID] == nil {
- s.families[familyID] = make(map[string]struct{})
- }
- s.families[familyID][tokenHash] = struct{}{}
- return nil
- }
- func (s *emailBindRefreshTokenCacheStub) GetUserTokenHashes(_ context.Context, userID int64) ([]string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- tokenSet := s.userSets[userID]
- out := make([]string, 0, len(tokenSet))
- for tokenHash := range tokenSet {
- out = append(out, tokenHash)
- }
- return out, nil
- }
- func (s *emailBindRefreshTokenCacheStub) GetFamilyTokenHashes(_ context.Context, familyID string) ([]string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- tokenSet := s.families[familyID]
- out := make([]string, 0, len(tokenSet))
- for tokenHash := range tokenSet {
- out = append(out, tokenHash)
- }
- return out, nil
- }
- func (s *emailBindRefreshTokenCacheStub) IsTokenInFamily(_ context.Context, familyID string, tokenHash string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- _, ok := s.families[familyID][tokenHash]
- return ok, nil
- }
- type emailBindUserRepoStub struct {
- mu sync.Mutex
- usersByID map[int64]*service.User
- usersByEmail map[string]*service.User
- }
- func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub {
- cloned := cloneEmailBindUser(user)
- return &emailBindUserRepoStub{
- usersByID: map[int64]*service.User{
- cloned.ID: cloned,
- },
- usersByEmail: map[string]*service.User{
- cloned.Email: cloned,
- },
- }
- }
- func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil }
- func (s *emailBindUserRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
- return s.Create(ctx, user)
- }
- func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- user, ok := s.usersByID[id]
- if !ok {
- return nil, service.ErrUserNotFound
- }
- return cloneEmailBindUser(user), nil
- }
- func (s *emailBindUserRepoStub) GetByEmail(_ context.Context, email string) (*service.User, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- user, ok := s.usersByEmail[email]
- if !ok {
- return nil, service.ErrUserNotFound
- }
- return cloneEmailBindUser(user), nil
- }
- func (s *emailBindUserRepoStub) GetFirstAdmin(context.Context) (*service.User, error) {
- panic("unexpected GetFirstAdmin call")
- }
- func (s *emailBindUserRepoStub) Update(_ context.Context, user *service.User, _ service.UserUpdateFields) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- existing, ok := s.usersByID[user.ID]
- if !ok {
- return service.ErrUserNotFound
- }
- delete(s.usersByEmail, existing.Email)
- cloned := cloneEmailBindUser(user)
- s.usersByID[user.ID] = cloned
- s.usersByEmail[cloned.Email] = cloned
- return nil
- }
- func (s *emailBindUserRepoStub) Delete(context.Context, int64) error { return nil }
- func (s *emailBindUserRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
- return nil, nil
- }
- func (s *emailBindUserRepoStub) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
- panic("unexpected UpsertUserAvatar call")
- }
- func (s *emailBindUserRepoStub) DeleteUserAvatar(context.Context, int64) error {
- panic("unexpected DeleteUserAvatar call")
- }
- func (s *emailBindUserRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
- panic("unexpected List call")
- }
- func (s *emailBindUserRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
- panic("unexpected ListWithFilters call")
- }
- func (s *emailBindUserRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
- return map[int64]*time.Time{}, nil
- }
- func (s *emailBindUserRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
- return nil, nil
- }
- func (s *emailBindUserRepoStub) UpdateUserLastActiveAt(context.Context, int64, time.Time) error {
- return nil
- }
- func (s *emailBindUserRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
- func (s *emailBindUserRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
- func (s *emailBindUserRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
- func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- _, ok := s.usersByEmail[email]
- return ok, nil
- }
- func (s *emailBindUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
- panic("unexpected AdjustBalance call")
- }
- func (s *emailBindUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
- panic("unexpected SetBalance call")
- }
- func (s *emailBindUserRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- identity := service.NormalizeEmailForAliasDedup(email)
- for stored := range s.usersByEmail {
- if service.NormalizeEmailForAliasDedup(stored) == identity {
- return true, nil
- }
- }
- return false, nil
- }
- func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
- return 0, nil
- }
- func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
- return 0, nil
- }
- func (s *emailBindUserRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
- return 0, nil
- }
- func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
- return 0, nil
- }
- func (s *emailBindUserRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error {
- return nil
- }
- func (s *emailBindUserRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
- return nil
- }
- func (s *emailBindUserRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
- return nil, nil
- }
- func (s *emailBindUserRepoStub) UnbindUserAuthProvider(context.Context, int64, string) error {
- return nil
- }
- func (s *emailBindUserRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
- func (s *emailBindUserRepoStub) EnableTotp(context.Context, int64) error { return nil }
- func (s *emailBindUserRepoStub) DisableTotp(context.Context, int64) error { return nil }
- func (s *emailBindUserRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
- return s.GetByID(ctx, id)
- }
- func cloneEmailBindUser(user *service.User) *service.User {
- if user == nil {
- return nil
- }
- cloned := *user
- return &cloned
- }
|