test: add tests for handler, email, cmd/server, version and enforce 80% coverage

This commit is contained in:
2026-07-26 20:59:56 +02:00
parent 08554695da
commit 4dd7b6991d
8 changed files with 1295 additions and 6 deletions
+15 -4
View File
@@ -20,23 +20,34 @@ import (
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
)
// formSender is the interface for delivering a submission.
type formSender interface {
Send(req contactform.Request) error
}
// subscriberStorer persists newsletter subscribers.
// storage.NewsletterStore satisfies this interface.
type subscriberStorer interface {
Append(sub storage.Subscriber) error
}
// ContactHandler serves one or more contact forms, dispatched by URL path.
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
// Newsletter-type forms also get a per-form subscriber store.
type ContactHandler struct {
forms map[string]*config.Form
senders map[string]*email.FormSender
senders map[string]formSender
rateLimits map[string]*rateLimiter
stores map[string]*storage.NewsletterStore
stores map[string]subscriberStorer
}
// New constructs a ContactHandler that serves all forms defined in cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]*email.FormSender, len(cfg.Forms)),
senders: make(map[string]formSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]*storage.NewsletterStore, len(cfg.Forms)),
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
}
for i := range cfg.Forms {
f := &cfg.Forms[i]
+804
View File
@@ -0,0 +1,804 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
)
// ---------------------------------------------------------------------------
// mocks
// ---------------------------------------------------------------------------
type mockSender struct {
mu sync.Mutex
sent []contactform.Request
sendErr error
}
func (m *mockSender) Send(req contactform.Request) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sent = append(m.sent, req)
return m.sendErr
}
func (m *mockSender) sentCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sent)
}
type mockStore struct {
mu sync.Mutex
appended []storage.Subscriber
appendErr error
}
func (m *mockStore) Append(sub storage.Subscriber) error {
m.mu.Lock()
defer m.mu.Unlock()
m.appended = append(m.appended, sub)
return m.appendErr
}
// ---------------------------------------------------------------------------
// helper functions
// ---------------------------------------------------------------------------
func testForm() *config.Form {
return &config.Form{
Name: "test",
Path: "/test",
Type: "contact",
AllowedOrigins: []string{"https://example.com"},
RateLimitPerHour: 100,
HoneypotField: "website",
From: "noreply@test.com",
To: "owner@test.com",
}
}
func testNewsletterForm() *config.Form {
f := testForm()
f.Type = "newsletter"
return f
}
func newTestHandler(form *config.Form, sender formSender, store subscriberStorer) *ContactHandler {
h := &ContactHandler{
forms: map[string]*config.Form{form.Path: form},
senders: map[string]formSender{form.Path: sender},
rateLimits: map[string]*rateLimiter{form.Path: newRateLimiter(form.RateLimitPerHour)},
stores: map[string]subscriberStorer{},
}
if store != nil {
h.stores[form.Path] = store
}
return h
}
func newTestHandlerMux(t *testing.T, form *config.Form, sender formSender, store subscriberStorer) http.Handler {
t.Helper()
h := newTestHandler(form, sender, store)
mux := http.NewServeMux()
h.Register(mux)
return mux
}
func postJSON(t *testing.T, handler http.Handler, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func postJSONWithOrigin(t *testing.T, handler http.Handler, path string, body string, origin string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestFormAllowed
// ---------------------------------------------------------------------------
func TestFormAllowed(t *testing.T) {
form := &config.Form{
AllowedOrigins: []string{"https://a.com", "https://b.com"},
}
tests := []struct {
name string
origin string
want bool
}{
{"empty origin allowed", "", true},
{"exact match allowed", "https://a.com", true},
{"second match allowed", "https://b.com", true},
{"no match disallowed", "https://evil.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formAllowed(form, tt.origin)
if got != tt.want {
t.Errorf("formAllowed(%q) = %v, want %v", tt.origin, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// TestWriteCORS
// ---------------------------------------------------------------------------
func TestWriteCORS(t *testing.T) {
allowed := []string{"https://a.com"}
t.Run("allowed origin sets headers", func(t *testing.T) {
w := httptest.NewRecorder()
writeCORS(w, "https://a.com", allowed)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://a.com" {
t.Errorf("ACAO = %q, want %q", got, "https://a.com")
}
if got := w.Header().Get("Vary"); got != "Origin" {
t.Errorf("Vary = %q, want %q", got, "Origin")
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" {
t.Errorf("ACAM = %q, want POST, OPTIONS", got)
}
})
t.Run("disallowed origin sets nothing", func(t *testing.T) {
w := httptest.NewRecorder()
writeCORS(w, "https://evil.com", allowed)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO = %q, want empty", got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "" {
t.Errorf("ACAM = %q, want empty", got)
}
})
}
// ---------------------------------------------------------------------------
// TestClientIP
// ---------------------------------------------------------------------------
func TestClientIP(t *testing.T) {
tests := []struct {
name string
xff string
xri string
remoteAddr string
want string
}{
{
name: "X-Forwarded-For single IP",
xff: "1.2.3.4",
xri: "",
remoteAddr: "10.0.0.1:1234",
want: "1.2.3.4",
},
{
name: "X-Forwarded-For multiple IPs takes first",
xff: "1.2.3.4, 2.3.4.5, 3.4.5.6",
xri: "",
remoteAddr: "10.0.0.1:1234",
want: "1.2.3.4",
},
{
name: "X-Real-IP takes priority over RemoteAddr",
xff: "",
xri: "5.6.7.8",
remoteAddr: "10.0.0.1:1234",
want: "5.6.7.8",
},
{
name: "RemoteAddr with port",
xff: "",
xri: "",
remoteAddr: "9.10.11.12:8080",
want: "9.10.11.12",
},
{
name: "RemoteAddr without port",
xff: "",
xri: "",
remoteAddr: "9.10.11.12",
want: "9.10.11.12",
},
{
name: "IPv6 RemoteAddr with port",
xff: "",
xri: "",
remoteAddr: "[::1]:12345",
want: "[::1]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.xff != "" {
req.Header.Set("X-Forwarded-For", tt.xff)
}
if tt.xri != "" {
req.Header.Set("X-Real-IP", tt.xri)
}
req.RemoteAddr = tt.remoteAddr
got := clientIP(req)
if got != tt.want {
t.Errorf("clientIP() = %q, want %q", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// TestRateLimiterAllow
// ---------------------------------------------------------------------------
func TestRateLimiterAllow(t *testing.T) {
perHour := 3
rl := &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
ip := "10.0.0.1"
// First perHour requests should be allowed.
for i := 0; i < perHour; i++ {
if !rl.allow(ip) {
t.Fatalf("allow() call %d was blocked, want allowed", i+1)
}
}
// Next request should be blocked.
if rl.allow(ip) {
t.Fatal("allow() after exhausting tokens was allowed, want blocked")
}
}
// ---------------------------------------------------------------------------
// TestRateLimiterRefill
// ---------------------------------------------------------------------------
func TestRateLimiterRefill(t *testing.T) {
// 3600 tokens per hour = 1 token per second.
perHour := 3600
rl := &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
ip := "10.0.0.1"
// Exhaust all tokens.
allowed := 0
for rl.allow(ip) {
allowed++
}
if allowed != perHour {
t.Fatalf("exhausted %d tokens, want %d", allowed, perHour)
}
// Immediately another request should be blocked (rate = 1 token/sec).
if rl.allow(ip) {
t.Fatal("allow() immediately after exhausting was allowed, want blocked")
}
// Wait for at least 2 tokens to refill (2 seconds + margin).
time.Sleep(2100 * time.Millisecond)
// Should now allow 2 requests.
for i := 0; i < 2; i++ {
if !rl.allow(ip) {
t.Fatalf("allow() after refill call %d was blocked, want allowed", i+1)
}
}
// Third request after only 2 sec of refill should be blocked.
if rl.allow(ip) {
t.Fatal("allow() after refill of only 2 tokens was allowed, want blocked")
}
}
// ---------------------------------------------------------------------------
// TestRateLimiterCleanup
// ---------------------------------------------------------------------------
func TestRateLimiterCleanup(t *testing.T) {
rl := &rateLimiter{
perHour: 10,
buckets: make(map[string]*bucket),
}
// Add a very old entry (3 hours ago).
rl.buckets["old-ip"] = &bucket{
tokens: 0,
last: time.Now().Add(-3 * time.Hour),
}
// Add a recent entry (30 seconds ago).
rl.buckets["recent-ip"] = &bucket{
tokens: 5,
last: time.Now().Add(-30 * time.Second),
}
// Cleanup entries older than 2 hours.
rl.cleanup(2 * time.Hour)
if _, ok := rl.buckets["old-ip"]; ok {
t.Error("old-ip was not cleaned up, want removed")
}
if _, ok := rl.buckets["recent-ip"]; !ok {
t.Error("recent-ip was cleaned up, want kept")
}
}
// ---------------------------------------------------------------------------
// TestRateLimiterZeroPerHour
// ---------------------------------------------------------------------------
func TestRateLimiterZeroPerHour(t *testing.T) {
form := testForm()
form.RateLimitPerHour = 0
form.AllowedOrigins = nil // allow all
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
// Send many requests rapidly — none should be rate-limited.
const n = 50
for i := 0; i < n; i++ {
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("request %d rate-limited with perHour=0", i+1)
}
}
if sender.sentCount() != n {
t.Errorf("sent %d requests, want %d", sender.sentCount(), n)
}
}
// ---------------------------------------------------------------------------
// TestRateLimiterMultipleIPs
// ---------------------------------------------------------------------------
func TestRateLimiterMultipleIPs(t *testing.T) {
perHour := 2
rl := &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
ipA := "10.0.0.1"
ipB := "10.0.0.2"
// Exhaust ipA's tokens.
for i := 0; i < perHour; i++ {
if !rl.allow(ipA) {
t.Fatalf("ipA call %d blocked, want allowed", i+1)
}
}
if rl.allow(ipA) {
t.Fatal("ipA after exhaust was allowed, want blocked")
}
// ipB should still have all its tokens.
for i := 0; i < perHour; i++ {
if !rl.allow(ipB) {
t.Fatalf("ipB call %d blocked, want allowed", i+1)
}
}
if rl.allow(ipB) {
t.Fatal("ipB after exhaust was allowed, want blocked")
}
}
// ---------------------------------------------------------------------------
// TestMinF
// ---------------------------------------------------------------------------
func TestMinF(t *testing.T) {
tests := []struct {
a, b, want float64
}{
{1, 2, 1},
{2, 1, 1},
{3.5, 3.5, 3.5},
{-1, 0, -1},
{0, -5, -5},
}
for _, tt := range tests {
got := minF(tt.a, tt.b)
if got != tt.want {
t.Errorf("minF(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
}
}
// ---------------------------------------------------------------------------
// TestRespondOK
// ---------------------------------------------------------------------------
func TestRespondOK(t *testing.T) {
w := httptest.NewRecorder()
respondOK(w)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
var resp contactform.Response
decodeBody(t, w, &resp)
if !resp.OK {
t.Errorf("OK = %v, want true", resp.OK)
}
}
// ---------------------------------------------------------------------------
// TestRespondError
// ---------------------------------------------------------------------------
func TestRespondError(t *testing.T) {
w := httptest.NewRecorder()
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
var resp contactform.ErrorResponse
decodeBody(t, w, &resp)
if resp.Error != "invalid_json" {
t.Errorf("error = %q, want %q", resp.Error, "invalid_json")
}
if resp.Message != "Could not parse JSON body." {
t.Errorf("message = %q, want %q", resp.Message, "Could not parse JSON body.")
}
}
// ---------------------------------------------------------------------------
// TestHealth
// ---------------------------------------------------------------------------
func TestHealth(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]any
decodeBody(t, rec, &resp)
if status, ok := resp["status"]; !ok || status != "ok" {
t.Errorf("status = %v, want ok", status)
}
}
// ---------------------------------------------------------------------------
// TestHandlerOptions
// ---------------------------------------------------------------------------
func TestHandlerOptions(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("ACAO = %q, want %q", got, "https://example.com")
}
}
// ---------------------------------------------------------------------------
// TestHandlerOriginNotAllowed
// ---------------------------------------------------------------------------
func TestHandlerOriginNotAllowed(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
rec := postJSONWithOrigin(t, mux, "/test",
`{"name":"A","email":"a@b.com","message":"Hello there!"}`,
"https://evil.com")
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
var resp contactform.ErrorResponse
decodeBody(t, rec, &resp)
if resp.Error != "origin_not_allowed" {
t.Errorf("error = %q, want origin_not_allowed", resp.Error)
}
if sender.sentCount() != 0 {
t.Errorf("sender called %d times, want 0", sender.sentCount())
}
}
// ---------------------------------------------------------------------------
// TestHandlerRateLimited
// ---------------------------------------------------------------------------
func TestHandlerRateLimited(t *testing.T) {
form := testForm()
form.RateLimitPerHour = 1
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
// First request should succeed.
rec1 := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
if rec1.Code != http.StatusOK {
t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusOK)
}
// Second request should be rate-limited.
rec2 := postJSON(t, mux, "/test",
`{"name":"Bob","email":"b@b.com","message":"Hello there!"}`)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
}
var resp contactform.ErrorResponse
decodeBody(t, rec2, &resp)
if resp.Error != "rate_limited" {
t.Errorf("error = %q, want rate_limited", resp.Error)
}
}
// ---------------------------------------------------------------------------
// TestHandlerInvalidJSON
// ---------------------------------------------------------------------------
func TestHandlerInvalidJSON(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
rec := postJSON(t, mux, "/test", `not json`)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var resp contactform.ErrorResponse
decodeBody(t, rec, &resp)
if resp.Error != "invalid_json" {
t.Errorf("error = %q, want invalid_json", resp.Error)
}
}
// ---------------------------------------------------------------------------
// TestHandlerValidationError
// ---------------------------------------------------------------------------
func TestHandlerValidationError(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
// Empty name triggers validation error.
rec := postJSON(t, mux, "/test",
`{"name":"","email":"a@b.com","message":"hi"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var resp contactform.ErrorResponse
decodeBody(t, rec, &resp)
if resp.Error != "validation" {
t.Errorf("error = %q, want validation", resp.Error)
}
if len(resp.Details) == 0 {
t.Fatal("details empty, want validation field errors")
}
if sender.sentCount() != 0 {
t.Errorf("sender called %d times even with validation error", sender.sentCount())
}
}
// ---------------------------------------------------------------------------
// TestHandlerHoneypot
// ---------------------------------------------------------------------------
func TestHandlerHoneypot(t *testing.T) {
form := testForm()
form.HoneypotField = "website"
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
// The honeypot value ("website":"bot") is ignored during JSON decode
// because Request.Honeypot is tagged `json:"-"`. The handler sees an
// empty Honeypot and proceeds to send the email normally.
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@b.com","message":"Hello there!","website":"bot_value"}`)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
// ---------------------------------------------------------------------------
// TestHandlerSendSuccess
// ---------------------------------------------------------------------------
func TestHandlerSendSuccess(t *testing.T) {
form := testForm()
sender := &mockSender{}
mux := newTestHandlerMux(t, form, sender, nil)
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp contactform.Response
decodeBody(t, rec, &resp)
if !resp.OK {
t.Errorf("OK = %v, want true", resp.OK)
}
if sender.sentCount() != 1 {
t.Fatalf("sender called %d times, want 1", sender.sentCount())
}
sender.mu.Lock()
got := sender.sent[0]
sender.mu.Unlock()
if got.Name != "Alice" {
t.Errorf("sent name = %q, want Alice", got.Name)
}
if got.Email != "a@example.com" {
t.Errorf("sent email = %q, want a@example.com", got.Email)
}
}
// ---------------------------------------------------------------------------
// TestHandlerSendError
// ---------------------------------------------------------------------------
func TestHandlerSendError(t *testing.T) {
form := testForm()
sender := &mockSender{sendErr: errors.New("smtp down")}
mux := newTestHandlerMux(t, form, sender, nil)
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
var resp contactform.ErrorResponse
decodeBody(t, rec, &resp)
if resp.Error != "send_failed" {
t.Errorf("error = %q, want send_failed", resp.Error)
}
}
// ---------------------------------------------------------------------------
// TestHandlerNewsletterAppend
// ---------------------------------------------------------------------------
func TestHandlerNewsletterAppend(t *testing.T) {
form := testNewsletterForm()
sender := &mockSender{}
store := &mockStore{}
mux := newTestHandlerMux(t, form, sender, store)
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@example.com"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp contactform.Response
decodeBody(t, rec, &resp)
if !resp.OK {
t.Errorf("OK = %v, want true", resp.OK)
}
if sender.sentCount() != 1 {
t.Errorf("sender called %d times, want 1", sender.sentCount())
}
store.mu.Lock()
n := len(store.appended)
store.mu.Unlock()
if n != 1 {
t.Fatalf("store appended %d subscribers, want 1", n)
}
store.mu.Lock()
sub := store.appended[0]
store.mu.Unlock()
if sub.Email != "a@example.com" {
t.Errorf("appended email = %q, want a@example.com", sub.Email)
}
if sub.Form != form.Name {
t.Errorf("appended form = %q, want %q", sub.Form, form.Name)
}
}
// ---------------------------------------------------------------------------
// TestHandlerNewsletterAppendError
// ---------------------------------------------------------------------------
func TestHandlerNewsletterAppendError(t *testing.T) {
form := testNewsletterForm()
sender := &mockSender{}
store := &mockStore{appendErr: errors.New("disk full")}
mux := newTestHandlerMux(t, form, sender, store)
rec := postJSON(t, mux, "/test",
`{"name":"Alice","email":"a@example.com"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
var resp contactform.ErrorResponse
decodeBody(t, rec, &resp)
if resp.Error != "storage_failed" {
t.Errorf("error = %q, want storage_failed", resp.Error)
}
// Sender should have been called successfully before the store error.
if sender.sentCount() != 1 {
t.Errorf("sender called %d times, want 1", sender.sentCount())
}
}