Commit bd0801a8 authored by PMExtra's avatar PMExtra
Browse files

feat(registration): add email domain whitelist policy

parent ba6de4c4
...@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
...@@ -132,6 +133,7 @@ type UpdateSettingsRequest struct { ...@@ -132,6 +133,7 @@ type UpdateSettingsRequest struct {
// 注册设置 // 注册设置
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
...@@ -428,6 +430,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -428,6 +430,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
settings := &service.SystemSettings{ settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: req.PromoCodeEnabled, PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled, PasswordResetEnabled: req.PasswordResetEnabled,
InvitationCodeEnabled: req.InvitationCodeEnabled, InvitationCodeEnabled: req.InvitationCodeEnabled,
...@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled, RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
...@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EmailVerifyEnabled != after.EmailVerifyEnabled { if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
changed = append(changed, "email_verify_enabled") changed = append(changed, "email_verify_enabled")
} }
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
changed = append(changed, "registration_email_suffix_whitelist")
}
if before.PasswordResetEnabled != after.PasswordResetEnabled { if before.PasswordResetEnabled != after.PasswordResetEnabled {
changed = append(changed, "password_reset_enabled") changed = append(changed, "password_reset_enabled")
} }
...@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto ...@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
return normalized return normalized
} }
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
......
...@@ -19,6 +19,7 @@ type CustomMenuItem struct { ...@@ -19,6 +19,7 @@ type CustomMenuItem struct {
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
...@@ -90,6 +91,7 @@ type DefaultSubscriptionSetting struct { ...@@ -90,6 +91,7 @@ type DefaultSubscriptionSetting struct {
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
......
...@@ -34,6 +34,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -34,6 +34,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
response.Success(c, dto.PublicSettings{ response.Success(c, dto.PublicSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
......
...@@ -448,6 +448,7 @@ func TestAPIContracts(t *testing.T) { ...@@ -448,6 +448,7 @@ func TestAPIContracts(t *testing.T) {
deps.settingRepo.SetAll(map[string]string{ deps.settingRepo.SetAll(map[string]string{
service.SettingKeyRegistrationEnabled: "true", service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyEmailVerifyEnabled: "false", service.SettingKeyEmailVerifyEnabled: "false",
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
service.SettingKeyPromoCodeEnabled: "true", service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeySMTPHost: "smtp.example.com", service.SettingKeySMTPHost: "smtp.example.com",
...@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) { ...@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
"data": { "data": {
"registration_enabled": true, "registration_enabled": true,
"email_verify_enabled": false, "email_verify_enabled": false,
"registration_email_suffix_whitelist": [],
"promo_code_enabled": true, "promo_code_enabled": true,
"password_reset_enabled": false, "password_reset_enabled": false,
"totp_enabled": false, "totp_enabled": false,
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strconv"
"strings" "strings"
"time" "time"
...@@ -33,6 +34,7 @@ var ( ...@@ -33,6 +34,7 @@ var (
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
...@@ -115,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw ...@@ -115,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
if isReservedEmail(email) { if isReservedEmail(email) {
return "", nil, ErrEmailReserved return "", nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return "", nil, err
}
// 检查是否需要邀请码 // 检查是否需要邀请码
var invitationRedeemCode *RedeemCode var invitationRedeemCode *RedeemCode
...@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { ...@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
if isReservedEmail(email) { if isReservedEmail(email) {
return ErrEmailReserved return ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
...@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S ...@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
if isReservedEmail(email) { if isReservedEmail(email) {
return nil, ErrEmailReserved return nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return nil, err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
...@@ -624,6 +635,32 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int ...@@ -624,6 +635,32 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int
} }
} }
func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error {
if s.settingService == nil {
return nil
}
whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx)
if !IsRegistrationEmailSuffixAllowed(email, whitelist) {
return buildEmailSuffixNotAllowedError(whitelist)
}
return nil
}
func buildEmailSuffixNotAllowedError(whitelist []string) error {
if len(whitelist) == 0 {
return ErrEmailSuffixNotAllowed
}
allowed := strings.Join(whitelist, ", ")
return infraerrors.BadRequest(
"EMAIL_SUFFIX_NOT_ALLOWED",
fmt.Sprintf("email suffix is not allowed, allowed suffixes: %s", allowed),
).WithMetadata(map[string]string{
"allowed_suffixes": strings.Join(whitelist, ","),
"allowed_suffix_count": strconv.Itoa(len(whitelist)),
})
}
// ValidateToken 验证JWT token并返回用户声明 // ValidateToken 验证JWT token并返回用户声明
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。 // 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
......
...@@ -9,6 +9,7 @@ import ( ...@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) { ...@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
require.ErrorIs(t, err, ErrEmailReserved) require.ErrorIs(t, err, ErrEmailReserved)
} }
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
_, _, err := service.Register(context.Background(), "user@other.com", "password")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"])
}
func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
repo := &userRepoStub{nextID: 8}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["example.com"]`,
}, nil)
_, user, err := service.Register(context.Background(), "user@example.com", "password")
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, int64(8), user.ID)
}
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
err := service.SendVerifyCode(context.Background(), "user@other.com")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
}
func TestAuthService_Register_CreateError(t *testing.T) { func TestAuthService_Register_CreateError(t *testing.T) {
repo := &userRepoStub{createErr: errors.New("create failed")} repo := &userRepoStub{createErr: errors.New("create failed")}
service := newAuthService(repo, map[string]string{ service := newAuthService(repo, map[string]string{
......
...@@ -76,6 +76,7 @@ const ( ...@@ -76,6 +76,7 @@ const (
// 注册设置 // 注册设置
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
......
package service
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var registrationEmailDomainPattern = regexp.MustCompile(
`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`,
)
// RegistrationEmailSuffix extracts normalized suffix in "@domain" form.
func RegistrationEmailSuffix(email string) string {
_, domain, ok := splitEmailForPolicy(email)
if !ok {
return ""
}
return "@" + domain
}
// IsRegistrationEmailSuffixAllowed checks whether an email is allowed by suffix whitelist.
// Empty whitelist means allow all.
func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool {
if len(whitelist) == 0 {
return true
}
suffix := RegistrationEmailSuffix(email)
if suffix == "" {
return false
}
for _, allowed := range whitelist {
if suffix == allowed {
return true
}
}
return false
}
// NormalizeRegistrationEmailSuffixWhitelist normalizes and validates suffix whitelist items.
func NormalizeRegistrationEmailSuffixWhitelist(raw []string) ([]string, error) {
return normalizeRegistrationEmailSuffixWhitelist(raw, true)
}
// ParseRegistrationEmailSuffixWhitelist parses persisted JSON into normalized suffixes.
// Invalid entries are ignored to keep old misconfigurations from breaking runtime reads.
func ParseRegistrationEmailSuffixWhitelist(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []string{}
}
normalized, _ := normalizeRegistrationEmailSuffixWhitelist(items, false)
if len(normalized) == 0 {
return []string{}
}
return normalized
}
func normalizeRegistrationEmailSuffixWhitelist(raw []string, strict bool) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
normalized, err := normalizeRegistrationEmailSuffix(item)
if err != nil {
if strict {
return nil, err
}
continue
}
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func normalizeRegistrationEmailSuffix(raw string) (string, error) {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return "", nil
}
domain := value
if strings.Contains(value, "@") {
if !strings.HasPrefix(value, "@") || strings.Count(value, "@") != 1 {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
domain = strings.TrimPrefix(value, "@")
}
if domain == "" || strings.Contains(domain, "@") || !registrationEmailDomainPattern.MatchString(domain) {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
return "@" + domain, nil
}
func splitEmailForPolicy(raw string) (local string, domain string, ok bool) {
email := strings.ToLower(strings.TrimSpace(raw))
local, domain, found := strings.Cut(email, "@")
if !found || local == "" || domain == "" || strings.Contains(domain, "@") {
return "", "", false
}
return local, domain, true
}
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeRegistrationEmailSuffixWhitelist(t *testing.T) {
got, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"example.com", "@EXAMPLE.COM", " @foo.bar "})
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestNormalizeRegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
_, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"@invalid_domain"})
require.Error(t, err)
}
func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) {
got := ParseRegistrationEmailSuffixWhitelist(`["example.com","@foo.bar","@invalid_domain"]`)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"}))
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"}))
require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{}))
}
...@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
keys := []string{ keys := []string{
SettingKeyRegistrationEnabled, SettingKeyRegistrationEnabled,
SettingKeyEmailVerifyEnabled, SettingKeyEmailVerifyEnabled,
SettingKeyRegistrationEmailSuffixWhitelist,
SettingKeyPromoCodeEnabled, SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled, SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled, SettingKeyInvitationCodeEnabled,
...@@ -144,10 +145,14 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -144,10 +145,14 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
// Password reset requires email verification to be enabled // Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
settings[SettingKeyRegistrationEmailSuffixWhitelist],
)
return &PublicSettings{ return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled, PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
...@@ -198,6 +203,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -198,6 +203,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
return &struct { return &struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
...@@ -221,6 +227,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -221,6 +227,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
}{ }{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
...@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
return err return err
} }
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
}
if normalizedWhitelist == nil {
normalizedWhitelist = []string{}
}
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
updates := make(map[string]string) updates := make(map[string]string)
// 注册设置 // 注册设置
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return fmt.Errorf("marshal registration email suffix whitelist: %w", err)
}
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
...@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { ...@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
return value == "true" return value == "true"
} }
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
if err != nil {
return []string{}
}
return ParseRegistrationEmailSuffixWhitelist(value)
}
// IsPromoCodeEnabled 检查是否启用优惠码功能 // IsPromoCodeEnabled 检查是否启用优惠码功能
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
...@@ -619,6 +648,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -619,6 +648,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
defaults := map[string]string{ defaults := map[string]string{
SettingKeyRegistrationEnabled: "true", SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false", SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeySiteName: "Sub2API", SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "", SettingKeySiteLogo: "",
...@@ -663,6 +693,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -663,6 +693,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result := &SystemSettings{ result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
......
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type settingPublicRepoStub struct {
values map[string]string
}
func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
panic("unexpected Get call")
}
func (s *settingPublicRepoStub) GetValue(ctx context.Context, key string) (string, error) {
panic("unexpected GetValue call")
}
func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) error {
panic("unexpected Set call")
}
func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *settingPublicRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *settingPublicRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *settingPublicRepoStub) Delete(ctx context.Context, key string) error {
panic("unexpected Delete call")
}
func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelist(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","@invalid_domain",""]`,
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, settings.RegistrationEmailSuffixWhitelist)
}
...@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou ...@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou
require.Nil(t, repo.updates) require.Nil(t, repo.updates)
} }
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Normalized(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar "},
})
require.NoError(t, err)
require.Equal(t, `["@example.com","@foo.bar"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist])
}
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"@invalid_domain"},
})
require.Error(t, err)
require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
}
func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) { func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`) got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`)
require.Equal(t, []DefaultSubscriptionSetting{ require.Equal(t, []DefaultSubscriptionSetting{
......
...@@ -3,6 +3,7 @@ package service ...@@ -3,6 +3,7 @@ package service
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
RegistrationEmailSuffixWhitelist []string
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
InvitationCodeEnabled bool InvitationCodeEnabled bool
...@@ -78,6 +79,7 @@ type DefaultSubscriptionSetting struct { ...@@ -78,6 +79,7 @@ type DefaultSubscriptionSetting struct {
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
RegistrationEmailSuffixWhitelist []string
PromoCodeEnabled bool PromoCodeEnabled bool
PasswordResetEnabled bool PasswordResetEnabled bool
InvitationCodeEnabled bool InvitationCodeEnabled bool
......
...@@ -18,6 +18,7 @@ export interface SystemSettings { ...@@ -18,6 +18,7 @@ export interface SystemSettings {
// Registration settings // Registration settings
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean
...@@ -86,6 +87,7 @@ export interface SystemSettings { ...@@ -86,6 +87,7 @@ export interface SystemSettings {
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
registration_enabled?: boolean registration_enabled?: boolean
email_verify_enabled?: boolean email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean promo_code_enabled?: boolean
password_reset_enabled?: boolean password_reset_enabled?: boolean
invitation_code_enabled?: boolean invitation_code_enabled?: boolean
......
...@@ -312,6 +312,9 @@ export default { ...@@ -312,6 +312,9 @@ export default {
passwordMinLength: 'Password must be at least 6 characters', passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.', loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.', registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
loginSuccess: 'Login successful! Welcome back.', loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.', reloginRequired: 'Session expired. Please log in again.',
...@@ -3528,6 +3531,11 @@ export default { ...@@ -3528,6 +3531,11 @@ export default {
enableRegistrationHint: 'Allow new users to register', enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification', emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations', emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
'Only email addresses from the specified domains can register (for example, @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
promoCode: 'Promo Code', promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration', promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration', invitationCode: 'Invitation Code Registration',
......
...@@ -312,6 +312,8 @@ export default { ...@@ -312,6 +312,8 @@ export default {
passwordMinLength: '密码至少需要 6 个字符', passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。', loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。', registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
loginSuccess: '登录成功!欢迎回来。', loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。', reloginRequired: '会话已过期,请重新登录。',
...@@ -3698,6 +3700,11 @@ export default { ...@@ -3698,6 +3700,11 @@ export default {
enableRegistrationHint: '允许新用户注册', enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证', emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱', emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
'仅允许使用指定域名的邮箱注册账号(例如 @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: '留空则不限制',
promoCode: '优惠码', promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码', promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册', invitationCode: '邀请码注册',
......
...@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
return { return {
registration_enabled: false, registration_enabled: false,
email_verify_enabled: false, email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true, promo_code_enabled: true,
password_reset_enabled: false, password_reset_enabled: false,
invitation_code_enabled: false, invitation_code_enabled: false,
......
...@@ -87,6 +87,7 @@ export interface CustomMenuItem { ...@@ -87,6 +87,7 @@ export interface CustomMenuItem {
export interface PublicSettings { export interface PublicSettings {
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean
......
import { describe, expect, it } from 'vitest'
import { buildAuthErrorMessage } from '@/utils/authError'
describe('buildAuthErrorMessage', () => {
it('prefers response detail message when available', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
detail: 'detailed message',
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('detailed message')
})
it('falls back to response message when detail is unavailable', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('plain message')
})
it('falls back to error.message when response payload is unavailable', () => {
const message = buildAuthErrorMessage(
{
message: 'error message'
},
{ fallback: 'fallback' }
)
expect(message).toBe('error message')
})
it('uses fallback when no message can be extracted', () => {
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
})
})
import { describe, expect, it } from 'vitest'
import {
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
normalizeRegistrationEmailSuffixWhitelist,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
expect(
normalizeRegistrationEmailSuffixDomains([
'@example.com',
'Example.com',
'',
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
])
).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
})
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
expect(
normalizeRegistrationEmailSuffixWhitelist([
'@Example.com',
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
])
).toEqual(['@example.com', '@foo.bar'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
})
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
})
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment