// 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()) } }