Files
restic-manager/internal/notification/smtp_test.go
T
steve 43e72b0d5b
CI / Build (windows/amd64) (pull_request) Successful in 30s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 1m19s
CI / Test (linux/amd64) (pull_request) Successful in 1m42s
feat(channels): include event verb in ntfy title + smtp subject
Raise / ack / resolve all rendered with the same title and body on
ntfy and SMTP, so a recovery looked identical to the original alert.
Webhook was already fine because the JSON envelope carries 'event'.

ntfy:
  Title  '[raised · warning] dev backup_failed' (was '[warning] …')
  Tags   'raised,warning,backup_failed' (was 'warning,backup_failed')
  Body   'Resolved · <message>' / 'Acknowledged · <message>' on those events
SMTP:
  Subject '[restic-manager] [raised · warning] dev: backup_failed'

Plus: cmd/_fake_alert now accepts the ref as a positional argument
(go run ./cmd/_fake_alert steve-001) instead of silently ignoring
unknown positional args. Refuses ambiguous '-ref X positional Y'.
2026-05-04 23:17:42 +01:00

155 lines
3.8 KiB
Go

package notification
import (
"context"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTPServer accepts a single connection, runs the minimal SMTP
// dialogue (HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT) and stores
// what came across the wire. Plain (no TLS) — we test the protocol
// shape, not crypto.
type fakeSMTPServer struct {
mu sync.Mutex
mailFrom string
rcptTo string
data string
authed bool
}
func startFakeSMTP(t *testing.T) (string, *fakeSMTPServer) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &fakeSMTPServer{}
t.Cleanup(func() { _ = ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
readLine := func() string {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return ""
}
return string(buf[:n])
}
write := func(s string) { _, _ = conn.Write([]byte(s)) }
write("220 fake.smtp ESMTP\r\n")
for {
line := readLine()
if line == "" {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
write("250-fake.smtp\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(cmd, "AUTH "):
srv.mu.Lock()
srv.authed = true
srv.mu.Unlock()
write("235 OK\r\n")
case strings.HasPrefix(cmd, "MAIL FROM"):
srv.mu.Lock()
srv.mailFrom = strings.TrimSpace(strings.TrimPrefix(line, "MAIL FROM:"))
srv.mu.Unlock()
write("250 OK\r\n")
case strings.HasPrefix(cmd, "RCPT TO"):
srv.mu.Lock()
srv.rcptTo = strings.TrimSpace(strings.TrimPrefix(line, "RCPT TO:"))
srv.mu.Unlock()
write("250 OK\r\n")
case cmd == "DATA":
write("354 OK\r\n")
// read until "\r\n.\r\n"
var data strings.Builder
for {
chunk := readLine()
if chunk == "" {
break
}
data.WriteString(chunk)
if strings.Contains(data.String(), "\r\n.\r\n") {
break
}
}
srv.mu.Lock()
srv.data = data.String()
srv.mu.Unlock()
write("250 OK\r\n")
case cmd == "QUIT":
write("221 bye\r\n")
return
default:
write("500 unknown\r\n")
}
}
}()
return ln.Addr().String(), srv
}
func TestSMTPSendsExpectedHeaders(t *testing.T) {
t.Parallel()
addr, srv := startFakeSMTP(t)
host, port := splitHostPort(addr)
ch := NewSMTPChannel(SMTPConfig{
Host: host, Port: port, Encryption: "none",
Username: "u", Password: "p",
From: "Restic-Manager <alerts@example.com>",
To: "ops@example.com",
}, "rm.example")
_, _, err := ch.Send(context.Background(), Payload{
Event: EventRaised, AlertID: "01ABC",
Severity: "warning", Kind: "backup_failed",
HostName: "alfa-01", Message: "Backup failed: 401",
RaisedAt: time.Date(2026, 5, 4, 15, 42, 1, 0, time.UTC),
Link: "https://rm.example/alerts/01ABC",
})
if err != nil {
t.Fatalf("send: %v", err)
}
srv.mu.Lock()
defer srv.mu.Unlock()
if !srv.authed {
t.Errorf("AUTH never sent")
}
if !strings.Contains(srv.mailFrom, "alerts@example.com") {
t.Errorf("MAIL FROM: %q", srv.mailFrom)
}
if !strings.Contains(srv.rcptTo, "ops@example.com") {
t.Errorf("RCPT TO: %q", srv.rcptTo)
}
if !strings.Contains(srv.data, "Subject: [restic-manager] [raised · warning] alfa-01: backup_failed") {
t.Errorf("subject missing or wrong: %q", srv.data)
}
if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") {
t.Errorf("Message-ID wrong: %q", srv.data)
}
if !strings.Contains(srv.data, "Backup failed: 401") {
t.Errorf("body missing: %q", srv.data)
}
}
func splitHostPort(addr string) (string, int) {
host, portStr, _ := net.SplitHostPort(addr)
var port int
for _, r := range portStr {
port = port*10 + int(r-'0')
}
return host, port
}