1946 lines
61 KiB
Go
1946 lines
61 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
|
||
|
|
}
|
||
|
|
}
|