1123 lines
40 KiB
Go
1123 lines
40 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"migadu_counters/internal/api"
|
|
"migadu_counters/internal/config"
|
|
"migadu_counters/internal/counting"
|
|
"migadu_counters/internal/domains"
|
|
"migadu_counters/internal/metrics"
|
|
"migadu_counters/internal/notify"
|
|
"migadu_counters/internal/store"
|
|
"migadu_counters/internal/txlog"
|
|
)
|
|
|
|
// Real CDB: yagolivas.com = org 1049, O/i=200,O/o=20,D/i=200,D/o=20,A/i=200,A/o=20.
|
|
const (
|
|
testCDB = "../../test_files/rspamd_domains_db.cdb"
|
|
token = "secret-token"
|
|
day = "2026-10-13"
|
|
)
|
|
|
|
var clock = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
|
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
// capture records notification requests.
|
|
type capture struct {
|
|
mu sync.Mutex
|
|
urls []string
|
|
}
|
|
|
|
func (c *capture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
c.mu.Lock()
|
|
c.urls = append(c.urls, r.URL.String())
|
|
c.mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (c *capture) count() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.urls)
|
|
}
|
|
|
|
type options struct {
|
|
token string
|
|
cidrs []netip.Prefix
|
|
handlerTimeout time.Duration
|
|
writeAddr string // override the write Redis (e.g. a black hole)
|
|
domainURL bool
|
|
exempt map[string]bool
|
|
queueSize int
|
|
workers int
|
|
}
|
|
|
|
type harness struct {
|
|
t *testing.T
|
|
api *httptest.Server
|
|
admin *httptest.Server
|
|
write *miniredis.Miniredis
|
|
read *miniredis.Miniredis
|
|
st *store.Store
|
|
db atomic.Pointer[domains.DB]
|
|
lookup atomic.Pointer[counting.LookupFunc]
|
|
notifier *notify.Notifier
|
|
addrURL *capture
|
|
orgURL *capture
|
|
domURL *capture
|
|
log *syncBuffer
|
|
tx *syncBuffer
|
|
reg *prometheus.Registry
|
|
m *metrics.Metrics
|
|
token string
|
|
srv *Server
|
|
}
|
|
|
|
func newHarness(t *testing.T, o options) *harness {
|
|
t.Helper()
|
|
if o.token == "" {
|
|
o.token = token
|
|
}
|
|
if o.handlerTimeout == 0 {
|
|
o.handlerTimeout = 4 * time.Second
|
|
}
|
|
if o.exempt == nil {
|
|
o.exempt = map[string]bool{"support@migadu.com": true, "contact@migadu.com": true}
|
|
}
|
|
h := &harness{t: t, token: o.token, log: &syncBuffer{}, tx: &syncBuffer{}}
|
|
|
|
h.write, h.read = miniredis.RunT(t), miniredis.RunT(t)
|
|
writeAddr := h.write.Addr()
|
|
if o.writeAddr != "" {
|
|
writeAddr = o.writeAddr
|
|
}
|
|
var err error
|
|
h.st, err = store.New(config.RedisConfig{WriteAddr: writeAddr, ReadAddrs: []string{h.read.Addr()}, PoolSize: 4, TimeoutMS: 2000})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { h.st.Close() })
|
|
|
|
db, err := domains.Open(testCDB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
h.db.Store(db)
|
|
defaultLookup := counting.LookupFunc(func(d string) (*domains.Domain, error) { return h.db.Load().Lookup(d) })
|
|
h.lookup.Store(&defaultLookup)
|
|
|
|
h.addrURL, h.orgURL, h.domURL = &capture{}, &capture{}, &capture{}
|
|
sa, so, sd := httptest.NewServer(h.addrURL), httptest.NewServer(h.orgURL), httptest.NewServer(h.domURL)
|
|
t.Cleanup(sa.Close)
|
|
t.Cleanup(so.Close)
|
|
t.Cleanup(sd.Close)
|
|
q := "/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
|
|
ncfg := notify.Config{AddressURL: sa.URL + q, OrgURL: so.URL + q, User: "u", Password: "p", Timeout: time.Second, Attempts: 2, Backoff: time.Millisecond}
|
|
if o.domainURL {
|
|
ncfg.DomainURL = sd.URL + q
|
|
}
|
|
|
|
h.reg = prometheus.NewRegistry()
|
|
h.m = metrics.New(h.reg)
|
|
tx := txlog.New(h.tx, "text", func() time.Time { return clock })
|
|
h.notifier = notify.New(ncfg, NotifyHooks(tx, h.m))
|
|
t.Cleanup(func() { h.notifier.Shutdown(context.Background()) })
|
|
|
|
logger := slog.New(slog.NewTextHandler(h.log, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
planner := &counting.Planner{
|
|
Lookup: counting.LookupFunc(func(d string) (*domains.Domain, error) { return (*h.lookup.Load())(d) }),
|
|
Exempt: o.exempt, Now: func() time.Time { return clock },
|
|
}
|
|
s := New(Deps{
|
|
AuthToken: o.token,
|
|
AllowedCIDRs: o.cidrs,
|
|
HandlerTimeout: o.handlerTimeout,
|
|
QueueSize: o.queueSize,
|
|
Workers: o.workers,
|
|
Exempt: o.exempt,
|
|
Counter: &counting.Counter{Planner: planner, Store: h.st, ReReadTimeout: 200 * time.Millisecond},
|
|
Store: h.st,
|
|
Domains: func() *domains.DB { return h.db.Load() },
|
|
Notifier: h.notifier,
|
|
TxLog: tx,
|
|
Metrics: h.m,
|
|
Gatherer: h.reg,
|
|
Logger: logger,
|
|
Now: func() time.Time { return clock },
|
|
})
|
|
h.srv = s
|
|
h.api = httptest.NewServer(s.Handler())
|
|
h.admin = httptest.NewServer(s.AdminHandler())
|
|
t.Cleanup(h.api.Close)
|
|
t.Cleanup(h.admin.Close)
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.Drain(ctx)
|
|
})
|
|
return h
|
|
}
|
|
|
|
// settle waits until every accepted job has been processed.
|
|
func (h *harness) settle() {
|
|
h.t.Helper()
|
|
waitFor(h.t, func() bool { return h.srv.Pending() == 0 }, "queue did not settle")
|
|
}
|
|
|
|
func waitFor(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !cond() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal(msg)
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// accept posts a body that must be accepted (202) and waits for it to be counted.
|
|
func (h *harness) accept(path, body string) {
|
|
h.t.Helper()
|
|
st, b := h.post(path, body)
|
|
if st != 202 || strings.TrimSpace(b) != `{"accepted":true}` {
|
|
h.t.Fatalf("%s %s: %d %s, want 202 accepted", path, body, st, b)
|
|
}
|
|
h.settle()
|
|
}
|
|
|
|
func (h *harness) redisInt(key string) int64 {
|
|
h.t.Helper()
|
|
v, err := h.write.Get(key)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
h.t.Fatalf("key %s holds %q", key, v)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// rawPostAndClose is a fire-and-forget client: it writes the request and
|
|
// closes the socket without reading the reply.
|
|
func rawPostAndClose(t *testing.T, addr, path, body string) {
|
|
t.Helper()
|
|
c, err := net.Dial("tcp", addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := fmt.Sprintf("POST %s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", path, token, len(body), body)
|
|
if _, err := c.Write([]byte(req)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Close()
|
|
}
|
|
|
|
// do sends a request with the default headers unless overridden; a header
|
|
// value of "-" removes it.
|
|
func (h *harness) do(method, path, body string, hdr map[string]string) (int, http.Header, string) {
|
|
h.t.Helper()
|
|
req, err := http.NewRequest(method, h.api.URL+path, strings.NewReader(body))
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
for k, v := range hdr {
|
|
if v == "-" {
|
|
req.Header.Del(k)
|
|
} else {
|
|
req.Header.Set(k, v)
|
|
}
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, resp.Header, string(b)
|
|
}
|
|
|
|
func (h *harness) post(path, body string) (int, string) {
|
|
st, _, b := h.do(http.MethodPost, path, body, nil)
|
|
return st, b
|
|
}
|
|
|
|
func (h *harness) get(path string) (int, string) {
|
|
st, _, b := h.do(http.MethodGet, path, "", nil)
|
|
return st, b
|
|
}
|
|
|
|
func (h *harness) metric(c prometheus.Collector) float64 { return testutil.ToFloat64(c) }
|
|
|
|
func TestOut_CountsSenderAndExternalRecipient(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.accept("/out", `{"trace_id":"tr1","auth_user":"a@yagolivas.com","mail_from":"a@yagolivas.com","recipient":"ext@gmail.com"}`)
|
|
for _, k := range []string{store.Key("A", "a@yagolivas.com", day, "o"), store.Key("D", "yagolivas.com", day, "o"), store.Key("O", "1049", day, "o")} {
|
|
if h.redisInt(k) != 1 {
|
|
t.Errorf("redis %s = %d, want 1", k, h.redisInt(k))
|
|
}
|
|
}
|
|
if keys := h.write.Keys(); len(keys) != 3 {
|
|
t.Errorf("redis keys = %v, want the 3 outgoing keys only", keys)
|
|
}
|
|
if got := h.tx.String(); !strings.Contains(got, "<tr1> out; tracked address counters for a@yagolivas.com => count=1 limit=20 reached=false") ||
|
|
!strings.Contains(got, "recipient domain not in domains db: gmail.com (not counted)") {
|
|
t.Errorf("transaction log:\n%s", got)
|
|
}
|
|
if h.metric(h.m.Requests.WithLabelValues("/out", "202")) != 1 || h.metric(h.m.Increments.WithLabelValues("A", "o")) != 1 {
|
|
t.Error("metrics not recorded")
|
|
}
|
|
}
|
|
|
|
func TestOut_LocalRecipientCountsIncomingToo(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.accept("/out", `{"trace_id":"t","auth_user":"a@yagolivas.com","recipient":"b@yagolivas.com"}`)
|
|
if keys := h.write.Keys(); len(keys) != 6 {
|
|
t.Errorf("redis keys = %v, want 6", keys)
|
|
}
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 1 || h.redisInt(store.Key("O", "1049", day, "i")) != 1 {
|
|
t.Errorf("recipient incoming not counted: %v", h.write.Keys())
|
|
}
|
|
}
|
|
|
|
func TestOut_ReachedNotifiesConfiguredLevelsOnce(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
for i := 1; i <= 21; i++ {
|
|
h.accept("/out", `{"trace_id":"t","auth_user":"a@yagolivas.com","recipient":"ext@gmail.com"}`)
|
|
}
|
|
if err := h.notifier.Shutdown(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.addrURL.count() != 1 || h.orgURL.count() != 1 || h.domURL.count() != 0 {
|
|
t.Errorf("notifications addr=%d org=%d dom=%d, want 1/1/0", h.addrURL.count(), h.orgURL.count(), h.domURL.count())
|
|
}
|
|
if u := h.addrURL.urls[0]; !strings.Contains(u, "level=A") || !strings.Contains(u, "id=a%40yagolivas.com") || !strings.Contains(u, "count=20") || !strings.Contains(u, "by=a%40yagolivas.com") {
|
|
t.Errorf("address notification url = %s", u)
|
|
}
|
|
tx := h.tx.String()
|
|
for _, want := range []string{
|
|
"tracked address counters for a@yagolivas.com => count=20 limit=20 reached=true",
|
|
"tracked address counters for a@yagolivas.com => count=21 limit=20 reached=false",
|
|
"limit reached (level=[A] id=[a@yagolivas.com] flow=[o] by=[a@yagolivas.com]) => notifying address_url",
|
|
"limit reached (level=[O] id=[1049] flow=[o] by=[a@yagolivas.com]) => notifying org_url",
|
|
"limit reached (level=[D] id=[yagolivas.com] flow=[o] by=[a@yagolivas.com]) => no domain_url configured, not notifying",
|
|
"sent limit exceeded notification to url",
|
|
} {
|
|
if !strings.Contains(tx, want) {
|
|
t.Errorf("transaction log lacks %q:\n%s", want, tx)
|
|
}
|
|
}
|
|
if h.metric(h.m.LimitReached.WithLabelValues("A", "o")) != 1 || h.metric(h.m.Notifications.WithLabelValues("A", "sent")) != 1 ||
|
|
h.metric(h.m.Notifications.WithLabelValues("D", "disabled")) != 1 {
|
|
t.Error("limit/notification metrics not recorded")
|
|
}
|
|
}
|
|
|
|
func TestIn_CountsRecipientOnly(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.accept("/in", `{"trace_id":"t","mail_from":"s@gmail.com","recipient":"b@yagolivas.com","junk":false}`)
|
|
if keys := h.write.Keys(); len(keys) != 3 {
|
|
t.Errorf("redis keys = %v", keys)
|
|
}
|
|
if h.redisInt(store.Key("O", "1049", day, "i")) != 1 {
|
|
t.Error("O/i not counted")
|
|
}
|
|
if !strings.Contains(h.tx.String(), "<t> in; tracked account counters for 1049 => count=1 limit=200 reached=false") {
|
|
t.Errorf("transaction log:\n%s", h.tx.String())
|
|
}
|
|
}
|
|
|
|
func TestSkips(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
cases := []struct{ path, body, skipped string }{
|
|
{"/in", `{"trace_id":"s1","mail_from":"s@gmail.com","recipient":"b@yagolivas.com","junk":true}`, "junk"},
|
|
{"/in", `{"trace_id":"s2","mail_from":"s@gmail.com","recipient":"nobody@gmail.com"}`, "unknown_domain"},
|
|
{"/in", `{"trace_id":"s3","mail_from":"s@gmail.com","recipient":"support@migadu.com"}`, "exempt"},
|
|
{"/out", `{"trace_id":"s4","auth_user":"contact@migadu.com","recipient":"b@yagolivas.com"}`, "exempt"},
|
|
{"/out", `{"trace_id":"s5","auth_user":"a@unknown.tld","recipient":"b@gmail.com"}`, "unknown_domain"},
|
|
}
|
|
for i, c := range cases {
|
|
h.accept(c.path, c.body)
|
|
if !strings.Contains(h.tx.String(), fmt.Sprintf("<s%d> %s; skipped: %s", i+1, strings.TrimPrefix(c.path, "/"), c.skipped)) {
|
|
t.Errorf("%s %s: transaction log lacks the skip line:\n%s", c.path, c.body, h.tx.String())
|
|
}
|
|
}
|
|
if keys := h.write.Keys(); len(keys) != 0 {
|
|
t.Errorf("skipped requests wrote keys: %v", keys)
|
|
}
|
|
}
|
|
|
|
// --- review round 1: counting is decoupled from the request (#1) -------------
|
|
|
|
// A fire-and-forget client that closes the socket before the reply must not
|
|
// cancel the count (reviewer A: 20 of 20 lost before the fix).
|
|
func TestClientDisconnectStillCounts(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
real := *h.lookup.Load()
|
|
slow := counting.LookupFunc(func(d string) (*domains.Domain, error) {
|
|
time.Sleep(20 * time.Millisecond)
|
|
return real(d)
|
|
})
|
|
h.lookup.Store(&slow)
|
|
addr := strings.TrimPrefix(h.api.URL, "http://")
|
|
const n = 20
|
|
for i := 0; i < n; i++ {
|
|
rawPostAndClose(t, addr, "/in", `{"trace_id":"dc","mail_from":"s@gmail.com","recipient":"a@yagolivas.com"}`)
|
|
}
|
|
key := store.Key("A", "a@yagolivas.com", day, "i")
|
|
waitFor(t, func() bool { return h.redisInt(key) == n }, "disconnected clients' requests were not all counted")
|
|
h.lookup.Store(&real)
|
|
for i := 0; i < 300; i++ {
|
|
rawPostAndClose(t, addr, "/in", `{"trace_id":"dc","mail_from":"s@gmail.com","recipient":"b@yagolivas.com"}`)
|
|
}
|
|
key = store.Key("A", "b@yagolivas.com", day, "i")
|
|
waitFor(t, func() bool { return h.redisInt(key) == 300 }, fmt.Sprintf("immediate disconnects: counter = %d, want 300", h.redisInt(key)))
|
|
}
|
|
|
|
// The reply arrives before the Redis work: even a stalled Redis does not
|
|
// delay the 202.
|
|
func TestAcceptedBeforeCounting(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0") // black hole: accepts, never answers
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
}
|
|
}()
|
|
h := newHarness(t, options{writeAddr: ln.Addr().String(), handlerTimeout: 300 * time.Millisecond})
|
|
start := time.Now()
|
|
st, body := h.post("/in", `{"trace_id":"bh","recipient":"b@yagolivas.com"}`)
|
|
if st != 202 || time.Since(start) > 200*time.Millisecond {
|
|
t.Errorf("status %d %s after %v, want an immediate 202", st, body, time.Since(start))
|
|
}
|
|
h.settle()
|
|
if tx := h.tx.String(); !strings.Contains(tx, "<bh> in; redis error:") || !strings.Contains(tx, "nothing counted") {
|
|
t.Errorf("worker should log the Redis failure:\n%s", tx)
|
|
}
|
|
if h.metric(h.m.RedisErrors.WithLabelValues("incr")) < 1 {
|
|
t.Error("redis error metric not recorded")
|
|
}
|
|
}
|
|
|
|
// A full queue answers 503 and the drop is visible in metrics and the log.
|
|
func TestQueueFull(t *testing.T) {
|
|
release := make(chan struct{})
|
|
h := newHarness(t, options{queueSize: 1, workers: 1})
|
|
real := *h.lookup.Load()
|
|
blocking := counting.LookupFunc(func(d string) (*domains.Domain, error) {
|
|
<-release
|
|
return real(d)
|
|
})
|
|
h.lookup.Store(&blocking)
|
|
// First job occupies the worker, second fills the queue.
|
|
for i := 0; i < 2; i++ {
|
|
if st, body := h.post("/in", `{"trace_id":"q","recipient":"b@yagolivas.com"}`); st != 202 {
|
|
t.Fatalf("request %d: %d %s", i, st, body)
|
|
}
|
|
}
|
|
waitFor(t, func() bool { return h.srv.Depth() == 1 }, "worker did not take the first job")
|
|
st, body := h.post("/in", `{"trace_id":"full","recipient":"b@yagolivas.com"}`)
|
|
if st != 503 || strings.TrimSpace(body) != `{"error":"queue full"}` {
|
|
t.Errorf("queue full = %d %s", st, body)
|
|
}
|
|
if h.metric(h.m.Dropped.WithLabelValues("/in")) != 1 || h.metric(h.m.Requests.WithLabelValues("/in", "503")) != 1 {
|
|
t.Error("dropped / 503 metrics not recorded")
|
|
}
|
|
if !strings.Contains(h.tx.String(), "<full> in; dropped: queue full (not counted)") {
|
|
t.Errorf("transaction log:\n%s", h.tx.String())
|
|
}
|
|
close(release)
|
|
h.settle()
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 2 {
|
|
t.Errorf("accepted jobs should still be counted: %v", h.write.Keys())
|
|
}
|
|
}
|
|
|
|
// Drain finishes every accepted job before returning.
|
|
func TestDrainCountsEverything(t *testing.T) {
|
|
h := newHarness(t, options{workers: 1})
|
|
real := *h.lookup.Load()
|
|
slow := counting.LookupFunc(func(d string) (*domains.Domain, error) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
return real(d)
|
|
})
|
|
h.lookup.Store(&slow)
|
|
for i := 0; i < 10; i++ {
|
|
if st, _ := h.post("/in", `{"recipient":"b@yagolivas.com"}`); st != 202 {
|
|
t.Fatal(st)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := h.srv.Drain(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 10 {
|
|
t.Errorf("after Drain counter = %d, want 10", h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")))
|
|
}
|
|
if st, body := h.post("/in", `{"recipient":"b@yagolivas.com"}`); st != 503 {
|
|
t.Errorf("after Drain = %d %s, want 503 (not accepting)", st, body)
|
|
}
|
|
}
|
|
|
|
// --- review round 1: only counted fields are validated (#2), token logins (#3)
|
|
|
|
// Reviewer B: every odd-but-accepted-by-mizu mail_from used to 400 the
|
|
// request and lose the recipient's count.
|
|
func TestIn_OddMailFromStillCountsRecipient(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
senders := []string{
|
|
"john smith@example.com",
|
|
"a@b@example.com",
|
|
"info@bücher.de",
|
|
"postmaster@[192.0.2.1]",
|
|
"bounce@mail_relay.example.com",
|
|
strings.Repeat("v", 250) + "@example.com",
|
|
}
|
|
for _, mf := range senders {
|
|
h.accept("/in", `{"trace_id":"odd","mail_from":"`+mf+`","recipient":"b@yagolivas.com"}`)
|
|
}
|
|
if got := h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")); got != int64(len(senders)) {
|
|
t.Errorf("recipient counted %d times, want %d", got, len(senders))
|
|
}
|
|
if !strings.Contains(h.tx.String(), "request mail_from=john smith@example.com recipient=b@yagolivas.com") ||
|
|
!strings.Contains(h.tx.String(), "request mail_from=info@xn--bcher-kva.de") {
|
|
t.Errorf("transaction log should show the raw / punycode sender:\n%s", h.tx.String())
|
|
}
|
|
}
|
|
|
|
// Reviewer B: an unusual external recipient used to drop the sender's count.
|
|
func TestOut_OddRecipientStillCountsSender(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
for _, rc := range []string{"x@[192.0.2.1]", "x@host_name.example.com", "", "nobody"} {
|
|
h.accept("/out", `{"trace_id":"odd","auth_user":"a@yagolivas.com","mail_from":"a@yagolivas.com","recipient":"`+rc+`"}`)
|
|
}
|
|
if got := h.redisInt(store.Key("A", "a@yagolivas.com", day, "o")); got != 4 {
|
|
t.Errorf("sender outgoing = %d, want 4", got)
|
|
}
|
|
tx := h.tx.String()
|
|
for _, want := range []string{
|
|
"recipient address not usable: x@[192.0.2.1] (invalid e-mail address, not counted)",
|
|
"recipient address not usable: x@host_name.example.com (invalid e-mail address, not counted)",
|
|
"recipient address not usable: <empty> (missing, not counted)",
|
|
"recipient address not usable: nobody (invalid e-mail address, not counted)",
|
|
} {
|
|
if !strings.Contains(tx, want) {
|
|
t.Errorf("transaction log lacks %q:\n%s", want, tx)
|
|
}
|
|
}
|
|
// IDN recipients are counted under their punycode key when hosted.
|
|
h.accept("/out", `{"trace_id":"idn","auth_user":"a@yagolivas.com","recipient":"x@Bücher.de"}`)
|
|
if !strings.Contains(h.tx.String(), "<idn> out; recipient domain not in domains db: xn--bcher-kva.de (not counted)") {
|
|
t.Errorf("IDN recipient should be looked up as punycode:\n%s", h.tx.String())
|
|
}
|
|
}
|
|
|
|
// Reviewer A: master/support logins arrive as user@dom@SUFFIX.
|
|
func TestOut_LoginSuffixCountsBaseAddress(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.accept("/out", `{"trace_id":"m1","auth_user":"x@yagolivas.com@SUPPORTTOKEN","mail_from":"x@yagolivas.com","recipient":"y@yagolivas.com"}`)
|
|
if h.redisInt(store.Key("A", "x@yagolivas.com", day, "o")) != 1 || h.redisInt(store.Key("A", "y@yagolivas.com", day, "i")) != 1 {
|
|
t.Errorf("base address not counted: %v", h.write.Keys())
|
|
}
|
|
if !strings.Contains(h.tx.String(), "<m1> out; request auth_user=x@yagolivas.com recipient=y@yagolivas.com mail_from=x@yagolivas.com login_suffix=SUPPORTTOKEN") {
|
|
t.Errorf("transaction log:\n%s", h.tx.String())
|
|
}
|
|
}
|
|
|
|
// --- review round 1: honest handling of ambiguous Redis results (#4) ---------
|
|
|
|
// Reviewer A: one key holding garbage used to fail the whole request and lose
|
|
// the crossing on the other keys.
|
|
func TestPartialExecFailureStillNotifies(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.write.Set(store.Key("O", "1049", day, "o"), "garbage")
|
|
h.write.Set(store.Key("A", "x@yagolivas.com", day, "o"), "19") // A/o=20: this request crosses
|
|
h.accept("/out", `{"trace_id":"p1","auth_user":"x@yagolivas.com","recipient":"z@gmail.com"}`)
|
|
if h.redisInt(store.Key("A", "x@yagolivas.com", day, "o")) != 20 || h.redisInt(store.Key("D", "yagolivas.com", day, "o")) != 1 {
|
|
t.Errorf("good keys not counted: %v", h.write.Keys())
|
|
}
|
|
if err := h.notifier.Shutdown(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.addrURL.count() != 1 {
|
|
t.Errorf("address notifications = %d, want 1", h.addrURL.count())
|
|
}
|
|
tx := h.tx.String()
|
|
if !strings.Contains(tx, "<p1> out; tracked address counters for x@yagolivas.com => count=20 limit=20 reached=true") ||
|
|
!strings.Contains(tx, "<p1> out; redis error on key cnt/O/1049/d/"+day+"/o: ") ||
|
|
strings.Contains(tx, "nothing counted") {
|
|
t.Errorf("transaction log:\n%s", tx)
|
|
}
|
|
if h.metric(h.m.RedisErrors.WithLabelValues("incr")) != 1 {
|
|
t.Errorf("redis_errors_total{incr} = %v, want 1 (the failed key)", h.metric(h.m.RedisErrors.WithLabelValues("incr")))
|
|
}
|
|
}
|
|
|
|
// delayProxy forwards to backend and, once it has seen EXEC, holds back the
|
|
// next reply longer than the client's read timeout.
|
|
func delayProxy(t *testing.T, backend string, delay time.Duration) string {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
var remaining atomic.Int64
|
|
remaining.Store(1)
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
b, err := net.Dial("tcp", backend)
|
|
if err != nil {
|
|
c.Close()
|
|
continue
|
|
}
|
|
var armed atomic.Bool
|
|
go func() {
|
|
defer b.Close()
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := c.Read(buf)
|
|
if n > 0 {
|
|
if bytes.Contains(bytes.ToLower(buf[:n]), []byte("exec")) {
|
|
armed.Store(true)
|
|
}
|
|
if _, werr := b.Write(buf[:n]); werr != nil {
|
|
return
|
|
}
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer c.Close()
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := b.Read(buf)
|
|
if n > 0 {
|
|
if armed.Load() && remaining.Add(-1) >= 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
if _, werr := c.Write(buf[:n]); werr != nil {
|
|
return
|
|
}
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
// Reviewer B: a lost EXEC reply used to be logged "nothing counted" although
|
|
// the counters moved, so the crossing was never notified that day.
|
|
func TestLostReplyStillNotifies(t *testing.T) {
|
|
backend := miniredis.RunT(t)
|
|
proxy := delayProxy(t, backend.Addr(), 3*time.Second)
|
|
h := newHarness(t, options{writeAddr: proxy})
|
|
key := store.Key("A", "a@yagolivas.com", day, "o") // A/o=20
|
|
backend.Set(key, "19")
|
|
h.accept("/out", `{"trace_id":"lost","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
|
|
if v, _ := backend.Get(key); v != "20" {
|
|
t.Fatalf("redis value = %s, the increment should have been applied", v)
|
|
}
|
|
if err := h.notifier.Shutdown(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.addrURL.count() != 1 {
|
|
t.Errorf("address notifications = %d, want 1 after the master re-read", h.addrURL.count())
|
|
}
|
|
tx := h.tx.String()
|
|
if !strings.Contains(tx, "<lost> out; redis outcome unknown: ") || !strings.Contains(tx, "re-read from master") ||
|
|
!strings.Contains(tx, "tracked address counters for a@yagolivas.com => count=20 limit=20 reached=true (re-read)") {
|
|
t.Errorf("transaction log:\n%s", tx)
|
|
}
|
|
if h.metric(h.m.RedisErrors.WithLabelValues("incr_unknown")) != 1 {
|
|
t.Error("redis_errors_total{incr_unknown} not recorded")
|
|
}
|
|
}
|
|
|
|
// --- HTTP validation layer, one test per step -------------------------------
|
|
|
|
func TestValidation_Method(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
st, hdr, body := h.do(http.MethodGet, "/out", "", nil)
|
|
if st != 405 || hdr.Get("Allow") != "POST" || !strings.Contains(body, `"error"`) {
|
|
t.Errorf("GET /out = %d %v %s", st, hdr, body)
|
|
}
|
|
if st, _, _ := h.do(http.MethodPost, "/counters?address=a@yagolivas.com", `{}`, nil); st != 405 {
|
|
t.Errorf("POST /counters = %d", st)
|
|
}
|
|
if st, _, _ := h.do(http.MethodPut, "/in", `{}`, nil); st != 405 {
|
|
t.Errorf("PUT /in = %d", st)
|
|
}
|
|
}
|
|
|
|
func TestValidation_BodyTooLarge(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
big := `{"recipient":"b@yagolivas.com","pad":"` + strings.Repeat("x", 65*1024) + `"}`
|
|
if st, body := h.post("/in", big); st != 413 {
|
|
t.Errorf("65 KB body = %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestValidation_BearerToken(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
body := `{"mail_from":"s@gmail.com","recipient":"b@yagolivas.com"}`
|
|
for name, hdr := range map[string]map[string]string{
|
|
"missing": {"Authorization": "-"},
|
|
"wrong": {"Authorization": "Bearer nope"},
|
|
"basic": {"Authorization": "Basic dTpw"},
|
|
"empty": {"Authorization": "Bearer "},
|
|
} {
|
|
st, h2, b := h.do(http.MethodPost, "/in", body, hdr)
|
|
if st != 401 || h2.Get("WWW-Authenticate") != "Bearer" {
|
|
t.Errorf("%s: %d %v %s", name, st, h2, b)
|
|
}
|
|
}
|
|
if st, _, _ := h.do(http.MethodGet, "/counters?address=b@yagolivas.com", "", map[string]string{"Authorization": "-"}); st != 401 {
|
|
t.Errorf("GET /counters without token = %d", st)
|
|
}
|
|
if keys := h.write.Keys(); len(keys) != 0 {
|
|
t.Errorf("rejected requests wrote keys: %v", keys)
|
|
}
|
|
if h.metric(h.m.Requests.WithLabelValues("/in", "401")) != 4 {
|
|
t.Errorf("401s not counted: %v", h.metric(h.m.Requests.WithLabelValues("/in", "401")))
|
|
}
|
|
}
|
|
|
|
func TestValidation_SourceIP(t *testing.T) {
|
|
denied := newHarness(t, options{cidrs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}})
|
|
if st, body := denied.post("/in", `{"recipient":"b@yagolivas.com"}`); st != 403 {
|
|
t.Errorf("from 127.0.0.1 with allowlist 10/8 = %d %s", st, body)
|
|
}
|
|
allowed := newHarness(t, options{cidrs: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}})
|
|
if st, body := allowed.post("/in", `{"recipient":"b@yagolivas.com"}`); st != 202 {
|
|
t.Errorf("from loopback with loopback allowlist = %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestValidation_ContentType(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
body := `{"recipient":"b@yagolivas.com"}`
|
|
if st, _, _ := h.do(http.MethodPost, "/in", body, map[string]string{"Content-Type": "text/plain"}); st != 415 {
|
|
t.Errorf("text/plain = %d", st)
|
|
}
|
|
if st, _, _ := h.do(http.MethodPost, "/in", body, map[string]string{"Content-Type": "-"}); st != 415 {
|
|
t.Errorf("missing content type = %d", st)
|
|
}
|
|
if st, _, b := h.do(http.MethodPost, "/in", body, map[string]string{"Content-Type": "application/json; charset=utf-8"}); st != 202 {
|
|
t.Errorf("json with charset = %d %s", st, b)
|
|
}
|
|
}
|
|
|
|
func TestValidation_JSONAndFields(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
cases := []struct {
|
|
name, path, body, wantErr string
|
|
}{
|
|
{"syntax", "/in", `{"recipient": `, "invalid json"},
|
|
{"two objects", "/in", `{"recipient":"b@yagolivas.com"}{}`, "exactly one JSON object"},
|
|
{"array", "/in", `[]`, "invalid json"},
|
|
{"missing recipient", "/in", `{"mail_from":"s@gmail.com"}`, "recipient: missing"},
|
|
{"bad recipient", "/in", `{"recipient":"nobody"}`, "recipient: invalid e-mail address"},
|
|
{"both sides missing", "/out", `{"mail_from":"a@yagolivas.com"}`, "auth_user: missing; recipient: missing"},
|
|
{"both sides bad", "/out", `{"auth_user":"nobody","recipient":"x@[192.0.2.1]"}`, "auth_user: invalid e-mail address; recipient: invalid e-mail address"},
|
|
{"wrong type", "/in", `{"recipient":"b@yagolivas.com","junk":"yes"}`, "invalid json"},
|
|
}
|
|
for _, c := range cases {
|
|
st, body := h.post(c.path, c.body)
|
|
if st != 400 || !strings.Contains(body, c.wantErr) {
|
|
t.Errorf("%s: %d %s (want 400 containing %q)", c.name, st, body, c.wantErr)
|
|
}
|
|
}
|
|
// Unknown fields are accepted so mailqueuer can add fields later; a bad
|
|
// mail_from and a missing auth_user never reject on their own (#2).
|
|
for _, body := range []string{
|
|
`{"recipient":"b@yagolivas.com","future_field":1}`,
|
|
`{"auth_user":"a@yagolivas.com","recipient":"b@yagolivas.com","mail_from":"x"}`,
|
|
`{"recipient":"b@yagolivas.com"}`,
|
|
} {
|
|
if st, b := h.post("/out", body); st != 202 {
|
|
t.Errorf("%s = %d %s, want 202", body, st, b)
|
|
}
|
|
}
|
|
h.settle()
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 3 || h.redisInt(store.Key("A", "a@yagolivas.com", day, "o")) != 1 {
|
|
t.Errorf("only the accepted requests may write keys: %v", h.write.Keys())
|
|
}
|
|
if !strings.Contains(h.log.String(), "recipient: missing") {
|
|
t.Errorf("rejections should be logged:\n%s", h.log.String())
|
|
}
|
|
}
|
|
|
|
// The synchronous part is still bounded by handler_timeout: a validation
|
|
// layer that stalls (here: a slow body) answers 503 instead of hanging.
|
|
func TestValidation_HandlerTimeout(t *testing.T) {
|
|
h := newHarness(t, options{handlerTimeout: 100 * time.Millisecond})
|
|
pr, pw := io.Pipe()
|
|
go func() {
|
|
pw.Write([]byte(`{"recipient":`))
|
|
time.Sleep(400 * time.Millisecond)
|
|
pw.Write([]byte(`"b@yagolivas.com"}`))
|
|
pw.Close()
|
|
}()
|
|
req, _ := http.NewRequest(http.MethodPost, h.api.URL+"/in", pr)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
start := time.Now()
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 503 || !strings.Contains(string(b), "timeout") || time.Since(start) > time.Second {
|
|
t.Errorf("status %d %s after %v", resp.StatusCode, b, time.Since(start))
|
|
}
|
|
}
|
|
|
|
// A panic while counting (here: in the domain lookup) is recovered in the
|
|
// worker: the request was already accepted, the panic is logged and the
|
|
// worker keeps serving.
|
|
func TestWorkerRecoversFromPanic(t *testing.T) {
|
|
h := newHarness(t, options{workers: 1})
|
|
boom := counting.LookupFunc(func(string) (*domains.Domain, error) { panic("boom") })
|
|
prev := h.lookup.Swap(&boom)
|
|
h.accept("/in", `{"trace_id":"pan","recipient":"b@yagolivas.com"}`)
|
|
h.lookup.Store(prev)
|
|
h.accept("/in", `{"recipient":"b@yagolivas.com"}`)
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 1 {
|
|
t.Error("worker must keep serving after a panic")
|
|
}
|
|
if !strings.Contains(h.log.String(), "panic") || !strings.Contains(h.log.String(), "pan") {
|
|
t.Errorf("panic should be logged with the trace id:\n%s", h.log.String())
|
|
}
|
|
}
|
|
|
|
// A panic in the synchronous part is still answered 500 and the daemon lives.
|
|
func TestValidation_RecoverFromPanic(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.srv.panicHook = func() { panic("sync boom") }
|
|
st, body := h.post("/in", `{"recipient":"b@yagolivas.com"}`)
|
|
if st != 500 || !strings.Contains(body, "internal error") {
|
|
t.Errorf("panic = %d %s", st, body)
|
|
}
|
|
h.srv.panicHook = nil
|
|
h.accept("/in", `{"recipient":"b@yagolivas.com"}`)
|
|
if !strings.Contains(h.log.String(), "panic") {
|
|
t.Error("panic should be logged")
|
|
}
|
|
}
|
|
|
|
func TestUnknownPath(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
if st, body := h.get("/nope"); st != 404 || !strings.Contains(body, `"error"`) {
|
|
t.Errorf("/nope = %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestRedisDown_LoggedNotCounted(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.write.Close()
|
|
h.accept("/in", `{"trace_id":"tr","recipient":"b@yagolivas.com"}`)
|
|
if !strings.Contains(h.tx.String(), "<tr> in; redis error:") || !strings.Contains(h.tx.String(), "(nothing counted)") {
|
|
t.Errorf("transaction log should record the redis error:\n%s", h.tx.String())
|
|
}
|
|
if h.metric(h.m.RedisErrors.WithLabelValues("incr")) != 1 {
|
|
t.Error("redis error metric not recorded")
|
|
}
|
|
if !strings.Contains(h.log.String(), "counting failed") {
|
|
t.Errorf("app log:\n%s", h.log.String())
|
|
}
|
|
}
|
|
|
|
func TestLookupError_Logged(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
broken := counting.LookupFunc(func(string) (*domains.Domain, error) { return nil, io.ErrUnexpectedEOF })
|
|
h.lookup.Store(&broken)
|
|
h.accept("/in", `{"trace_id":"tr","recipient":"b@yagolivas.com"}`)
|
|
if !strings.Contains(h.tx.String(), "<tr> in; error: lookup recipient domain yagolivas.com") {
|
|
t.Errorf("transaction log:\n%s", h.tx.String())
|
|
}
|
|
}
|
|
|
|
// --- /counters --------------------------------------------------------------
|
|
|
|
func decodeCounters(t *testing.T, body string) api.CountersResponse {
|
|
t.Helper()
|
|
var r api.CountersResponse
|
|
if err := json.Unmarshal([]byte(body), &r); err != nil {
|
|
t.Fatalf("bad JSON %q: %v", body, err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func TestCounters_AddressReadsReplica(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
// Counters live on the replica (reads) — the write instance holds other values.
|
|
h.read.Set(store.Key("A", "a@yagolivas.com", day, "i"), "12")
|
|
h.read.Set(store.Key("A", "a@yagolivas.com", day, "o"), "3")
|
|
h.read.Set(store.Key("D", "yagolivas.com", day, "i"), "40")
|
|
h.read.Set(store.Key("O", "1049", day, "o"), "30")
|
|
h.write.Set(store.Key("A", "a@yagolivas.com", day, "i"), "999")
|
|
|
|
st, body := h.get("/counters?address=A@Yagolivas.com")
|
|
if st != 200 {
|
|
t.Fatalf("status %d: %s", st, body)
|
|
}
|
|
r := decodeCounters(t, body)
|
|
if r.Query != "a@yagolivas.com" || r.Mode != "address" || r.Date != day || !r.Managed || r.Exempt || r.Domain != "yagolivas.com" || r.OrgID == nil || *r.OrgID != 1049 {
|
|
t.Errorf("header = %+v", r)
|
|
}
|
|
if len(r.Levels) != 3 {
|
|
t.Fatalf("levels = %+v", r.Levels)
|
|
}
|
|
a, d, o := r.Levels[0], r.Levels[1], r.Levels[2]
|
|
if a.Level != "A" || a.ID != "a@yagolivas.com" || a.In != 12 || *a.InLimit != 200 || a.Out != 3 || *a.OutLimit != 20 {
|
|
t.Errorf("A = %+v", a)
|
|
}
|
|
if d.Level != "D" || d.ID != "yagolivas.com" || d.In != 40 || d.Out != 0 || *d.InLimit != 200 {
|
|
t.Errorf("D = %+v", d)
|
|
}
|
|
if o.Level != "O" || o.ID != "1049" || o.Out != 30 || *o.OutLimit != 20 || *o.InLimit != 200 {
|
|
t.Errorf("O = %+v", o)
|
|
}
|
|
}
|
|
|
|
func TestCounters_DomainMode(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
st, body := h.get("/counters?domain=yagolivas.com")
|
|
if st != 200 {
|
|
t.Fatalf("status %d: %s", st, body)
|
|
}
|
|
r := decodeCounters(t, body)
|
|
if r.Mode != "domain" || len(r.Levels) != 2 || r.Levels[0].Level != "D" || r.Levels[1].Level != "O" || !r.Managed {
|
|
t.Errorf("response = %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestCounters_DateParam(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.read.Set(store.Key("A", "a@yagolivas.com", "2026-10-01", "i"), "7")
|
|
st, body := h.get("/counters?address=a@yagolivas.com&date=2026-10-01")
|
|
if st != 200 {
|
|
t.Fatalf("status %d: %s", st, body)
|
|
}
|
|
if r := decodeCounters(t, body); r.Date != "2026-10-01" || r.Levels[0].In != 7 {
|
|
t.Errorf("response = %+v", r)
|
|
}
|
|
for _, q := range []string{"address=a@yagolivas.com&date=2026-10-14", "address=a@yagolivas.com&date=13.10.2026", "address=a@yagolivas.com&domain=x.com", "", "address=nobody", "domain=yagolivas.com&date=2027-01-01"} {
|
|
if st, body := h.get("/counters?" + q); st != 400 {
|
|
t.Errorf("%q = %d %s, want 400", q, st, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCounters_UnmanagedAndExempt(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
st, body := h.get("/counters?address=x@gmail.com")
|
|
if st != 200 {
|
|
t.Fatalf("status %d: %s", st, body)
|
|
}
|
|
r := decodeCounters(t, body)
|
|
if r.Managed || r.OrgID != nil || len(r.Levels) != 2 || r.Levels[0].InLimit != nil || r.Levels[1].Level != "D" {
|
|
t.Errorf("unmanaged response = %+v", r)
|
|
}
|
|
st, body = h.get("/counters?address=support@migadu.com")
|
|
if st != 200 {
|
|
t.Fatalf("status %d: %s", st, body)
|
|
}
|
|
if r := decodeCounters(t, body); !r.Exempt || !r.Managed {
|
|
t.Errorf("exempt response = %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestCounters_RedisDown(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.read.Close()
|
|
if st, body := h.get("/counters?address=a@yagolivas.com"); st != 503 {
|
|
t.Errorf("status %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
// --- admin -------------------------------------------------------------------
|
|
|
|
func (h *harness) adminGet(path string) (int, string) {
|
|
resp, err := http.Get(h.admin.URL + path)
|
|
if err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
if st, body := h.adminGet("/health"); st != 200 || !strings.Contains(body, "ok") {
|
|
t.Errorf("healthy = %d %s", st, body)
|
|
}
|
|
h.write.Close()
|
|
if st, body := h.adminGet("/health"); st != 503 || !strings.Contains(body, "redis") {
|
|
t.Errorf("redis down = %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestHealth_NoDomains(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.db.Store(nil)
|
|
if st, body := h.adminGet("/health"); st != 503 || !strings.Contains(body, "domains") {
|
|
t.Errorf("no cdb = %d %s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestMetricsEndpoint(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
h.accept("/in", `{"recipient":"b@yagolivas.com"}`)
|
|
st, body := h.adminGet("/metrics")
|
|
if st != 200 || !strings.Contains(body, `migadu_counters_requests_total{route="/in",status="202"} 1`) || !strings.Contains(body, "migadu_counters_queue_depth 0") {
|
|
t.Errorf("metrics = %d\n%s", st, body)
|
|
}
|
|
}
|
|
|
|
func TestNotifyHooks(t *testing.T) {
|
|
reg := prometheus.NewRegistry()
|
|
m := metrics.New(reg)
|
|
buf := &syncBuffer{}
|
|
hooks := NotifyHooks(txlog.New(buf, "text", func() time.Time { return clock }), m)
|
|
ev := notify.Event{TraceID: "t", Route: "out", Level: "O"}
|
|
hooks.Sent(ev, "http://x", 1)
|
|
hooks.Failed(ev, "http://x", 5, io.EOF)
|
|
if testutil.ToFloat64(m.Notifications.WithLabelValues("O", "sent")) != 1 || testutil.ToFloat64(m.Notifications.WithLabelValues("O", "failed")) != 1 {
|
|
t.Error("hooks did not record metrics")
|
|
}
|
|
if s := buf.String(); !strings.Contains(s, "sent limit exceeded") || !strings.Contains(s, "failed limit exceeded") {
|
|
t.Errorf("hooks did not log:\n%s", s)
|
|
}
|
|
}
|
|
|
|
// Redis-backed /counters must use a client that is not the write client.
|
|
var _ = redis.Nil
|
|
|
|
// --- review #7 / #8 -----------------------------------------------------------
|
|
|
|
// Reviewer B: plus-addressed mail used to create cnt/A/b+1@… counters that the
|
|
// web app never reads; now it counts against the mailbox.
|
|
func TestIn_PlusTagCountsMailbox(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
for _, r := range []string{"b+1@yagolivas.com", "b+2@yagolivas.com", "B+3@yagolivas.com"} {
|
|
h.accept("/in", `{"trace_id":"plus","recipient":"`+r+`"}`)
|
|
}
|
|
if h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")) != 3 {
|
|
t.Errorf("mailbox counter = %d, want 3; keys %v", h.redisInt(store.Key("A", "b@yagolivas.com", day, "i")), h.write.Keys())
|
|
}
|
|
for _, k := range h.write.Keys() {
|
|
if strings.Contains(k, "+") {
|
|
t.Errorf("tagged key written: %s", k)
|
|
}
|
|
}
|
|
if !strings.Contains(h.tx.String(), "request mail_from= recipient=b+1@yagolivas.com") ||
|
|
!strings.Contains(h.tx.String(), "tracked address counters for b@yagolivas.com => count=1") {
|
|
t.Errorf("log should show the tagged request and the mailbox counter:\n%s", h.tx.String())
|
|
}
|
|
// /counters resolves the tag the same way (it reads the replica side,
|
|
// which holds no data in this harness: only the identity is checked).
|
|
h.read.Set(store.Key("A", "b@yagolivas.com", day, "i"), "3")
|
|
st, body := h.get("/counters?address=b%2B9@yagolivas.com")
|
|
if st != 200 {
|
|
t.Fatalf("%d %s", st, body)
|
|
}
|
|
if r := decodeCounters(t, body); r.Query != "b@yagolivas.com" || r.Levels[0].ID != "b@yagolivas.com" || r.Levels[0].In != 3 {
|
|
t.Errorf("/counters for a tagged address = %+v", r)
|
|
}
|
|
}
|
|
|
|
// Reviewer B: a malformed row (non-numeric org id) used to 503 the request and
|
|
// lose the sender's count.
|
|
func TestMalformedRowCountsOtherSide(t *testing.T) {
|
|
h := newHarness(t, options{})
|
|
db, err := domains.Open("../../review/_proof_tests/b/bad.cdb")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
lf := counting.LookupFunc(db.Lookup)
|
|
h.lookup.Store(&lf)
|
|
h.db.Store(db)
|
|
h.accept("/out", `{"trace_id":"bad1","auth_user":"a@good.com","recipient":"b@bad.com"}`)
|
|
h.accept("/in", `{"trace_id":"bad2","recipient":"b@bad.com"}`)
|
|
if h.redisInt(store.Key("A", "a@good.com", day, "o")) != 1 || len(h.write.Keys()) != 3 {
|
|
t.Errorf("sender not counted / unexpected keys: %v", h.write.Keys())
|
|
}
|
|
tx := h.tx.String()
|
|
if !strings.Contains(tx, "<bad1> out; recipient domain row malformed in domains db: bad.com:") || !strings.Contains(tx, "<bad2> in; skipped: unknown_domain") {
|
|
t.Errorf("transaction log:\n%s", tx)
|
|
}
|
|
if !strings.Contains(h.log.String(), "malformed") || !strings.Contains(h.log.String(), "bad.com") {
|
|
t.Errorf("app log should warn about the row:\n%s", h.log.String())
|
|
}
|
|
// /counters on such a domain: unmanaged, not an error.
|
|
st, body := h.get("/counters?address=b@bad.com")
|
|
if st != 200 || decodeCounters(t, body).Managed {
|
|
t.Errorf("/counters = %d %s", st, body)
|
|
}
|
|
}
|