feat(auth): reclaim stale identities and refresh profile UI

This commit is contained in:
IanShaw027
2026-04-21 07:48:24 -07:00
parent c0371e9104
commit d5819181ea
16 changed files with 633 additions and 105 deletions

View File

@@ -12,7 +12,9 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
"github.com/Wei-Shaw/sub2api/ent/predicate"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
@@ -292,13 +294,57 @@ func normalizeEmailAuthIdentitySubject(email string) string {
}
func (r *userRepository) Delete(ctx context.Context, id int64) error {
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
var txClient *dbent.Client
if err == nil {
defer func() { _ = tx.Rollback() }()
txClient = tx.Client()
} else {
txClient = r.client
}
identityIDs, err := txClient.AuthIdentity.Query().
Where(authidentity.UserIDEQ(id)).
IDs(ctx)
if err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
if len(identityIDs) > 0 {
if _, err := txClient.IdentityAdoptionDecision.Update().
Where(identityadoptiondecision.IdentityIDIn(identityIDs...)).
ClearIdentityID().
Save(ctx); err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
if _, err := txClient.AuthIdentityChannel.Delete().
Where(authidentitychannel.IdentityIDIn(identityIDs...)).
Exec(ctx); err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
if _, err := txClient.AuthIdentity.Delete().
Where(authidentity.UserIDEQ(id)).
Exec(ctx); err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
}
affected, err := txClient.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
if err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
if affected == 0 {
return service.ErrUserNotFound
}
if tx != nil {
if err := tx.Commit(); err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}
}
return nil
}
@@ -645,15 +691,17 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
}
func userEmailLookupPredicate(email string) predicate.User {
normalized := strings.TrimSpace(email)
normalized := strings.ToLower(strings.TrimSpace(email))
if normalized == "" {
return dbuser.EmailEQ(email)
}
return predicate.User(func(s *entsql.Selector) {
s.Where(entsql.ExprP(
fmt.Sprintf("LOWER(TRIM(%s)) = LOWER(TRIM(?))", s.C(dbuser.FieldEmail)),
normalized,
))
s.Where(entsql.P(func(b *entsql.Builder) {
b.WriteString("LOWER(TRIM(").
Ident(s.C(dbuser.FieldEmail)).
WriteString(")) = ").
Arg(normalized)
}))
})
}

View File

@@ -8,6 +8,8 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite"
@@ -124,11 +126,27 @@ func (s *UserRepoSuite) TestGetByEmail() {
s.Require().Equal(user.ID, got.ID)
}
func (s *UserRepoSuite) TestGetByEmail_NormalizesSpacingAndCaseOnPostgres() {
user := s.mustCreateUser(&service.User{Email: " Legacy@Example.com "})
got, err := s.repo.GetByEmail(s.ctx, " legacy@example.com ")
s.Require().NoError(err, "GetByEmail normalized lookup")
s.Require().Equal(user.ID, got.ID)
}
func (s *UserRepoSuite) TestGetByEmail_NotFound() {
_, err := s.repo.GetByEmail(s.ctx, "nonexistent@test.com")
s.Require().Error(err, "expected error for non-existent email")
}
func (s *UserRepoSuite) TestExistsByEmail_NormalizesSpacingAndCaseOnPostgres() {
s.mustCreateUser(&service.User{Email: " Legacy@Example.com "})
exists, err := s.repo.ExistsByEmail(s.ctx, " LEGACY@example.com ")
s.Require().NoError(err, "ExistsByEmail normalized lookup")
s.Require().True(exists)
}
func (s *UserRepoSuite) TestUpdate() {
user := s.mustCreateUser(&service.User{Email: "update@test.com", Username: "original"})
@@ -152,6 +170,39 @@ func (s *UserRepoSuite) TestDelete() {
s.Require().Error(err, "expected error after delete")
}
func (s *UserRepoSuite) TestDeleteRemovesAuthIdentitiesAndChannels() {
user := s.mustCreateUser(&service.User{Email: "delete-oauth@test.com"})
identity, err := s.client.AuthIdentity.Create().
SetUserID(user.ID).
SetProviderType("linuxdo").
SetProviderKey("linuxdo").
SetProviderSubject("delete-oauth-subject").
Save(s.ctx)
s.Require().NoError(err)
_, err = s.client.AuthIdentityChannel.Create().
SetIdentityID(identity.ID).
SetProviderType("wechat").
SetProviderKey("wechat").
SetChannel("open").
SetChannelAppID("app-id").
SetChannelSubject("openid-123").
Save(s.ctx)
s.Require().NoError(err)
err = s.repo.Delete(s.ctx, user.ID)
s.Require().NoError(err)
identityCount, err := s.client.AuthIdentity.Query().Where(authidentity.UserIDEQ(user.ID)).Count(s.ctx)
s.Require().NoError(err)
s.Require().Zero(identityCount)
channelCount, err := s.client.AuthIdentityChannel.Query().Where(authidentitychannel.IdentityIDEQ(identity.ID)).Count(s.ctx)
s.Require().NoError(err)
s.Require().Zero(channelCount)
}
// --- List / ListWithFilters ---
func (s *UserRepoSuite) TestList() {