196 lines
5.2 KiB
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)
|
|
}
|
|
}
|