generated from VLADIMIR/template
updates email
This commit is contained in:
@@ -4,6 +4,8 @@ SMTP_HOST=mail.crabs-games.art
|
|||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_USER=evening_detective@crabs-games.art
|
SMTP_USER=evening_detective@crabs-games.art
|
||||||
SMTP_PASSWORD=your_password
|
SMTP_PASSWORD=your_password
|
||||||
|
# Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s
|
||||||
|
SMTP_TIMEOUT=10s
|
||||||
|
|
||||||
JWT_SECRET=your_secret
|
JWT_SECRET=your_secret
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ go.sum
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Локальные Go-кэши (используются, когда системный кэш недоступен)
|
||||||
|
.gocache/
|
||||||
|
.gotmp/
|
||||||
|
|
||||||
.VSCodeCounter/
|
.VSCodeCounter/
|
||||||
|
|
||||||
docker-compose-prod.yml
|
docker-compose-prod.yml
|
||||||
Binary file not shown.
@@ -28,6 +28,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -58,12 +59,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
usersRepo := users_repo.NewUserRepo(dbpool)
|
usersRepo := users_repo.NewUserRepo(dbpool)
|
||||||
passwordGenerator := password_generator.NewGenerator(8)
|
passwordGenerator := password_generator.NewGenerator(12)
|
||||||
|
smtpTimeout := 10 * time.Second
|
||||||
|
if v := os.Getenv("SMTP_TIMEOUT"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
smtpTimeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
emailSender := email_sender.NewSender(
|
emailSender := email_sender.NewSender(
|
||||||
os.Getenv("SMTP_HOST"),
|
os.Getenv("SMTP_HOST"),
|
||||||
os.Getenv("SMTP_PORT"),
|
os.Getenv("SMTP_PORT"),
|
||||||
os.Getenv("SMTP_USER"),
|
os.Getenv("SMTP_USER"),
|
||||||
os.Getenv("SMTP_PASSWORD"),
|
os.Getenv("SMTP_PASSWORD"),
|
||||||
|
smtpTimeout,
|
||||||
)
|
)
|
||||||
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
|
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
|
||||||
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
|
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ package email_sender
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sender struct {
|
type sender struct {
|
||||||
@@ -12,6 +18,9 @@ type sender struct {
|
|||||||
smtpPort string
|
smtpPort string
|
||||||
smtpUser string
|
smtpUser string
|
||||||
smtpPassword string
|
smtpPassword string
|
||||||
|
from string
|
||||||
|
timeout time.Duration
|
||||||
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSender(
|
func NewSender(
|
||||||
@@ -19,76 +28,131 @@ func NewSender(
|
|||||||
smtpPort string,
|
smtpPort string,
|
||||||
smtpUser string,
|
smtpUser string,
|
||||||
smtpPassword string,
|
smtpPassword string,
|
||||||
|
timeout time.Duration,
|
||||||
) IEmailSender {
|
) IEmailSender {
|
||||||
|
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
|
||||||
|
from := smtpUser
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
return &sender{
|
return &sender{
|
||||||
smtpHost: smtpHost,
|
smtpHost: smtpHost,
|
||||||
smtpPort: smtpPort,
|
smtpPort: smtpPort,
|
||||||
smtpUser: smtpUser,
|
smtpUser: smtpUser,
|
||||||
smtpPassword: smtpPassword,
|
smtpPassword: smtpPassword,
|
||||||
|
from: from,
|
||||||
|
timeout: timeout,
|
||||||
|
// Проверка имени сервера включена всегда; поле переопределяется
|
||||||
|
// только в тестах (свой RootCAs для самоподписанного сертификата).
|
||||||
|
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sender) Send(_ context.Context, message Message) error {
|
// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и
|
||||||
emailMessage := []byte(fmt.Sprintf(
|
// таймаута: соединение устанавливается через DialContext, а все фазы SMTP-
|
||||||
"To: %s\r\n"+
|
// диалога ограничены общим deadline, производным от ctx.
|
||||||
"Subject: %s\r\n"+
|
func (s *sender) Send(ctx context.Context, message Message) error {
|
||||||
"MIME-Version: 1.0\r\n"+
|
// Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают
|
||||||
"Content-Type: text/plain; charset=utf-8\r\n"+
|
// формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.).
|
||||||
"\r\n"+
|
to := sanitizeHeader(message.To)
|
||||||
"%s\r\n",
|
subject := sanitizeHeader(message.Subject)
|
||||||
message.To,
|
if err := validateMessage(to, subject); err != nil {
|
||||||
message.Subject,
|
|
||||||
message.Body,
|
|
||||||
))
|
|
||||||
|
|
||||||
// Настраиваем TLS
|
|
||||||
tlsConfig := &tls.Config{
|
|
||||||
ServerName: s.smtpHost,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключаемся к серверу
|
|
||||||
conn, err := tls.Dial("tcp", s.smtpHost+":"+s.smtpPort, tlsConfig)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
body := buildMessage(s.from, to, subject, message.Body)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(s.smtpHost, s.smtpPort)
|
||||||
|
rawConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("email: smtp dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer rawConn.Close()
|
||||||
|
|
||||||
|
conn := tls.Client(rawConn, s.tlsConfig)
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
// Единый бюджет времени на весь SMTP-диалог: зависший сервер не
|
||||||
|
// должен держать запрос дольше таймаута.
|
||||||
|
if err = conn.SetDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("email: set deadline: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем SMTP клиент
|
|
||||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("email: smtp greeting: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Quit()
|
// Close гарантированно закрывает соединение при раннем выходе; в конце
|
||||||
|
// штатного пути вместо него вызывается Quit.
|
||||||
|
defer client.Close() //nolint:errcheck
|
||||||
|
|
||||||
// Аутентификация
|
|
||||||
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||||
if err = client.Auth(auth); err != nil {
|
if err = client.Auth(auth); err != nil {
|
||||||
return err
|
return fmt.Errorf("email: smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Mail(s.from); err != nil {
|
||||||
|
return fmt.Errorf("email: smtp mail from: %w", err)
|
||||||
|
}
|
||||||
|
if err = client.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("email: smtp rcpt to: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Указываем отправителя
|
|
||||||
if err = client.Mail(s.smtpUser); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Указываем получателя
|
|
||||||
if err = client.Rcpt(message.To); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем данные письма
|
|
||||||
w, err := client.Data()
|
w, err := client.Data()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("email: smtp data: %w", err)
|
||||||
}
|
}
|
||||||
_, err = w.Write(emailMessage)
|
if _, err = w.Write(body); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("email: smtp write: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
err = w.Close()
|
if err = w.Close(); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("email: smtp data close: %w", err)
|
||||||
return err
|
}
|
||||||
|
if err = client.Quit(); err != nil {
|
||||||
|
return fmt.Errorf("email: smtp quit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст.
|
||||||
|
// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица
|
||||||
|
// иначе может быть испорчена промежуточными серверами).
|
||||||
|
func buildMessage(from, to, subject, body string) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(from) + len(to) + len(subject) + len(body) + 128)
|
||||||
|
b.WriteString("From: ")
|
||||||
|
b.WriteString(from)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString("To: ")
|
||||||
|
b.WriteString(to)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString("Subject: ")
|
||||||
|
b.WriteString(mime.QEncoding.Encode("utf-8", subject))
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(body)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeHeader удаляет символы, ломающие структуру заголовков письма
|
||||||
|
// (CRLF-инъекция заголовков, NUL).
|
||||||
|
func sanitizeHeader(s string) string {
|
||||||
|
r := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMessage проверяет адрес получателя и наличие темы до обращения к
|
||||||
|
// SMTP-серверу.
|
||||||
|
func validateMessage(to, subject string) error {
|
||||||
|
if _, err := mail.ParseAddress(to); err != nil {
|
||||||
|
return fmt.Errorf("email: некорректный адрес получателя: %w", err)
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
return errors.New("email: пустая тема письма")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package email_sender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на
|
||||||
|
// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми
|
||||||
|
// телами писем. При hang=true сервер отправляет приветствие и молчит —
|
||||||
|
// для проверки таймаутов. Сертификат генерируется один на тест и
|
||||||
|
// передаётся и серверу, и клиенту (testSender).
|
||||||
|
func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = ln.Close() })
|
||||||
|
|
||||||
|
messages = make(chan []byte, 8)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hang {
|
||||||
|
go func() {
|
||||||
|
// Приветствие отправляем, дальше молчим.
|
||||||
|
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
|
||||||
|
}()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handleSMTPConn(conn, messages)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ln.Addr().String(), messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp:
|
||||||
|
// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT.
|
||||||
|
func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
write := func(s string) { _, _ = conn.Write([]byte(s)) }
|
||||||
|
|
||||||
|
write("220 test.example.com ESMTP\r\n")
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(cmd, "EHLO"):
|
||||||
|
write("250-test.example.com\r\n250-AUTH PLAIN\r\n250 OK\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "AUTH"):
|
||||||
|
write("235 2.7.0 Authentication successful\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "MAIL"):
|
||||||
|
write("250 OK\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "RCPT"):
|
||||||
|
write("250 OK\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "DATA"):
|
||||||
|
write("354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||||
|
var body []byte
|
||||||
|
for {
|
||||||
|
b, err := r.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body = append(body, b...)
|
||||||
|
if string(b) == ".\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case messages <- body:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
write("250 OK\r\n")
|
||||||
|
case strings.HasPrefix(cmd, "QUIT"):
|
||||||
|
write("221 Bye\r\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
|
||||||
|
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
|
||||||
|
// тест мог добавить его в корни доверия клиента.
|
||||||
|
func testTLSCert(t *testing.T) tls.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse certificate: %v", err)
|
||||||
|
}
|
||||||
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSender собирает sender, доверяющий самоподписанному сертификату
|
||||||
|
// тестового SMTP-сервера (в проде NewSender использует системные корни).
|
||||||
|
func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender {
|
||||||
|
t.Helper()
|
||||||
|
host, port := splitHostPort(t, addr)
|
||||||
|
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(cert.Leaf)
|
||||||
|
|
||||||
|
return &sender{
|
||||||
|
smtpHost: host,
|
||||||
|
smtpPort: port,
|
||||||
|
smtpUser: "sender@example.com",
|
||||||
|
smtpPassword: "secret",
|
||||||
|
from: "sender@example.com",
|
||||||
|
timeout: timeout,
|
||||||
|
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(t *testing.T, addr string) (host, port string) {
|
||||||
|
t.Helper()
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split host port: %v", err)
|
||||||
|
}
|
||||||
|
return host, port
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendSuccess(t *testing.T) {
|
||||||
|
cert := testTLSCert(t)
|
||||||
|
addr, messages := startSMTPTestServer(t, false, cert)
|
||||||
|
s := testSender(t, addr, 5*time.Second, cert)
|
||||||
|
|
||||||
|
err := s.Send(context.Background(), Message{
|
||||||
|
To: "user@example.com",
|
||||||
|
Subject: "Привет, детектив!",
|
||||||
|
Body: "Текст письма",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case data := <-messages:
|
||||||
|
raw := string(data)
|
||||||
|
for _, want := range []string{
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: user@example.com",
|
||||||
|
"Subject: =?utf-8?q?",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"\r\n\r\n",
|
||||||
|
"Текст письма",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(raw, want) {
|
||||||
|
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("письмо не получено сервером")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendRejectsCRLFInjection(t *testing.T) {
|
||||||
|
cert := testTLSCert(t)
|
||||||
|
addr, messages := startSMTPTestServer(t, false, cert)
|
||||||
|
s := testSender(t, addr, 5*time.Second, cert)
|
||||||
|
|
||||||
|
// CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо
|
||||||
|
// должно быть отклонено до обращения к SMTP-серверу.
|
||||||
|
err := s.Send(context.Background(), Message{
|
||||||
|
To: "user@example.com\r\nBcc: victim@example.com",
|
||||||
|
Subject: "Test",
|
||||||
|
Body: "body",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка для адреса с CRLF")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-messages:
|
||||||
|
t.Fatal("письмо не должно было уйти на сервер")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendContextCancelled(t *testing.T) {
|
||||||
|
cert := testTLSCert(t)
|
||||||
|
addr, _ := startSMTPTestServer(t, false, cert)
|
||||||
|
s := testSender(t, addr, 10*time.Second, cert)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := s.Send(ctx, Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка при отменённом контексте")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("отмена контекста должна прерывать отправку быстро, заняло %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendTimeout(t *testing.T) {
|
||||||
|
cert := testTLSCert(t)
|
||||||
|
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
|
||||||
|
s := testSender(t, addr, 500*time.Millisecond, cert)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := s.Send(context.Background(), Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка таймаута")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("таймаут должен сработать быстро, заняло %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMessage(t *testing.T) {
|
||||||
|
raw := string(buildMessage("sender@example.com", "user@example.com", "Привет", "текст"))
|
||||||
|
for _, want := range []string{
|
||||||
|
"From: sender@example.com",
|
||||||
|
"To: user@example.com",
|
||||||
|
"Subject: =?utf-8?q?",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"\r\n\r\n",
|
||||||
|
"текст",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(raw, want) {
|
||||||
|
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHeader(t *testing.T) {
|
||||||
|
got := sanitizeHeader("a@b.c\r\nBcc: x@y.z\x00")
|
||||||
|
want := "a@b.cBcc: x@y.z"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("sanitizeHeader = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMessage(t *testing.T) {
|
||||||
|
if err := validateMessage("user@example.com", "тема"); err != nil {
|
||||||
|
t.Errorf("валидное письмо отклонено: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateMessage("not-an-email", "тема"); err == nil {
|
||||||
|
t.Error("невалидный адрес принят")
|
||||||
|
}
|
||||||
|
if err := validateMessage("user@example.com", ""); err == nil {
|
||||||
|
t.Error("пустая тема принята")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ import (
|
|||||||
"evening_detective_server/internal/repos/user_agreements_repo"
|
"evening_detective_server/internal/repos/user_agreements_repo"
|
||||||
"evening_detective_server/internal/repos/users_repo"
|
"evening_detective_server/internal/repos/users_repo"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -29,8 +32,28 @@ const (
|
|||||||
privacyVersion = "1.0"
|
privacyVersion = "1.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// usersRepository — граница доступа к данным пользователей, реализуемая
|
||||||
|
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
|
||||||
|
type usersRepository interface {
|
||||||
|
AddUserWithAgreements(
|
||||||
|
ctx context.Context,
|
||||||
|
username string,
|
||||||
|
email string,
|
||||||
|
passwordHash string,
|
||||||
|
roles []string,
|
||||||
|
agreements []user_agreements_repo.Agreement,
|
||||||
|
) (int, error)
|
||||||
|
UpdateUserPassword(ctx context.Context, email string, passwordHash string) error
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (*repos.User, error)
|
||||||
|
GetUserByID(ctx context.Context, id int) (*repos.User, error)
|
||||||
|
GetUsers(ctx context.Context) ([]*repos.User, error)
|
||||||
|
AddUserRole(ctx context.Context, userId int, role string) error
|
||||||
|
DeleteUserRole(ctx context.Context, userId int, role string) error
|
||||||
|
DeleteUser(ctx context.Context, userId int) error
|
||||||
|
}
|
||||||
|
|
||||||
type UsersService struct {
|
type UsersService struct {
|
||||||
usersRepo *users_repo.UsersRepo
|
usersRepo usersRepository
|
||||||
passwordGenerator password_generator.IPasswordGenerator
|
passwordGenerator password_generator.IPasswordGenerator
|
||||||
emailSender email_sender.IEmailSender
|
emailSender email_sender.IEmailSender
|
||||||
processorJWT processor_jwt.IProcessorJWT
|
processorJWT processor_jwt.IProcessorJWT
|
||||||
@@ -38,7 +61,7 @@ type UsersService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewUsersService(
|
func NewUsersService(
|
||||||
usersRepo *users_repo.UsersRepo,
|
usersRepo usersRepository,
|
||||||
passwordGenerator password_generator.IPasswordGenerator,
|
passwordGenerator password_generator.IPasswordGenerator,
|
||||||
emailSender email_sender.IEmailSender,
|
emailSender email_sender.IEmailSender,
|
||||||
processorJWT processor_jwt.IProcessorJWT,
|
processorJWT processor_jwt.IProcessorJWT,
|
||||||
@@ -69,6 +92,11 @@ func (s *UsersService) AddUser(
|
|||||||
return ErrTermsNotAccepted
|
return ErrTermsNotAccepted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email = normalizeEmail(email)
|
||||||
|
if err := validateEmail(email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
password, err := s.passwordGenerator.Generate()
|
password, err := s.passwordGenerator.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -79,7 +107,7 @@ func (s *UsersService) AddUser(
|
|||||||
}
|
}
|
||||||
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
||||||
// без записей о согласиях пользователь не существует.
|
// без записей о согласиях пользователь не существует.
|
||||||
_, err = s.usersRepo.AddUserWithAgreements(
|
id, err := s.usersRepo.AddUserWithAgreements(
|
||||||
ctx,
|
ctx,
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
@@ -104,13 +132,25 @@ func (s *UsersService) AddUser(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Консистентность ответа и состояния БД: если письмо с паролем не
|
||||||
|
// ушло, пользователь не должен оставаться в системе «полусозданным»
|
||||||
|
// (клиент получит ошибку и повторит регистрацию). Компенсация —
|
||||||
|
// удаление созданного пользователя. Выполняется вне отменённого
|
||||||
|
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
|
||||||
|
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||||
To: email,
|
To: email,
|
||||||
Subject: "Приветствую тебя, детектив!",
|
Subject: "Приветствую тебя, детектив!",
|
||||||
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
|
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil {
|
||||||
|
log.Printf("email: отправка не удалась (%v) и компенсация не выполнена: %v", err, delErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("email send: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
||||||
@@ -135,6 +175,21 @@ func (s *UsersService) RefreshPassword(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
email string,
|
email string,
|
||||||
) error {
|
) error {
|
||||||
|
email = normalizeEmail(email)
|
||||||
|
if err := validateEmail(email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анти-enumeration: ответ одинаков для существующего и несуществующего
|
||||||
|
// адреса, письмо отправляется только реальному пользователю.
|
||||||
|
user, err := s.usersRepo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, users_repo.ErrUserNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
password, err := s.passwordGenerator.Generate()
|
password, err := s.passwordGenerator.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -144,22 +199,41 @@ func (s *UsersService) RefreshPassword(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.usersRepo.UpdateUserPassword(
|
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
|
||||||
ctx,
|
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
|
||||||
email,
|
// не работает, а новый пользователь так и не узнал).
|
||||||
string(hashedPassword),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||||
To: email,
|
To: user.Email,
|
||||||
Subject: "Приветствую тебя, детектив!",
|
Subject: "Сброс пароля",
|
||||||
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
|
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("email send: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeEmail приводит адрес к каноническому виду (без пробелов, нижний
|
||||||
|
// регистр): "User@Example.COM " и "user@example.com" — один аккаунт.
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateEmail проверяет, что строка является одиночным email-адресом.
|
||||||
|
func validateEmail(email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return errors.New("Email не указан")
|
||||||
|
}
|
||||||
|
addr, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Некорректный email")
|
||||||
|
}
|
||||||
|
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||||
|
if !strings.EqualFold(addr.Address, email) {
|
||||||
|
return errors.New("Некорректный email")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UsersService) Login(
|
func (s *UsersService) Login(
|
||||||
@@ -167,9 +241,11 @@ func (s *UsersService) Login(
|
|||||||
email string,
|
email string,
|
||||||
password string,
|
password string,
|
||||||
) (string, string, error) {
|
) (string, string, error) {
|
||||||
|
// Email нормализуется так же, как при регистрации: иначе пользователь,
|
||||||
|
// зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM.
|
||||||
user, err := s.usersRepo.GetUserByEmail(
|
user, err := s.usersRepo.GetUserByEmail(
|
||||||
ctx,
|
ctx,
|
||||||
email,
|
normalizeEmail(email),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package users_service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"evening_detective_server/internal/modules/email_sender"
|
||||||
|
"evening_detective_server/internal/modules/password_generator"
|
||||||
|
"evening_detective_server/internal/modules/processor_jwt"
|
||||||
|
"evening_detective_server/internal/repos"
|
||||||
|
"evening_detective_server/internal/repos/refresh_tokens_repo"
|
||||||
|
"evening_detective_server/internal/repos/user_agreements_repo"
|
||||||
|
"evening_detective_server/internal/repos/users_repo"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSender struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
messages []email_sender.Message
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSender) Send(_ context.Context, m email_sender.Message) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.messages = append(f.messages, m)
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSender) sent() []email_sender.Message {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]email_sender.Message(nil), f.messages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
|
||||||
|
// сервиса без БД.
|
||||||
|
type fakeRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
users map[string]*repos.User
|
||||||
|
byID map[int]*repos.User
|
||||||
|
nextID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRepo() *fakeRepo {
|
||||||
|
return &fakeRepo{
|
||||||
|
users: map[string]*repos.User{},
|
||||||
|
byID: map[int]*repos.User{},
|
||||||
|
nextID: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) AddUserWithAgreements(
|
||||||
|
_ context.Context,
|
||||||
|
username, email, passwordHash string,
|
||||||
|
roles []string,
|
||||||
|
_ []user_agreements_repo.Agreement,
|
||||||
|
) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.users[email]; ok {
|
||||||
|
return 0, errors.New("Username или email уже используется")
|
||||||
|
}
|
||||||
|
id := r.nextID
|
||||||
|
r.nextID++
|
||||||
|
u := &repos.User{ID: id, Username: username, Email: email, PasswordHash: passwordHash, Roles: roles}
|
||||||
|
r.users[email] = u
|
||||||
|
r.byID[id] = u
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateUserPassword(_ context.Context, email, passwordHash string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
u, ok := r.users[email]
|
||||||
|
if !ok {
|
||||||
|
return users_repo.ErrUserNotFound
|
||||||
|
}
|
||||||
|
u.PasswordHash = passwordHash
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*repos.User, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
u, ok := r.users[email]
|
||||||
|
if !ok {
|
||||||
|
return nil, users_repo.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetUserByID(_ context.Context, id int) (*repos.User, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
u, ok := r.byID[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, users_repo.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetUsers(_ context.Context) ([]*repos.User, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*repos.User, 0, len(r.users))
|
||||||
|
for _, u := range r.users {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) AddUserRole(_ context.Context, _ int, _ string) error { return nil }
|
||||||
|
|
||||||
|
func (r *fakeRepo) DeleteUserRole(_ context.Context, _ int, _ string) error { return nil }
|
||||||
|
|
||||||
|
func (r *fakeRepo) DeleteUser(_ context.Context, userId int) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
u, ok := r.byID[userId]
|
||||||
|
if !ok {
|
||||||
|
return users_repo.ErrUserNotFound
|
||||||
|
}
|
||||||
|
delete(r.byID, userId)
|
||||||
|
delete(r.users, u.Email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) userCount() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return len(r.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) seed(email, passwordHash string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
u := &repos.User{ID: r.nextID, Email: email, PasswordHash: passwordHash}
|
||||||
|
r.nextID++
|
||||||
|
r.users[email] = u
|
||||||
|
r.byID[u.ID] = u
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService {
|
||||||
|
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
|
||||||
|
// тестах не используется.
|
||||||
|
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return NewUsersService(
|
||||||
|
repo,
|
||||||
|
password_generator.NewGenerator(12),
|
||||||
|
sender,
|
||||||
|
processor_jwt.NewProcessor("test-secret"),
|
||||||
|
refresh_tokens_repo.NewRefreshTokensRepo(pool),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddUserSuccess(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
sender := &fakeSender{}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
err := svc.AddUser(context.Background(), "detective", " User@Example.COM ", true, true, "1.2.3.4", "ua")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email нормализован: пробелы убраны, регистр приведён.
|
||||||
|
if _, ok := repo.users["user@example.com"]; !ok {
|
||||||
|
t.Errorf("пользователь не создан с нормализованным email: %+v", repo.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := sender.sent()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].To != "user@example.com" {
|
||||||
|
t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msgs[0].Body, "пароль") {
|
||||||
|
t.Error("в письме не упоминается пароль")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddUserSendFailureCompensates(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
sender := &fakeSender{err: errors.New("smtp down")}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка при сбое отправки письма")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компенсация: клиенту ошибка, пользователя в БД нет.
|
||||||
|
if repo.userCount() != 0 {
|
||||||
|
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddUserInvalidEmail(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
svc := newTestService(repo, &fakeSender{})
|
||||||
|
|
||||||
|
err := svc.AddUser(context.Background(), "detective", "not-an-email", true, true, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка валидации email")
|
||||||
|
}
|
||||||
|
if repo.userCount() != 0 {
|
||||||
|
t.Error("пользователь не должен создаваться при невалидном email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddUserRequiresTerms(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
svc := newTestService(repo, &fakeSender{})
|
||||||
|
|
||||||
|
err := svc.AddUser(context.Background(), "detective", "user@example.com", false, true, "", "")
|
||||||
|
if !errors.Is(err, ErrTermsNotAccepted) {
|
||||||
|
t.Fatalf("ожидался ErrTermsNotAccepted, получено %v", err)
|
||||||
|
}
|
||||||
|
if repo.userCount() != 0 {
|
||||||
|
t.Error("пользователь не должен создаваться без акцепта")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshPasswordSuccess(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seed("user@example.com", "old-hash")
|
||||||
|
sender := &fakeSender{}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
// Ввод с пробелами и другим регистром — нормализуется.
|
||||||
|
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RefreshPassword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sender.sent()) != 1 {
|
||||||
|
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(sender.sent()))
|
||||||
|
}
|
||||||
|
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
|
||||||
|
if u.PasswordHash == "old-hash" {
|
||||||
|
t.Error("пароль не изменён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshPasswordSendFailureKeepsPassword(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seed("user@example.com", "old-hash")
|
||||||
|
sender := &fakeSender{err: errors.New("smtp down")}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
err := svc.RefreshPassword(context.Background(), "user@example.com")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка при сбое отправки")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
|
||||||
|
if u.PasswordHash != "old-hash" {
|
||||||
|
t.Error("пароль изменён, хотя письмо не ушло — аккаунт заблокирован")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshPasswordUnknownEmail(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
sender := &fakeSender{}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
// Анти-enumeration: для несуществующего адреса — успех и без письма.
|
||||||
|
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
|
||||||
|
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
|
||||||
|
}
|
||||||
|
if len(sender.sent()) != 0 {
|
||||||
|
t.Error("письмо не должно уходить несуществующему пользователю")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshPasswordInvalidEmail(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
sender := &fakeSender{}
|
||||||
|
svc := newTestService(repo, sender)
|
||||||
|
|
||||||
|
if err := svc.RefreshPassword(context.Background(), "not-an-email"); err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка валидации email")
|
||||||
|
}
|
||||||
|
if len(sender.sent()) != 0 {
|
||||||
|
t.Error("письмо не должно уходить при невалидном email")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user