From 4dd7b6991d0d9d7018dd731b068dac0d986420f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 26 Jul 2026 20:59:56 +0200 Subject: [PATCH] test: add tests for handler, email, cmd/server, version and enforce 80% coverage --- .gitea/workflows/test.yml | 5 + cmd/server/main_test.go | 118 +++++ internal/config/config_test.go | 13 + internal/email/sender_test.go | 314 ++++++++++++ internal/handler/contact.go | 19 +- internal/handler/contact_test.go | 804 +++++++++++++++++++++++++++++++ internal/version/version_test.go | 20 + justfile | 8 +- 8 files changed, 1295 insertions(+), 6 deletions(-) create mode 100644 cmd/server/main_test.go create mode 100644 internal/email/sender_test.go create mode 100644 internal/handler/contact_test.go create mode 100644 internal/version/version_test.go diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 17d13e8..1cb63c0 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -49,6 +49,11 @@ jobs: - name: go test -race run: go test -race -count=1 ./... + - name: Check coverage threshold + run: | + go test -coverprofile=coverage.out ./internal/... ./pkg/... + go tool cover -func=coverage.out | grep '^total:' | python3 -c "import sys; pct=float(sys.stdin.read().split()[2].rstrip('%')); sys.exit(0 if pct >= 80 else 1)" || (echo "ERROR: coverage < 80%"; exit 1) + - name: go test ./examples run: go build ./examples/... diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..0ca3f71 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,118 @@ +//go:build linux || freebsd + +package main + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestItoa(t *testing.T) { + tests := []struct { + n int + want string + }{ + {0, "0"}, + {1, "1"}, + {9, "9"}, + {10, "10"}, + {42, "42"}, + {100, "100"}, + {-1, "-1"}, + {-42, "-42"}, + {8080, "8080"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := itoa(tt.n); got != tt.want { + t.Errorf("itoa(%d) = %q, want %q", tt.n, got, tt.want) + } + }) + } +} + +func TestLast(t *testing.T) { + tests := []struct { + s string + c byte + want int + }{ + {"hello", 'l', 3}, + {"hello", 'o', 4}, + {"hello", 'h', 0}, + {"hello", 'x', -1}, + {"", 'a', -1}, + {"aaa", 'a', 2}, + } + + for _, tt := range tests { + if got := last(tt.s, tt.c); got != tt.want { + t.Errorf("last(%q, %q) = %d, want %d", tt.s, tt.c, got, tt.want) + } + } +} + +func TestClientIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + want string + }{ + {"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"}, + {"ipv6 with brackets", "[::1]:12345", "[::1]"}, + {"ipv4 without port", "192.168.1.1", "192.168.1.1"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = tt.remoteAddr + if got := clientIP(req); got != tt.want { + t.Errorf("clientIP() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStatusRecorder(t *testing.T) { + rec := httptest.NewRecorder() + sr := &statusRecorder{ResponseWriter: rec, status: http.StatusOK} + + // Default status before WriteHeader. + if sr.status != http.StatusOK { + t.Errorf("initial status = %d, want %d", sr.status, http.StatusOK) + } + + sr.WriteHeader(http.StatusNotFound) + if sr.status != http.StatusNotFound { + t.Errorf("status after WriteHeader = %d, want %d", sr.status, http.StatusNotFound) + } + + // rec should also have the status set. + if rec.Code != http.StatusNotFound { + t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestWithRequestLog(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + wrapped := withRequestLog(handler, logger) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.1:12345" + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3923982..540ab82 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -95,3 +95,16 @@ to = "you@example.com" t.Fatal("expected an error for an unknown field") } } + +func TestConfigPath(t *testing.T) { + if got := ConfigPath(); got != "/etc/nuntius/config.toml" { + t.Errorf("ConfigPath() = %q, want %q", got, "/etc/nuntius/config.toml") + } +} + +func TestSMTPConfigAddrFor(t *testing.T) { + smtp := SMTPConfig{Host: "smtp.example.com", Port: 587} + if got := smtp.AddrFor(); got != "smtp.example.com:587" { + t.Errorf("AddrFor() = %q, want %q", got, "smtp.example.com:587") + } +} diff --git a/internal/email/sender_test.go b/internal/email/sender_test.go new file mode 100644 index 0000000..e4f55e3 --- /dev/null +++ b/internal/email/sender_test.go @@ -0,0 +1,314 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package email + +import ( + "strings" + "testing" + + "sourcedock.dev/petrbalvin/nuntius/pkg/contactform" +) + +func TestComposeContactWithoutService(t *testing.T) { + req := contactform.Request{ + Name: "Alice", + Email: "alice@example.com", + Message: "I have a question about your services.", + } + b := compose("from@example.com", "to@example.com", req, "MyForm", "contact") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") { + t.Errorf("expected subject with form name only, got body:\n%s", s) + } + if !strings.Contains(s, "From: from@example.com") { + t.Error("expected From header") + } + if !strings.Contains(s, "To: to@example.com") { + t.Error("expected To header") + } + if !strings.Contains(s, "Reply-To: alice@example.com") { + t.Error("expected Reply-To header") + } + if !strings.Contains(s, "MIME-Version: 1.0") { + t.Error("expected MIME-Version header") + } + if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") { + t.Error("expected text/plain part") + } + if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") { + t.Error("expected text/html part") + } + if !strings.Contains(s, "Content-Type: multipart/alternative") { + t.Error("expected multipart/alternative content type") + } + if !strings.Contains(s, "I have a question about your services.") { + t.Error("expected message content in body") + } + if !strings.Contains(s, "Contact Form Submission:") { + t.Error("expected contact form plain-text header") + } +} + +func TestComposeContactWithService(t *testing.T) { + req := contactform.Request{ + Name: "Bob", + Email: "bob@example.com", + Service: "architecture", + Message: "I would like a consultation.", + } + b := compose("from@e.com", "to@e.com", req, "ContactForm", "contact") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") { + t.Errorf("expected subject with service tag, got body:\n%s", s) + } + if !strings.Contains(s, "Service interest: architecture") { + t.Error("expected service interest in plain text") + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part") + } + if !strings.Contains(s, "bob@example.com") { + t.Error("expected submitter email in body") + } +} + +func TestComposeFeedback(t *testing.T) { + req := contactform.Request{ + Name: "Carol", + Email: "carol@example.com", + Message: "Great platform, but can you add dark mode?", + } + b := compose("sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") { + t.Errorf("expected feedback subject, got body:\n%s", s) + } + if !strings.Contains(s, "New Feedback:") { + t.Error("expected feedback plain-text header") + } + if !strings.Contains(s, "From: Carol ") { + t.Error("expected From line in plain text") + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part") + } +} + +func TestComposeNewsletter(t *testing.T) { + req := contactform.Request{ + Email: "subscriber@example.com", + } + b := compose("news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") { + t.Errorf("expected newsletter subject, got body:\n%s", s) + } + if !strings.Contains(s, "New Newsletter Subscriber:") { + t.Error("expected newsletter plain-text header") + } + if !strings.Contains(s, "Email: subscriber@example.com") { + t.Error("expected subscriber email in plain text") + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part") + } +} + +func TestComposeGeneric(t *testing.T) { + req := contactform.Request{ + Name: "Dave", + Email: "dave@example.com", + Message: "Generic inquiry.", + } + b := compose("g@h.com", "g@h.com", req, "GenericForm", "generic") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") { + t.Errorf("expected generic subject, got body:\n%s", s) + } + if !strings.Contains(s, "New Submission:") { + t.Error("expected generic plain-text header") + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part") + } +} + +func TestComposeEmptyFormNameAndEmail(t *testing.T) { + req := contactform.Request{ + Name: "", + Email: "", + Message: "", + } + b := compose("", "", req, "", "generic") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/] New submission") { + t.Errorf("expected subject with empty form name, got body:\n%s", s) + } + if !strings.Contains(s, "MIME-Version: 1.0") { + t.Error("expected MIME-Version header even with empty fields") + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part even with empty fields") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part even with empty fields") + } +} + +func TestComposeLongMessage(t *testing.T) { + longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200) + req := contactform.Request{ + Name: "Eve", + Email: "eve@example.com", + Message: longMsg, + } + b := compose("x@y.com", "z@y.com", req, "LongForm", "feedback") + s := string(b) + + if !strings.Contains(s, longMsg) { + t.Error("expected long message content in body") + } + // Verify it's in both text and html parts by checking after the respective + // content-type boundaries. + textIdx := strings.Index(s, "Content-Type: text/plain") + htmlIdx := strings.Index(s, "Content-Type: text/html") + if textIdx == -1 || htmlIdx == -1 { + t.Fatal("expected both text/plain and text/html parts") + } + textPart := s[textIdx:htmlIdx] + htmlPart := s[htmlIdx:] + if !strings.Contains(textPart, longMsg) { + t.Error("expected long message in text/plain part") + } + if !strings.Contains(htmlPart, longMsg) { + t.Error("expected long message in text/html part") + } +} + +func TestComposeSpecialCharacters(t *testing.T) { + specialMsg := "Café résumé — déjà vu\nLine\twith\ttabs\n€uro sign © 2026" + req := contactform.Request{ + Name: "Renée", + Email: "renée@example.com", + Message: specialMsg, + } + b := compose("ñ@c.com", "ö@c.com", req, "SpaForm", "contact") + s := string(b) + + if !strings.Contains(s, "Café résumé — déjà vu") { + t.Error("expected accented characters to survive round-trip") + } + if !strings.Contains(s, "€uro sign © 2026") { + t.Error("expected special symbols to survive round-trip") + } + if !strings.Contains(s, "Renée") { + t.Error("expected accented name in body") + } + if !strings.Contains(s, "MIME-Version: 1.0") { + t.Error("expected MIME-Version header") + } +} + +func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) { + // Unknown form type should default to the contact template. + req := contactform.Request{ + Name: "Fallback", + Email: "fallback@example.com", + Message: "Does this work?", + } + b := compose("a@b.com", "c@b.com", req, "UnknownForm", "nonexistent") + s := string(b) + + if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") { + t.Errorf("expected contact fallback subject, got body:\n%s", s) + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Error("expected text/plain part in fallback") + } + if !strings.Contains(s, "Content-Type: text/html") { + t.Error("expected text/html part in fallback") + } +} + +func TestComposeAllHeadersPresent(t *testing.T) { + req := contactform.Request{ + Name: "Test", + Email: "test@example.com", + Message: "Checking headers.", + } + b := compose("from@x.com", "to@x.com", req, "HeaderForm", "contact") + s := string(b) + + required := []string{ + "From: from@x.com", + "To: to@x.com", + "Subject:", + "Date:", + "Reply-To: test@example.com", + "MIME-Version: 1.0", + "Content-Type: multipart/alternative", + } + for _, h := range required { + if !strings.Contains(s, h) { + t.Errorf("expected header %q in message", h) + } + } +} + +func TestComposeMultipartBoundary(t *testing.T) { + req := contactform.Request{ + Name: "Boundary", + Email: "boundary@example.com", + Message: "Boundary test.", + } + b := compose("from@t.com", "to@t.com", req, "BoundForm", "contact") + s := string(b) + + if !strings.Contains(s, "--nuntius-boundary") { + t.Error("expected nuntius-boundary separator") + } + // Should have opening boundary, middle boundary, closing boundary. + count := strings.Count(s, "--nuntius-boundary") + if count < 3 { + t.Errorf("expected at least 3 boundary markers, got %d", count) + } + if !strings.Contains(s, "--nuntius-boundary--") { + t.Error("expected closing boundary marker") + } +} + +func TestComposeDeliveredByFooter(t *testing.T) { + req := contactform.Request{ + Name: "Footer", + Email: "footer@example.com", + Message: "Footer check.", + } + for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} { + b := compose("f@t.com", "t@t.com", req, "FooterForm", ft) + s := string(b) + if !strings.Contains(s, "Delivered by nuntius") { + t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft) + } + } +} diff --git a/internal/handler/contact.go b/internal/handler/contact.go index ce20061..2e6f567 100644 --- a/internal/handler/contact.go +++ b/internal/handler/contact.go @@ -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] diff --git a/internal/handler/contact_test.go b/internal/handler/contact_test.go new file mode 100644 index 0000000..3c0955a --- /dev/null +++ b/internal/handler/contact_test.go @@ -0,0 +1,804 @@ +// Copyright (c) 2026 Petr Balvín (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()) + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..dff2312 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd + +package version + +import "testing" + +func TestName(t *testing.T) { + if Name != "nuntius" { + t.Errorf("Name = %q, want %q", Name, "nuntius") + } +} + +func TestVersion(t *testing.T) { + if Version == "" { + t.Error("Version must not be empty") + } +} diff --git a/justfile b/justfile index 543e468..e476761 100644 --- a/justfile +++ b/justfile @@ -23,8 +23,12 @@ build: go build -ldflags="-s -w" -o bin/nuntius ./cmd/server test: - @echo "→ Running tests…" - go test ./... + @echo "→ Running all tests…" + go test -race -count=1 ./... + @echo "→ Checking coverage threshold (≥80% for internal + pkg)…" + go test -coverprofile=coverage.out ./internal/... ./pkg/... + @go tool cover -func=coverage.out | grep '^total:' | python3 -c "import sys; pct=float(sys.stdin.read().split()[2].rstrip('%')); sys.exit(0 if pct >= 80 else 1)" && echo "Coverage ✓" || (echo "ERROR: coverage < 80%"; exit 1) + @rm -f coverage.out # Format Go source files fmt: