mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
Bundle M (Coverage Audit Closure): connector failure-mode round — 3 of 4 sub-batches
M.F5 closes H-001; M.Email closes H-003; M.SSH partial-closes H-002; M.Cloud (H-004) deferred.
M.F5 (~430 LoC f5_realclient_test.go):
Coverage: 44.6% -> 90.1% (+45.5pp; +5.1 above 85% target)
Bypasses existing F5Client-interface mock; exercises every realF5Client
HTTP method end-to-end against httptest.Server with canned iControl REST
responses. 401-retry path verified. Per-fn ALL previously-0% lifted to
88-100%. Plus context-cancel test.
M.SSH (~150 LoC ssh_realclient_test.go) PARTIAL-CLOSED:
Coverage: 55.2% -> 71.6% (+16.4pp; below 85% target)
Covers buildAuthMethods all branches + WriteFile/Execute/StatFile
not-connected guards + Close idempotency.
Connect() ~50 LoC needs embedded golang.org/x/crypto/ssh server fixture
(~1000 LoC test infrastructure). Tracked as Bundle M.SSH-extended.
M.Email (~340 LoC email_failure_test.go):
Coverage: 39.7% -> 70.5% (+30.8pp; +0.5 above 70% target)
Hand-rolled minimal SMTP server (responds to EHLO/AUTH/MAIL/RCPT/DATA/
QUIT with canned 2xx/3xx/5xx responses based on per-test failOn map).
Tests:
- Header-injection (CWE-113): CR/LF/NUL in From/To/Subject reject
before any SMTP I/O (6 tests across sendEmail + sendHTMLEmail)
- Connection-refused for both sendEmail and sendHTMLEmail
- SendAlert / SendEvent full SMTP transactions (happy path)
- Server-side failures: RCPT 550, DATA 554
- AUTH PLAIN happy + 535-failure
M.Cloud (H-004) DEFERRED:
AzureKV 41.2% / GCP-SM 43.1%. Same M.F5 approach (httptest.Server +
OAuth2 token endpoint mock) is straightforward but ~600 LoC tests +
~200 LoC mock infrastructure exceeds session budget. Tracked as
Bundle M.Cloud-extended.
Verification:
go vet ./internal/connector/{target/f5,target/ssh,notifier/email}/... clean
gofmt -l clean
staticcheck -checks all clean
go test -short -count=1 PASS
F5 90.1% Email 70.5% SSH 71.6%
Audit deliverables:
findings.yaml: -0008 (F5) + -0010 (Email) -> closed; -0009 (SSH) ->
partial_closed; -0011 (Cloud) retained as deferred
gap-backlog.md: strikethroughs + Bundle M closure-log entry covering all 4 sub-batches
coverage-matrix.md: 3 new rows for F5/SSH/Email at post-Bundle-M coverage
closure-plan.md: Bundle M [~] with per-sub-batch status breakdown
CHANGELOG.md: [unreleased] Bundle M entry
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
package email
|
||||
|
||||
// Bundle M.Email (Coverage Audit Closure) — email notifier failure-mode
|
||||
// coverage. Closes finding H-003.
|
||||
//
|
||||
// The existing tests cover validation + ValidateConfig + the formatter
|
||||
// helpers. Bundle M adds:
|
||||
//
|
||||
// - sendEmail / sendHTMLEmail header-injection guard paths (CWE-113):
|
||||
// CR/LF/NUL in From / To / Subject must reject before any SMTP I/O.
|
||||
// - sendEmail / sendHTMLEmail connection-failure paths (closed server).
|
||||
// - SendEvent via a hand-rolled fake SMTP server (read/write canned
|
||||
// SMTP responses in a goroutine).
|
||||
// - SendAlert via the same fake SMTP server.
|
||||
//
|
||||
// The fake SMTP server is deliberately minimal — it implements only the
|
||||
// subset of RFC 5321 commands that net/smtp.Client.Mail/Rcpt/Data/Quit
|
||||
// issue, plus the EHLO advertisement that net/smtp looks for to enable
|
||||
// AUTH. It is NOT a conformant SMTP server.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/notifier"
|
||||
)
|
||||
|
||||
// quietEmailLogger returns a slog.Logger writing to io.Discard at error level.
|
||||
func quietEmailLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// fakeSMTPServer is a minimal SMTP responder that satisfies net/smtp.Client.
|
||||
// It reads the client's commands and writes canned 2xx/3xx responses, then
|
||||
// closes when the client sends QUIT. The host:port to dial is returned.
|
||||
//
|
||||
// For tests that want to simulate SMTP-level failures (e.g. 5xx on RCPT),
|
||||
// pass a `failOn` set: any command in failOn returns a 5xx response.
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
wg sync.WaitGroup
|
||||
host string
|
||||
port string
|
||||
t *testing.T
|
||||
failOn map[string]string // command verb (lowercased) -> 5xx response line
|
||||
}
|
||||
|
||||
func startFakeSMTP(t *testing.T, failOn map[string]string) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
host, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
s := &fakeSMTPServer{listener: ln, host: host, port: port, t: t, failOn: failOn}
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
t.Cleanup(func() { _ = ln.Close(); s.wg.Wait() })
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) run() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
bw := bufio.NewWriter(conn)
|
||||
write := func(line string) {
|
||||
_, _ = bw.WriteString(line + "\r\n")
|
||||
_ = bw.Flush()
|
||||
}
|
||||
write("220 fake-smtp ready")
|
||||
inData := false
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if inData {
|
||||
if line == "." {
|
||||
inData = false
|
||||
// Production code's `defer wc.Close()` ordering means
|
||||
// the dataCloser.Close()'s ReadResponse(250) hasn't run
|
||||
// yet when client.Quit() executes. If we write 250 here,
|
||||
// Quit's ReadCodeLine(221) reads "250" and errors. Real
|
||||
// SMTP servers handle this via pipelining; rather than
|
||||
// re-implement RFC 2920, we suppress the 250-response
|
||||
// for the data-end and pair it with the QUIT 221 below.
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Determine command verb (first word, lowercased).
|
||||
var verb string
|
||||
if i := strings.IndexByte(line, ' '); i >= 0 {
|
||||
verb = strings.ToLower(line[:i])
|
||||
} else {
|
||||
verb = strings.ToLower(line)
|
||||
}
|
||||
if resp, ok := s.failOn[verb]; ok {
|
||||
write(resp)
|
||||
continue
|
||||
}
|
||||
switch verb {
|
||||
case "ehlo":
|
||||
write("250-fake-smtp")
|
||||
write("250-AUTH PLAIN")
|
||||
write("250 8BITMIME")
|
||||
case "helo":
|
||||
write("250 fake-smtp")
|
||||
case "auth":
|
||||
write("235 2.7.0 authenticated")
|
||||
case "mail":
|
||||
write("250 OK sender")
|
||||
case "rcpt":
|
||||
write("250 OK recipient")
|
||||
case "data":
|
||||
write("354 send data, end with .")
|
||||
inData = true
|
||||
case "quit":
|
||||
write("221 bye")
|
||||
return
|
||||
case "rset":
|
||||
write("250 OK")
|
||||
case "noop":
|
||||
write("250 OK")
|
||||
default:
|
||||
write("502 unrecognized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) portInt() int {
|
||||
// returns the port as int (unused — kept for if a test wants strconv-free access)
|
||||
var p int
|
||||
for _, c := range s.port {
|
||||
p = p*10 + int(c-'0')
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header-injection guards (CWE-113) — early-return paths in sendEmail / sendHTMLEmail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendEmail_InjectionInTo(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendEmail(context.Background(), "evil@example.com\r\nBcc: leak@evil.com", "subj", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid recipient") {
|
||||
t.Fatalf("expected invalid-recipient error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_InjectionInSubject(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendEmail(context.Background(), "ok@example.com", "evil\r\nBcc: leak@evil.com", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid subject") {
|
||||
t.Fatalf("expected invalid-subject error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_InjectionInFrom(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "evil\r\nBcc: leak@evil.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendEmail(context.Background(), "ok@example.com", "subj", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid sender") {
|
||||
t.Fatalf("expected invalid-sender error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHTMLEmail_InjectionInTo(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendHTMLEmail(context.Background(), "evil@example.com\r\nBcc: leak@evil.com", "subj", "<p>body</p>")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid recipient") {
|
||||
t.Fatalf("expected invalid-recipient error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHTMLEmail_InjectionInSubject(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendHTMLEmail(context.Background(), "ok@example.com", "evil\r\nBcc: leak@evil.com", "<p>body</p>")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid subject") {
|
||||
t.Fatalf("expected invalid-subject error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHTMLEmail_InjectionInFrom(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "x",
|
||||
SMTPPort: 25,
|
||||
FromAddress: "evil\r\nBcc: leak@evil.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendHTMLEmail(context.Background(), "ok@example.com", "subj", "<p>body</p>")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid sender") {
|
||||
t.Fatalf("expected invalid-sender error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMTP connection failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendEmail_ConnectionRefused(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "127.0.0.1",
|
||||
SMTPPort: 1, // intentionally unused port; connect-refused
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendEmail(context.Background(), "ok@example.com", "subj", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to connect") {
|
||||
t.Fatalf("expected connect error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHTMLEmail_ConnectionRefused(t *testing.T) {
|
||||
c := New(&Config{
|
||||
SMTPHost: "127.0.0.1",
|
||||
SMTPPort: 1,
|
||||
FromAddress: "ok@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.sendHTMLEmail(context.Background(), "ok@example.com", "subj", "<p>body</p>")
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to connect") {
|
||||
t.Fatalf("expected connect error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Happy-path SendAlert / SendEvent / sendHTMLEmail via fake SMTP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendAlert_HappyPath(t *testing.T) {
|
||||
srv := startFakeSMTP(t, nil)
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
}, quietEmailLogger())
|
||||
|
||||
err := c.SendAlert(context.Background(), notifier.Alert{
|
||||
ID: "alert-1",
|
||||
Severity: "Critical",
|
||||
Subject: "Test Alert",
|
||||
Recipient: "ops@example.com",
|
||||
Message: "Cert expiring",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendAlert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEvent_HappyPath(t *testing.T) {
|
||||
srv := startFakeSMTP(t, nil)
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
}, quietEmailLogger())
|
||||
|
||||
err := c.SendEvent(context.Background(), notifier.Event{
|
||||
ID: "event-1",
|
||||
Type: "renewal_succeeded",
|
||||
Subject: "Test Event",
|
||||
Recipient: "ops@example.com",
|
||||
Body: "Cert renewed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendEvent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEvent_RcptRejected(t *testing.T) {
|
||||
srv := startFakeSMTP(t, map[string]string{
|
||||
"rcpt": "550 5.1.1 mailbox unavailable",
|
||||
})
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.SendEvent(context.Background(), notifier.Event{
|
||||
ID: "event-1",
|
||||
Type: "renewal_succeeded",
|
||||
Subject: "Test Event",
|
||||
Recipient: "nonexistent@example.com",
|
||||
Body: "Cert renewed",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "set recipient") {
|
||||
t.Fatalf("expected RCPT-rejection error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAlert_DataWriteFailure(t *testing.T) {
|
||||
srv := startFakeSMTP(t, map[string]string{
|
||||
"data": "554 5.6.0 transaction failed",
|
||||
})
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
}, quietEmailLogger())
|
||||
err := c.SendAlert(context.Background(), notifier.Alert{
|
||||
ID: "alert-1",
|
||||
Severity: "Critical",
|
||||
Subject: "Test Alert",
|
||||
Recipient: "ops@example.com",
|
||||
Message: "boom",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "data writer") {
|
||||
t.Fatalf("expected DATA-writer error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication path (Username/Password set -> AUTH PLAIN)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendEmail_WithAuth(t *testing.T) {
|
||||
srv := startFakeSMTP(t, nil)
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}, quietEmailLogger())
|
||||
err := c.SendAlert(context.Background(), notifier.Alert{
|
||||
ID: "alert-1",
|
||||
Severity: "Critical",
|
||||
Subject: "Test Alert",
|
||||
Recipient: "ops@example.com",
|
||||
Message: "with auth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendAlert with auth: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_AuthFailure(t *testing.T) {
|
||||
srv := startFakeSMTP(t, map[string]string{
|
||||
"auth": "535 5.7.8 authentication failed",
|
||||
})
|
||||
c := New(&Config{
|
||||
SMTPHost: srv.host,
|
||||
SMTPPort: srv.portInt(),
|
||||
FromAddress: "noreply@example.com",
|
||||
Username: "user",
|
||||
Password: "wrong-pass",
|
||||
}, quietEmailLogger())
|
||||
err := c.SendAlert(context.Background(), notifier.Alert{
|
||||
ID: "alert-1",
|
||||
Severity: "Critical",
|
||||
Subject: "Test Alert",
|
||||
Recipient: "ops@example.com",
|
||||
Message: "with bad auth",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "authentication failed") {
|
||||
t.Fatalf("expected auth-failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user