diff --git a/.env.example b/.env.example index b4946b2..e3f8238 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/evening_detective_server?sslmode=disable +# local отключает SMTP, фоновые письма и внешний webhook регистрации команд. +# Локальные учётные записи создаются отдельно; почтовый сброс пароля недоступен. +APP_MODE=online + # Заполните данными внешнего SMTP-релея; для локальных тестов можно оставить пустыми. SMTP_HOST= SMTP_PORT=465 diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index cb25ef0..b1400cc 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -80,6 +80,7 @@ const grpcMsgLimit = 257 << 20 func main() { _ = godotenv.Load() + localMode := os.Getenv("APP_MODE") == "local" dbpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { @@ -98,15 +99,18 @@ func main() { smtpTimeout = d } } - emailSender := email_sender.NewSender( - os.Getenv("SMTP_HOST"), - os.Getenv("SMTP_PORT"), - os.Getenv("SMTP_USER"), - os.Getenv("SMTP_PASSWORD"), - "Вечерний детектив", - os.Getenv("SMTP_REPLY_TO"), - smtpTimeout, - ) + var emailSender email_sender.IEmailSender + if !localMode { + emailSender = email_sender.NewSender( + os.Getenv("SMTP_HOST"), + os.Getenv("SMTP_PORT"), + os.Getenv("SMTP_USER"), + os.Getenv("SMTP_PASSWORD"), + "Вечерний детектив", + os.Getenv("SMTP_REPLY_TO"), + smtpTimeout, + ) + } processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET")) refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool) usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL")) @@ -167,8 +171,10 @@ func main() { participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL")) ticketService := ticket_service.NewTicketService(ticketRepo, os.Getenv("APP_BASE_URL")) - go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) - go reviewService.RunReviewReminderEmails(context.Background(), emailSender) + if !localMode { + go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) + go reviewService.RunReviewReminderEmails(context.Background(), emailSender) + } lis, err := net.Listen("tcp", ":8080") if err != nil { @@ -289,7 +295,9 @@ func main() { } mainMux := http.NewServeMux() - mainMux.Handle("POST /api/games/{id}/registrations", app.TeamRegistrationHandler(os.Getenv("YANDEX_FORMS_TOKEN"), teamService.RegisterTeam)) + if !localMode { + mainMux.Handle("POST /api/games/{id}/registrations", app.TeamRegistrationHandler(os.Getenv("YANDEX_FORMS_TOKEN"), teamService.RegisterTeam)) + } docsHandler := v5emb.New("API", "/swagger/doc.json", "/docs/") mainMux.Handle("/docs/", docsHandler) diff --git a/internal/app/local_errors_test.go b/internal/app/local_errors_test.go new file mode 100644 index 0000000..eecb3a4 --- /dev/null +++ b/internal/app/local_errors_test.go @@ -0,0 +1,22 @@ +package app + +import ( + "fmt" + "testing" + + "evening_detective_server/internal/services/users_service" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestLocalAccountErrorStatus(t *testing.T) { + for _, test := range []struct{ name string }{{"email unavailable"}} { + t.Run(test.name, func(t *testing.T) { + err := userErrorStatus(fmt.Errorf("private detail: %w", users_service.ErrEmailUnavailable), "account failed") + if status.Code(err) != codes.FailedPrecondition || status.Convert(err).Message() != users_service.ErrEmailUnavailable.Error() { + t.Fatalf("status = %v", err) + } + }) + } +} diff --git a/internal/app/user_errors.go b/internal/app/user_errors.go index a3df9e4..55992cb 100644 --- a/internal/app/user_errors.go +++ b/internal/app/user_errors.go @@ -11,6 +11,8 @@ import ( func userErrorStatus(err error, internalMessage string) error { switch { + case errors.Is(err, users_service.ErrEmailUnavailable): + return status.Error(codes.FailedPrecondition, users_service.ErrEmailUnavailable.Error()) case errors.Is(err, users_service.ErrUserNotFound): return status.Error(codes.NotFound, err.Error()) case errors.Is(err, users_service.ErrTermsNotAccepted), errors.Is(err, users_service.ErrInvalidEmail), errors.Is(err, users_service.ErrInvalidRole), errors.Is(err, users_service.ErrInvalidPasswordChange): diff --git a/internal/services/users_service/local_test.go b/internal/services/users_service/local_test.go new file mode 100644 index 0000000..1d3aaae --- /dev/null +++ b/internal/services/users_service/local_test.go @@ -0,0 +1,34 @@ +package users_service + +import ( + "context" + "errors" + "testing" +) + +func TestLocalAccountOperationsDoNotChangeUsers(t *testing.T) { + for _, test := range []struct { + name string + run func(*UsersService) error + }{ + {"signup", func(s *UsersService) error { + return s.AddUser(context.Background(), "Local", "local@example.com", true, true, "", "") + }}, + {"password reset", func(s *UsersService) error { + return s.RefreshPassword(context.Background(), "local@example.com") + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newFakeRepo() + repo.seed("local@example.com", "unchanged-hash") + s := &UsersService{usersRepo: repo} + if err := test.run(s); !errors.Is(err, ErrEmailUnavailable) { + t.Fatalf("error = %v", err) + } + user, err := repo.GetUserByEmail(context.Background(), "local@example.com") + if err != nil || repo.userCount() != 1 || user.PasswordHash != "unchanged-hash" { + t.Fatalf("account changed: user=%v, error=%v", user, err) + } + }) + } +} diff --git a/internal/services/users_service/service.go b/internal/services/users_service/service.go index ebebe3a..aa37906 100644 --- a/internal/services/users_service/service.go +++ b/internal/services/users_service/service.go @@ -76,6 +76,8 @@ func NewUsersService( } var ( + // ErrEmailUnavailable indicates that email-based account operations are disabled. + ErrEmailUnavailable = errors.New("почтовые операции отключены на локальной площадке; обратитесь к организатору") ErrTermsNotAccepted = errors.New("необходимо принять пользовательское соглашение и политику конфиденциальности") // ErrInvalidEmail indicates that an email address is invalid. ErrInvalidEmail = errors.New("некорректный email") @@ -99,6 +101,9 @@ func (s *UsersService) AddUser( ip string, userAgent string, ) error { + if s.emailSender == nil { + return ErrEmailUnavailable + } if !acceptTerms || !acceptPrivacy { return ErrTermsNotAccepted } @@ -184,6 +189,9 @@ func (s *UsersService) RefreshPassword( ctx context.Context, email string, ) error { + if s.emailSender == nil { + return ErrEmailUnavailable + } email = strings.TrimSpace(email) if err := validateEmail(email); err != nil { return err