// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd package handler import ( "bytes" "encoding/json" "errors" "fmt" "mime/multipart" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "testing" "time" "sourcedock.dev/petrbalvin/nuntius/internal/config" "sourcedock.dev/petrbalvin/nuntius/internal/contactform" "sourcedock.dev/petrbalvin/nuntius/internal/storage" ) // --------------------------------------------------------------------------- // mocks // --------------------------------------------------------------------------- type mockSender struct { mu sync.Mutex sent []contactform.Request sendErr error acks []string ackErr 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) SendAcknowledgement(to string) error { m.mu.Lock() defer m.mu.Unlock() m.acks = append(m.acks, to) return m.ackErr } func (m *mockSender) sentCount() int { m.mu.Lock() defer m.mu.Unlock() return len(m.sent) } func (m *mockSender) ackCount() int { m.mu.Lock() defer m.mu.Unlock() return len(m.acks) } 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 } type mockArchive struct { mu sync.Mutex appended []storage.Submission appendErr error } func (m *mockArchive) Append(sub storage.Submission) error { m.mu.Lock() defer m.mu.Unlock() m.appended = append(m.appended, sub) return m.appendErr } type mockTelegram struct { mu sync.Mutex notes []string notifyErr error } func (m *mockTelegram) Notify(formName string, req contactform.Request) error { m.mu.Lock() defer m.mu.Unlock() m.notes = append(m.notes, formName) return m.notifyErr } // --------------------------------------------------------------------------- // helper functions // --------------------------------------------------------------------------- //go:fix inline func intPtr(i int) *int { return new(i) } //go:fix inline func strPtr(s string) *string { return new(s) } func testForm() *config.Form { return &config.Form{ Name: "test", Path: "/test", Type: "contact", AllowedOrigins: []string{"https://example.com"}, RateLimitPerHour: new(100), HoneypotField: new("website"), From: "noreply@test.com", To: "owner@test.com", } } func testNewsletterForm() *config.Form { f := testForm() f.Type = "newsletter" return f } // testLimiterSettings mirrors the server defaults for tests that build a // limiter without a full config. func testLimiterSettings(perHour int) limiterSettings { return limiterSettings{ perHour: perHour, maxBuckets: 32768, cleanupEvery: time.Hour, maxBucketAge: 2 * time.Hour, } } // newTestRateLimiter builds a limiter with the default mechanics and no // running cleanup goroutine. func newTestRateLimiter(perHour int) *rateLimiter { s := testLimiterSettings(perHour) return &rateLimiter{ perHour: s.perHour, maxBuckets: s.maxBuckets, cleanupEvery: s.cleanupEvery, maxBucketAge: s.maxBucketAge, buckets: make(map[string]*bucket), stopCh: make(chan struct{}), } } func newTestHandler(form *config.Form, sender formSender, store subscriberStorer) *ContactHandler { h := &ContactHandler{ maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: sender}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{}, archives: map[string]archiveStorer{}, notifiers: map[string]telegramNotifier{}, } h.stats = newFormStatsRegistry(h.forms) 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 trustProxy bool xff string xri string remoteAddr string want string }{ { name: "peer address is used when proxy headers are not trusted", trustProxy: false, xff: "1.2.3.4", xri: "", remoteAddr: "10.0.0.1:1234", want: "10.0.0.1", }, { name: "spoofed forwarded headers ignored without proxy trust", trustProxy: false, xff: "203.0.113.9", xri: "198.51.100.9", remoteAddr: "10.0.0.1:1234", want: "10.0.0.1", }, { name: "X-Forwarded-For single IP with proxy trust", trustProxy: true, 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 hop", trustProxy: true, 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 with proxy trust", trustProxy: true, xff: "", xri: "5.6.7.8", remoteAddr: "10.0.0.1:1234", want: "5.6.7.8", }, { name: "RemoteAddr fallback when proxy trusted but no headers set", trustProxy: true, xff: "", xri: "", remoteAddr: "9.10.11.12:8080", want: "9.10.11.12", }, { name: "RemoteAddr without port", trustProxy: false, xff: "", xri: "", remoteAddr: "9.10.11.12", want: "9.10.11.12", }, { name: "IPv6 RemoteAddr with port", trustProxy: false, 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, tt.trustProxy) if got != tt.want { t.Errorf("ClientIP() = %q, want %q", got, tt.want) } }) } } // A caller that rotates a forged X-Forwarded-For value must not receive a // fresh rate-limit bucket unless proxy headers are explicitly trusted. func TestHandlerRateLimitIgnoresSpoofedForwardedFor(t *testing.T) { form := testForm() form.RateLimitPerHour = new(1) sender := &mockSender{} h := &ContactHandler{ trustProxy: false, maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: sender}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{}, } mux := http.NewServeMux() h.Register(mux) rec1 := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@b.com","message":"Hello there!"}`) req2 := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"Bob","email":"b@b.com","message":"Hello there!"}`)) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("X-Forwarded-For", "203.0.113.99") // forged identity rotation rec2 := httptest.NewRecorder() mux.ServeHTTP(rec2, req2) if rec1.Code != http.StatusOK { t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusOK) } if rec2.Code != http.StatusTooManyRequests { t.Fatalf("second request status = %d, want %d (forged header must not reset the bucket)", rec2.Code, http.StatusTooManyRequests) } } // With an explicitly trusted proxy in front, distinct forwarded IPs get // distinct buckets. func TestHandlerRateLimitHonoursTrustedForwardedFor(t *testing.T) { form := testForm() form.RateLimitPerHour = new(1) sender := &mockSender{} h := &ContactHandler{ trustProxy: true, maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: sender}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{}, } mux := http.NewServeMux() h.Register(mux) post := func(xff string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Forwarded-For", xff) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec } if rec := post("203.0.113.1"); rec.Code != http.StatusOK { t.Fatalf("first forwarded client status = %d, want %d", rec.Code, http.StatusOK) } if rec := post("203.0.113.2"); rec.Code != http.StatusOK { t.Fatalf("second forwarded client status = %d, want %d (separate bucket expected)", rec.Code, http.StatusOK) } } // --------------------------------------------------------------------------- // TestRateLimiterAllow // --------------------------------------------------------------------------- func TestRateLimiterAllow(t *testing.T) { perHour := 3 rl := newTestRateLimiter(perHour) ip := "10.0.0.1" // First perHour requests should be allowed. for i := range perHour { 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 := newTestRateLimiter(perHour) 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 := range 2 { 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 = new(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 := range n { 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 := newTestRateLimiter(perHour) ipA := "10.0.0.1" ipB := "10.0.0.2" // Exhaust ipA's tokens. for i := range perHour { 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 := range perHour { 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 // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // 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_body", "Could not parse request 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_body" { t.Errorf("error = %q, want %q", resp.Error, "invalid_body") } if resp.Message != "Could not parse request body." { t.Errorf("message = %q, want %q", resp.Message, "Could not parse request 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 = new(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) } } // --------------------------------------------------------------------------- // TestHandlerRateLimitDisabled // --------------------------------------------------------------------------- func TestHandlerRateLimitDisabled(t *testing.T) { form := testForm() form.RateLimitPerHour = new(0) // explicit zero disables the limit sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) // Far more requests than the default bucket would allow. for i := range 5 { rec := postJSON(t, mux, "/test", fmt.Sprintf(`{"name":"User%d","email":"u%d@b.com","message":"Hello there!"}`, i, i)) if rec.Code != http.StatusOK { t.Fatalf("request %d status = %d, want %d (limit should be disabled)", i, rec.Code, http.StatusOK) } } } // --------------------------------------------------------------------------- // TestHandlerInvalidBody // --------------------------------------------------------------------------- func TestHandlerInvalidBody(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_body" { t.Errorf("error = %q, want invalid_body", 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()) } } // --------------------------------------------------------------------------- // TestHandlerServicePolicy // --------------------------------------------------------------------------- // A payload without a service field is always legitimate: the field is // optional, and petrbalvin.org posts without it. func TestHandlerPayloadWithoutServicePasses(t *testing.T) { form := testForm() // contact type: the built-in default list applies 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) } if sender.sentCount() != 1 { t.Errorf("sender called %d times, want 1", sender.sentCount()) } } // A configured services list drives the per-form allow-list: a listed // value passes, an unlisted one is rejected, and the empty value keeps // passing. func TestHandlerConfiguredServices(t *testing.T) { form := testForm() form.Services = []string{"consulting", "support"} sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","service":"consulting","message":"Hello there!"}`); rec.Code != http.StatusOK { t.Errorf("listed service status = %d, want %d", rec.Code, http.StatusOK) } if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","service":"","message":"Hello there!"}`); rec.Code != http.StatusOK { t.Errorf("empty service status = %d, want %d", rec.Code, http.StatusOK) } rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","service":"hairstyling","message":"Hello there!"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("unlisted service status = %d, want %d", rec.Code, http.StatusBadRequest) } var resp contactform.ErrorResponse decodeBody(t, rec, &resp) if len(resp.Details) == 0 || resp.Details[0].Field != "service" { t.Errorf("details = %+v, want a service field error", resp.Details) } // Only the two accepted posts reached the sender. if sender.sentCount() != 2 { t.Errorf("sender called %d times, want 2", sender.sentCount()) } } // A custom services list on a feedback form switches the field into that // form's validation too. func TestHandlerConfiguredServicesOnFeedback(t *testing.T) { form := testNewsletterForm() form.Type = "feedback" form.Services = []string{"bug", "idea"} sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","service":"idea","message":"Hello there!"}`); rec.Code != http.StatusOK { t.Errorf("listed service on feedback status = %d, want %d", rec.Code, http.StatusOK) } if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","message":"Hello there!"}`); rec.Code != http.StatusOK { t.Errorf("no service on feedback status = %d, want %d", rec.Code, http.StatusOK) } if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@example.com","service":"hairstyling","message":"Hello there!"}`); rec.Code != http.StatusBadRequest { t.Errorf("unlisted service on feedback status = %d, want %d", rec.Code, http.StatusBadRequest) } } // A false require_name takes the name field out of the validation. func TestHandlerRequireNameFromConfig(t *testing.T) { form := testForm() form.RequireName = new(false) sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postJSON(t, mux, "/test", `{"email":"a@example.com","message":"Hello there!"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if sender.sentCount() != 1 { t.Errorf("sender called %d times, want 1", sender.sentCount()) } } // --------------------------------------------------------------------------- // TestHandlerHoneypot // --------------------------------------------------------------------------- func TestHandlerHoneypot(t *testing.T) { form := testForm() form.HoneypotField = new("website") sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) // A non-empty honeypot field must be silently dropped: HTTP 200 but // no email sent. 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) } if n := sender.sentCount(); n != 0 { t.Errorf("expected honeypot to suppress send, got %d sends", n) } } func TestHandlerHoneypotEmptyPasses(t *testing.T) { form := testForm() form.HoneypotField = new("website") sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) // An empty honeypot field is legitimate human behaviour. rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@b.com","message":"Hello there!","website":""}`) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } if n := sender.sentCount(); n != 1 { t.Errorf("expected exactly 1 send, got %d", n) } } func TestHandlerBodyTooLarge(t *testing.T) { // A handler configured with a small body limit must reject anything // larger with 413 before the sender is reached. form := testForm() sender := &mockSender{} h := newTestHandler(form, sender, nil) h.maxBodyBytes = 64 mux := http.NewServeMux() h.Register(mux) body := fmt.Sprintf(`{"name":"A","email":"a@b.com","message":"%s"}`, strings.Repeat("x", 200)) rec := postJSON(t, mux, "/test", body) if rec.Code != http.StatusRequestEntityTooLarge { t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge) } if n := sender.sentCount(); n != 0 { t.Errorf("expected no send for oversized body, got %d", n) } } // The configured body limit must not clip legitimate payloads. func TestHandlerBodyWithinConfiguredLimit(t *testing.T) { form := testForm() sender := &mockSender{} h := newTestHandler(form, sender, nil) h.maxBodyBytes = 64 mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@b.com","message":"Hello there!"}`) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } } // A configured bucket cap bounds how many distinct IPs one form serves // between cleanup sweeps: once full, unknown IPs are denied. func TestHandlerRateLimitBucketCapFromConfig(t *testing.T) { form := testForm() sender := &mockSender{} h := newTestHandler(form, sender, nil) h.rateLimits["/test"] = newRateLimiter(limiterSettings{ perHour: 100, maxBuckets: 1, cleanupEvery: time.Hour, maxBucketAge: 2 * time.Hour, }) mux := http.NewServeMux() h.Register(mux) if rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@b.com","message":"Hello there!"}`); rec.Code != http.StatusOK { t.Fatalf("first IP status = %d, want %d", rec.Code, http.StatusOK) } // A second distinct IP finds the map full and is denied. req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"Bob","email":"b@b.com","message":"Hello there!"}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "203.0.113.50:4321" rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusTooManyRequests { t.Errorf("second IP status = %d, want %d (bucket cap exhausted)", rec.Code, http.StatusTooManyRequests) } } // New must wire the configured policies into the handler: body cap, // limiter mechanics, pending TTL and the per-form validation policy. func TestNewWiresConfiguredPolicies(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ DataDir: dir, Server: config.ServerConfig{ MaxBodyBytes: new(4096), RateLimitMaxBuckets: new(7), RateLimitCleanupSeconds: new(60), RateLimitMaxBucketAgeSeconds: new(120), }, Forms: []config.Form{ {Name: "n", Path: "/n", Type: "newsletter", To: "a@b.c", PendingTTLSeconds: new(3600), SMTP: config.SMTPConfig{Host: "h", Port: 587, User: "u"}}, }, } h := New(cfg) if h.maxBodyBytes != 4096 { t.Errorf("maxBodyBytes = %d, want 4096", h.maxBodyBytes) } lim := h.rateLimits["/n"] if lim.maxBuckets != 7 || lim.cleanupEvery != time.Minute || lim.maxBucketAge != 2*time.Minute { t.Errorf("limiter mechanics = %d/%v/%v, want 7/1m/2m", lim.maxBuckets, lim.cleanupEvery, lim.maxBucketAge) } if got := h.pendings["/n"].TTL(); got != time.Hour { t.Errorf("pending TTL = %v, want 1h", got) } h.Close() } // --------------------------------------------------------------------------- // 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) } } // --------------------------------------------------------------------------- // TestHandlerNewsletterDoubleOptIn // --------------------------------------------------------------------------- // mockConfirmSender records the opt-in links it "mails". type mockConfirmSender struct { *mockSender links []string } func (m *mockConfirmSender) SendConfirmation(to, link string) error { m.mu.Lock() defer m.mu.Unlock() m.links = append(m.links, link) return nil } func (m *mockConfirmSender) lastLink() string { m.mu.Lock() defer m.mu.Unlock() if len(m.links) == 0 { return "" } return m.links[len(m.links)-1] } // A newsletter signup must stay pending until the confirmation link is // redeemed; only then does the record land in the log and the owner get // notified. Replaying the consumed link is a harmless no-op. func TestHandlerNewsletterDoubleOptIn(t *testing.T) { form := testNewsletterForm() confirm := &mockConfirmSender{mockSender: &mockSender{}} store := &mockStore{} dir := t.TempDir() h := &ContactHandler{ trustProxy: false, dataDir: dir, maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: confirm}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{form.Path: store}, pendings: map[string]*storage.PendingStore{ form.Path: storage.NewPendingStore(filepath.Join(dir, "pending.json"), time.Hour), }, } h.stats = newFormStatsRegistry(h.forms) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"email":"jane@example.com"}`) if rec.Code != http.StatusOK { t.Fatalf("signup status = %d, want %d", rec.Code, http.StatusOK) } if n := confirm.sentCount(); n != 0 { t.Errorf("owner notified %d times at signup stage, want 0", n) } link := confirm.lastLink() if link == "" || !strings.Contains(link, "/test/confirm?token=") { t.Fatalf("confirmation link not captured: %q", link) } store.mu.Lock() n := len(store.appended) store.mu.Unlock() if n != 0 { t.Fatalf("%d records persisted before confirmation, want 0", n) } token := link[strings.Index(link, "token=")+len("token="):] page := httptest.NewRecorder() mux.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/test/confirm?token="+token, nil)) if page.Code != http.StatusOK { t.Fatalf("confirm status = %d, want %d", page.Code, http.StatusOK) } if !strings.Contains(page.Body.String(), "Subscription confirmed") { t.Errorf("unexpected confirmation page:\n%s", page.Body.String()) } store.mu.Lock() n, appended := len(store.appended), store.appended store.mu.Unlock() if n != 1 || appended[0].Email != "jane@example.com" { t.Fatalf("log after confirm = %+v, want one jane@example.com record", appended) } if confirm.sentCount() != 1 { t.Errorf("owner notifications after confirm = %d, want 1", confirm.sentCount()) } replay := httptest.NewRecorder() mux.ServeHTTP(replay, httptest.NewRequest(http.MethodGet, "/test/confirm?token="+token, nil)) if replay.Code != http.StatusGone { t.Fatalf("replay status = %d, want %d", replay.Code, http.StatusGone) } store.mu.Lock() n = len(store.appended) store.mu.Unlock() if n != 1 { t.Errorf("records after replay = %d, want 1", n) } mrec := httptest.NewRecorder() mux.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) var payload struct { Totals map[string]int64 `json:"totals"` } if err := json.Unmarshal(mrec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } for metric, want := range map[string]int64{ "confirmation_sent": 1, "confirmed": 1, "confirmation_failed": 1, } { if got := payload.Totals[metric]; got != want { t.Errorf("metrics[%s] = %d, want %d", metric, got, want) } } } // When persisting the confirmed record fails, the visitor sees a retry hint // and the pending entry stays alive so the link can be used again. func TestHandlerNewsletterConfirmStorageError(t *testing.T) { form := testNewsletterForm() confirm := &mockConfirmSender{mockSender: &mockSender{}} store := &mockStore{appendErr: errors.New("disk full")} dir := t.TempDir() h := &ContactHandler{ trustProxy: false, dataDir: dir, maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: confirm}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{form.Path: store}, pendings: map[string]*storage.PendingStore{ form.Path: storage.NewPendingStore(filepath.Join(dir, "pending.json"), time.Hour), }, } h.stats = newFormStatsRegistry(h.forms) mux := http.NewServeMux() h.Register(mux) if rec := postJSON(t, mux, "/test", `{"email":"a@example.com"}`); rec.Code != http.StatusOK { t.Fatalf("signup status = %d, want %d", rec.Code, http.StatusOK) } token := strings.Split(confirm.lastLink(), "token=")[1] page := httptest.NewRecorder() mux.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/test/confirm?token="+token, nil)) if page.Code != http.StatusInternalServerError { t.Fatalf("confirm status = %d, want %d", page.Code, http.StatusInternalServerError) } if !h.pendings["/test"].HasToken(token) { t.Error("pending entry dropped despite failed persist; retry impossible") } } func TestCloseIsIdempotent(t *testing.T) { h := newTestHandler(testForm(), &mockSender{}, nil) h.Close() // A second Close must be a no-op, not a panic on a closed channel. h.Close() } // A full persist/restore round trip: buckets survive a simulated restart, // stale entries are dropped, and unknown forms are ignored. func TestRateLimitStateRoundTrip(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ DataDir: dir, Forms: []config.Form{ {Name: "c", Path: "/c", Type: "contact", To: "a@b.c", SMTP: config.SMTPConfig{Host: "h", Port: 587, User: "u"}}, {Name: "gone", Path: "/gone", Type: "contact", To: "a@b.c", SMTP: config.SMTPConfig{Host: "h", Port: 587, User: "u"}}, }, } h := New(cfg) h.rateLimits["/c"].mu.Lock() h.rateLimits["/c"].buckets["1.2.3.4"] = &bucket{tokens: 5, last: time.Now()} h.rateLimits["/c"].buckets["stale.ip"] = &bucket{tokens: 5, last: time.Now().Add(-3 * time.Hour)} h.rateLimits["/c"].mu.Unlock() h.PersistState() // Simulate a restart where the "gone" form disappeared from the config. cfg.Forms = cfg.Forms[:1] h2 := New(cfg) if got := len(h2.rateLimits); got != 1 { t.Fatalf("restarted handler has %d limiters, want 1", got) } if len(h2.rateLimits["/c"].buckets) != 1 { t.Fatalf("restored bucket count = %d, want 1 (fresh IP only)", len(h2.rateLimits["/c"].buckets)) } if got := h2.rateLimits["/c"].buckets["1.2.3.4"]; got == nil || got.tokens < 4 { t.Fatalf("restored bucket = %+v, want ~5 tokens for 1.2.3.4", got) } if !h2.rateLimits["/c"].allow("1.2.3.4") { t.Fatal("restored bucket should still allow a request") } } // The metrics registry counts what the pipeline reports and /metrics // serves those counters as JSON. One walkthrough keeps the endpoint honest // without a dedicated test harness. func TestMetricsCountAndServe(t *testing.T) { form := testForm() sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postJSON(t, mux, "/test", `{"name":"Alice","email":"a@b.com","message":"Hello there!"}`) if rec.Code != http.StatusOK { t.Fatalf("signup status = %d, want %d", rec.Code, http.StatusOK) } mrec := httptest.NewRecorder() mux.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if mrec.Code != http.StatusOK { t.Fatalf("metrics status = %d, want %d", mrec.Code, http.StatusOK) } var payload struct { Totals map[string]int64 `json:"totals"` Forms map[string]map[string]int64 `json:"forms"` } if err := json.Unmarshal(mrec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } fs := payload.Forms["/test"] if fs["sent"] != 1 || fs["received"] != 1 || fs["duplicate_signup"] != 0 { t.Errorf("per-form counters = %v, want sent=1 received=1", fs) } if payload.Totals["sent"] != 1 { t.Errorf("totals.sent = %d, want 1", payload.Totals["sent"]) } // Unknown metrics and unknown paths are dropped; nil stats are 404. h := newTestHandler(testForm(), sender, nil) h.bump("/nonexistent", metricSent) h.bump("/test", "not_a_metric") noreg := &ContactHandler{} nr := httptest.NewRecorder() noreg.Metrics(nr, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if nr.Code != http.StatusNotFound { t.Errorf("metrics without registry status = %d, want %d", nr.Code, http.StatusNotFound) } } // With a dedupe-aware store, an address that is already recorded gets the // same success response without a second confirmation mail. func TestHandlerNewsletterDuplicateSuppressed(t *testing.T) { dir := t.TempDir() form := testNewsletterForm() store := storage.NewDedupeNewsletterStore(storage.NewNewsletterStore(filepath.Join(dir, "subs.jsonl"))) if err := store.Append(storage.Subscriber{Email: "jane@example.com"}); err != nil { t.Fatal(err) } confirm := &mockConfirmSender{mockSender: &mockSender{}} h := &ContactHandler{ trustProxy: false, dataDir: dir, maxBodyBytes: 1 << 20, forms: map[string]*config.Form{form.Path: form}, senders: map[string]formSender{form.Path: confirm}, rateLimits: map[string]*rateLimiter{form.Path: newTestRateLimiter(form.RateLimit())}, stores: map[string]subscriberStorer{form.Path: store}, pendings: map[string]*storage.PendingStore{ form.Path: storage.NewPendingStore(filepath.Join(dir, "pending.json"), time.Hour), }, } h.stats = newFormStatsRegistry(h.forms) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"email":"jane@example.com"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if len(confirm.links) != 0 { t.Errorf("confirmation mails sent = %d, want 0", len(confirm.links)) } n, err := store.Count() if err != nil || n != 1 { t.Errorf("log lines = %d (err %v), want exactly one record", n, err) } mrec := httptest.NewRecorder() mux.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) var payload struct { Totals map[string]int64 `json:"totals"` } if err := json.Unmarshal(mrec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } if payload.Totals["duplicate_signup"] != 1 { t.Errorf("duplicate_signup counter = %d, want 1", payload.Totals["duplicate_signup"]) } } // --------------------------------------------------------------------------- // Plain HTML form posts: urlencoded and multipart, with and without a // redirect_url. // --------------------------------------------------------------------------- // postForm sends an application/x-www-form-urlencoded POST. func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } func TestFormPostRedirectsOnSuccess(t *testing.T) { form := testForm() form.RedirectURL = "https://example.com/thanks" sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postForm(mux, "/test", url.Values{ "name": {"Jane Doe"}, "email": {"jane@example.com"}, "message": {"Hello, I would like to discuss an engagement."}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther) } if loc := rec.Header().Get("Location"); loc != "https://example.com/thanks" { t.Errorf("location = %q, want the configured redirect_url", loc) } if sender.sentCount() != 1 { t.Errorf("mails sent = %d, want 1", sender.sentCount()) } } // TestFormPostWithoutRedirectKeepsJSON pins the default: an omitted // redirect_url keeps the JSON contract for fetch-based frontends. func TestFormPostWithoutRedirectKeepsJSON(t *testing.T) { form := testForm() sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postForm(mux, "/test", url.Values{ "name": {"Jane Doe"}, "email": {"jane@example.com"}, "message": {"Hello, I would like to discuss an engagement."}, }) 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) } } // TestFormPostValidationStaysJSON documents the error contract: only the // success answer speaks browser, failures stay machine-readable. func TestFormPostValidationStaysJSON(t *testing.T) { form := testForm() form.RedirectURL = "https://example.com/thanks" sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postForm(mux, "/test", url.Values{ "name": {"J"}, "email": {"not-an-address"}, "message": {"hi"}, }) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } if loc := rec.Header().Get("Location"); loc != "" { t.Errorf("location = %q, want none on failure", loc) } var resp contactform.ErrorResponse decodeBody(t, rec, &resp) if resp.Error != "validation" || len(resp.Details) == 0 { t.Errorf("error = %q with %d details, want validation with details", resp.Error, len(resp.Details)) } if sender.sentCount() != 0 { t.Errorf("mails sent = %d, want 0", sender.sentCount()) } } func TestFormPostHoneypotSilentRedirect(t *testing.T) { form := testForm() form.RedirectURL = "https://example.com/thanks" sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postForm(mux, "/test", url.Values{ "name": {"Bot"}, "email": {"bot@spam.example"}, "message": {"buy cheap clicks"}, "website": {"https://spam.example"}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d (silent accept)", rec.Code, http.StatusSeeOther) } if sender.sentCount() != 0 { t.Errorf("mails sent = %d, want 0", sender.sentCount()) } mrec := httptest.NewRecorder() mux.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) var payload struct { Totals map[string]int64 `json:"totals"` } if err := json.Unmarshal(mrec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } if payload.Totals["honeypot_blocked"] != 1 { t.Errorf("honeypot_blocked counter = %d, want 1", payload.Totals["honeypot_blocked"]) } } func TestMultipartFormPostRedirects(t *testing.T) { form := testForm() form.RedirectURL = "https://example.com/thanks" sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) var buf bytes.Buffer mw := multipart.NewWriter(&buf) for k, v := range map[string]string{ "name": "Jane Doe", "email": "jane@example.com", "service": "architecture", "message": "Hello, I would like to discuss an engagement.", } { if err := mw.WriteField(k, v); err != nil { t.Fatalf("write field: %v", err) } } if err := mw.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } req := httptest.NewRequest(http.MethodPost, "/test", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther) } sent := sender.sent if len(sent) != 1 || sent[0].Service != "architecture" { t.Errorf("sent = %+v, want one request with the service field", sent) } } // TestFormPostIgnoresQueryParameters pins the body as the only source: // a query string cannot smuggle or override payload fields. func TestFormPostIgnoresQueryParameters(t *testing.T) { form := testForm() sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) req := httptest.NewRequest(http.MethodPost, "/test?name=Spoof&email=spoof@example.com&message=spoofed", nil) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d (empty body, empty fields)", rec.Code, http.StatusBadRequest) } if sender.sentCount() != 0 { t.Errorf("mails sent = %d, want 0", sender.sentCount()) } } // --------------------------------------------------------------------------- // Submission archive: written before the send, so a failed SMTP // round-trip loses nothing and a failed append blocks the send. // --------------------------------------------------------------------------- func newArchivingHandler(t *testing.T, form *config.Form, sender formSender) (*ContactHandler, *mockArchive) { t.Helper() h := newTestHandler(form, sender, nil) archive := &mockArchive{} h.archives[form.Path] = archive return h, archive } func TestArchiveLandsBeforeSend(t *testing.T) { form := testForm() form.Archive = true sender := &mockSender{sendErr: fmt.Errorf("smtp unreachable")} h, archive := newArchivingHandler(t, form, sender) mux := http.NewServeMux() h.Register(mux) // The send fails, yet the submission is on disk: nothing is lost. rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } if len(archive.appended) != 1 { t.Fatalf("archived = %d, want exactly one record despite the send failure", len(archive.appended)) } if got := archive.appended[0]; got.Email != "jane@example.com" || got.Message != "Hello there" { t.Errorf("archived = %+v, want the submitted payload", got) } // A healthy send archives and delivers. sender.sendErr = nil rec = postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("second status = %d, want %d", rec.Code, http.StatusOK) } if len(archive.appended) != 2 { t.Errorf("archived = %d, want 2", len(archive.appended)) } } func TestArchiveAppendFailureBlocksSend(t *testing.T) { form := testForm() form.Archive = true sender := &mockSender{} h, archive := newArchivingHandler(t, form, sender) archive.appendErr = fmt.Errorf("disk full") mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@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 != "storage_failed" { t.Errorf("error = %q, want storage_failed", resp.Error) } if sender.sentCount() != 0 { t.Errorf("mails sent = %d, want 0 (the record must not split from the mail)", sender.sentCount()) } } // TestNoArchiveWithoutTheKey pins the default: New() wires an archive // store only for a form that asked for one. func TestNoArchiveWithoutTheKey(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), Forms: []config.Form{*testForm()}, } h := New(cfg) defer h.Close() if _, ok := h.archives["/test"]; ok { t.Errorf("archive store wired for a form without archive = true") } } // --------------------------------------------------------------------------- // Auto-reply: a best-effort receipt after the owner mail went out. // --------------------------------------------------------------------------- func TestAutoReplyMailedAfterSend(t *testing.T) { form := testForm() form.AutoReply = true sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if sender.sentCount() != 1 { t.Fatalf("owner mails = %d, want 1", sender.sentCount()) } if sender.ackCount() != 1 || sender.acks[0] != "jane@example.com" { t.Errorf("acknowledgements = %v, want one to jane@example.com", sender.acks) } } // TestAutoReplyFailureKeepsSuccess pins the contract: the receipt is // courtesy, its failure must not turn an accepted submission into an // error, and the failure lands in its own counter. func TestAutoReplyFailureKeepsSuccess(t *testing.T) { form := testForm() form.AutoReply = true sender := &mockSender{ackErr: fmt.Errorf("smtp unreachable")} mux := newTestHandlerMux(t, form, sender, nil) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if sender.sentCount() != 1 { t.Errorf("owner mails = %d, want 1", sender.sentCount()) } mrec := httptest.NewRecorder() mux.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) var payload struct { Totals map[string]int64 `json:"totals"` } if err := json.Unmarshal(mrec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } if payload.Totals["auto_reply_failed"] != 1 { t.Errorf("auto_reply_failed counter = %d, want 1", payload.Totals["auto_reply_failed"]) } } // TestNoAutoReplyWithoutTheKey pins the default. func TestNoAutoReplyWithoutTheKey(t *testing.T) { form := testForm() sender := &mockSender{} mux := newTestHandlerMux(t, form, sender, nil) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if sender.ackCount() != 0 { t.Errorf("acknowledgements = %d, want 0", sender.ackCount()) } } // --------------------------------------------------------------------------- // Metrics bearer token. // --------------------------------------------------------------------------- func newTokenedHandler(form *config.Form, token string) http.Handler { h := newTestHandler(form, &mockSender{}, nil) h.metricsToken = token mux := http.NewServeMux() h.Register(mux) return mux } func TestMetricsBearerTokenRequired(t *testing.T) { mux := newTokenedHandler(testForm(), "admin") req := httptest.NewRequest(http.MethodGet, "/metrics", nil) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status without a header = %d, want %d", rec.Code, http.StatusUnauthorized) } if got := rec.Header().Get("WWW-Authenticate"); got != `Bearer realm="nuntius metrics"` { t.Errorf("www-authenticate = %q, want the bearer challenge", got) } req = httptest.NewRequest(http.MethodGet, "/metrics", nil) req.Header.Set("Authorization", "Bearer wrong") rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status with a wrong token = %d, want %d", rec.Code, http.StatusUnauthorized) } req = httptest.NewRequest(http.MethodGet, "/metrics", nil) req.Header.Set("Authorization", "Bearer admin") rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status with the right token = %d, want %d", rec.Code, http.StatusOK) } } // TestMetricsOpenWithoutToken pins the default: an unset metrics_token // keeps the endpoint open, protected at the reverse proxy. func TestMetricsOpenWithoutToken(t *testing.T) { mux := newTokenedHandler(testForm(), "") rec := httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } } // --------------------------------------------------------------------------- // Telegram: the bell beside the mail. The submission counts as delivered // when either channel gets through. // --------------------------------------------------------------------------- // newTelegramHandler wires the form into a handler with a Telegram // channel: the fresh mock when notifier is nil, the given one otherwise. func newTelegramHandler(t *testing.T, form *config.Form, sender formSender, notifier telegramNotifier) (*ContactHandler, *mockTelegram) { t.Helper() h := newTestHandler(form, sender, nil) telegram := &mockTelegram{} if notifier != nil { h.notifiers[form.Path] = notifier } else { h.notifiers[form.Path] = telegram } return h, telegram } func telegramTotals(t *testing.T, mux http.Handler) map[string]int64 { t.Helper() rec := httptest.NewRecorder() mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) var payload struct { Totals map[string]int64 `json:"totals"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode metrics: %v", err) } return payload.Totals } func TestTelegramDeliversBesideTheMail(t *testing.T) { form := testForm() sender := &mockSender{} h, telegram := newTelegramHandler(t, form, sender, nil) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if sender.sentCount() != 1 || len(telegram.notes) != 1 { t.Errorf("mails = %d, notifications = %d, want one of each", sender.sentCount(), len(telegram.notes)) } totals := telegramTotals(t, mux) if totals["sent"] != 1 || totals["telegram_failed"] != 0 { t.Errorf("sent = %d, telegram_failed = %d, want 1 and 0", totals["sent"], totals["telegram_failed"]) } } // TestTelegramSavesAFailedMail pins the useful half of the contract: an // SMTP outage does not silence the bell, and the caller still sees // success because the message reached the owner. func TestTelegramSavesAFailedMail(t *testing.T) { form := testForm() sender := &mockSender{sendErr: fmt.Errorf("smtp unreachable")} h, telegram := newTelegramHandler(t, form, sender, nil) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (the bell rang)", rec.Code, http.StatusOK) } if len(telegram.notes) != 1 { t.Fatalf("notifications = %d, want 1", len(telegram.notes)) } totals := telegramTotals(t, mux) if totals["send_failed"] != 1 || totals["sent"] != 0 { t.Errorf("send_failed = %d, sent = %d, want 1 and 0", totals["send_failed"], totals["sent"]) } } func TestTelegramFailureStillFailsWithoutTheMail(t *testing.T) { form := testForm() sender := &mockSender{sendErr: fmt.Errorf("smtp unreachable")} notifier := &mockTelegram{notifyErr: fmt.Errorf("telegram down")} h, _ := newTelegramHandler(t, form, sender, notifier) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d (nothing got through)", rec.Code, http.StatusInternalServerError) } totals := telegramTotals(t, mux) if totals["send_failed"] != 1 || totals["telegram_failed"] != 1 { t.Errorf("send_failed = %d, telegram_failed = %d, want 1 and 1", totals["send_failed"], totals["telegram_failed"]) } } func TestTelegramFailureBesideAMailedSubmission(t *testing.T) { form := testForm() sender := &mockSender{} notifier := &mockTelegram{notifyErr: fmt.Errorf("telegram down")} h, _ := newTelegramHandler(t, form, sender, notifier) mux := http.NewServeMux() h.Register(mux) rec := postJSON(t, mux, "/test", `{"name":"Jane Doe","email":"jane@example.com","message":"Hello there"}`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (the mail went out)", rec.Code, http.StatusOK) } totals := telegramTotals(t, mux) if totals["telegram_failed"] != 1 || totals["sent"] != 1 { t.Errorf("telegram_failed = %d, sent = %d, want 1 and 1", totals["telegram_failed"], totals["sent"]) } } // TestNoTelegramWithoutTheKey pins the default: New() wires a notifier // only for a form that carries the telegram table. func TestNoTelegramWithoutTheKey(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), Forms: []config.Form{*testForm()}, } h := New(cfg) defer h.Close() if _, ok := h.notifiers["/test"]; ok { t.Errorf("telegram notifier wired for a form without the table") } }