PrintToVeraeTimeStamp/internal/mailer/mailer_test.go

196 lines
5.2 KiB
Go

package mailer
import (
"bufio"
"encoding/base64"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTP: minimal server without STARTTLS, records AUTH and DATA.
type fakeSMTP struct {
ln net.Listener
mu sync.Mutex
auth string
data string
rcpt string
}
func startFake(t *testing.T) *fakeSMTP {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeSMTP{ln: ln}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.serve(c)
}
}()
t.Cleanup(func() { ln.Close() })
return f
}
func (f *fakeSMTP) serve(c net.Conn) {
defer c.Close()
r := bufio.NewReader(c)
w := func(s string) { c.Write([]byte(s + "\r\n")) }
w("220 fake")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
up := strings.ToUpper(line)
switch {
case strings.HasPrefix(up, "EHLO"):
w("250-fake")
w("250 AUTH PLAIN")
case strings.HasPrefix(up, "AUTH PLAIN"):
f.mu.Lock()
f.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
f.mu.Unlock()
w("235 ok")
case strings.HasPrefix(up, "MAIL FROM"):
w("250 ok")
case strings.HasPrefix(up, "RCPT TO"):
f.mu.Lock()
f.rcpt = line
f.mu.Unlock()
w("250 ok")
case up == "DATA":
w("354 go")
var b strings.Builder
for {
l, err := r.ReadString('\n')
if err != nil {
return
}
if l == ".\r\n" {
break
}
b.WriteString(l)
}
f.mu.Lock()
f.data = b.String()
f.mu.Unlock()
w("250 queued")
case up == "QUIT":
w("221 bye")
return
default:
w("250 ok")
}
}
}
func (f *fakeSMTP) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
func TestSendViaFakeSMTP(t *testing.T) {
f := startFake(t)
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "veraetimestamp@gmail.com", Pass: "s3cret-app-pw", From: "Verae TimeStamp <veraetimestamp@gmail.com>", AllowInsecureLocal: true, Timeout: 5 * time.Second}}
m := CodeEmail("george@example.com", "George", "123456", 15, "")
if err := s.Send(m); err != nil {
t.Fatal(err)
}
f.mu.Lock()
defer f.mu.Unlock()
dec, _ := base64.StdEncoding.DecodeString(f.auth)
if string(dec) != "\x00veraetimestamp@gmail.com\x00s3cret-app-pw" {
t.Fatalf("auth %q", dec)
}
for _, want := range []string{"multipart/alternative", "text/plain", "text/html", "Subject: Your Verae verification code: 123 456", "To: <george@example.com>"} {
if !strings.Contains(f.data, want) {
t.Errorf("message missing %q", want)
}
}
if !strings.Contains(f.rcpt, "george@example.com") {
t.Errorf("rcpt %q", f.rcpt)
}
}
func TestRefusesPlaintextWithoutOptIn(t *testing.T) {
f := startFake(t)
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "u@x.com", Pass: "pw-should-not-leak", From: "u@x.com", Timeout: 5 * time.Second}}
err := s.Send(CodeEmail("a@b.com", "", "000111", 15, ""))
if err == nil || !strings.Contains(err.Error(), "STARTTLS") {
t.Fatalf("want STARTTLS refusal, got %v", err)
}
if strings.Contains(err.Error(), "pw-should-not-leak") {
t.Fatal("password leaked into error")
}
}
func TestConfigNeverPrintsPassword(t *testing.T) {
t.Setenv("SMTP_USER", "veraetimestamp@gmail.com")
t.Setenv("SMTP_PASS", "abcd efgh ijkl mnop")
t.Setenv("SMTP_HOST", "")
t.Setenv("SMTP_PORT", "")
t.Setenv("SMTP_FROM", "")
c, err := FromEnv()
if err != nil {
t.Fatal(err)
}
if c.Host != "smtp.gmail.com" || c.Port != 587 || c.From != "Verae TimeStamp <veraetimestamp@gmail.com>" {
t.Fatalf("defaults: %v", c)
}
if strings.Contains(c.String(), "abcd") {
t.Fatal("String() leaked password")
}
}
func TestRejectsHeaderInjection(t *testing.T) {
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: 1, User: "u@x.com", Pass: "p", From: "u@x.com"}}
if err := s.Send(Message{To: "a@b.com\r\nBcc: evil@x.com", Subject: "x"}); err == nil {
t.Fatal("expected bad recipient")
}
}
func TestTemplateEscapesAndCode(t *testing.T) {
m := CodeEmail("a@b.com", "<script>", "987654", 15, "")
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "987654") || !strings.Contains(m.Text, "987 654") {
t.Fatal("template")
}
c, err := NewCode()
if err != nil || len(c) != 6 {
t.Fatal(c, err)
}
}
func TestLimiter(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
l := NewLimiter(Limits{Cooldown: time.Minute, PerEmailHour: 3, PerIPHour: 4, GlobalMinute: 100, GlobalDay: 1000})
l.Now = func() time.Time { return now }
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
t.Fatal(err)
}
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "cooldown") {
t.Fatalf("want cooldown, got %v", err)
}
now = now.Add(61 * time.Second)
_ = l.Allow("a@x", "1.1.1.1")
now = now.Add(61 * time.Second)
_ = l.Allow("a@x", "1.1.1.1")
now = now.Add(61 * time.Second)
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "too many codes") {
t.Fatalf("want per-email cap, got %v", err)
}
if err := l.Allow("b@x", "1.1.1.1"); err != nil {
t.Fatal(err)
}
if err := l.Allow("c@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "network") {
t.Fatalf("want per-IP cap, got %v", err)
}
now = now.Add(time.Hour)
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
t.Fatalf("window should reset: %v", err)
}
}