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
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'.
155 lines
3.8 KiB
Go
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
|
|
}
|