First commit Go counter service

This commit is contained in:
Dimitrije Dimitrijevic
2026-10-15 02:12:18 +02:00
commit c43ff17627
59 changed files with 11205 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# Proof tests from the 2026-10-14 code review
These are the tests the two reviewers wrote to demonstrate their findings. They live under a
directory whose name starts with `_`, so `go build ./...`, `go test ./...` and `go vet ./...`
ignore them. They are evidence, not part of the suite. When a finding is fixed, turn its proof
test into a real regression test in the package it belongs to.
| Dir | Reviewer | Style |
|---|---|---|
| `a/` | A — correctness and runtime | Demonstrations: the tests **pass** and print the evidence with `t.Log` (run with `-v`). |
| `b/` | B — security, contracts, ops | Assertions: the tests **fail** while the defect exists (a few `PASS`es only log evidence). |
## Running
Copy the files into a scratch copy of the repo, so the real packages stay clean:
```sh
rm -rf /tmp/mc_proof && cp -R /Users/dice/Work/migadu_counters /tmp/mc_proof && cd /tmp/mc_proof
cp -R review/_proof_tests/a/internal/. internal/ # reviewer A
cp -R review/_proof_tests/b/internal/. internal/ # reviewer B
cp -R review/_proof_tests/b/cmd/. cmd/
go test -count=1 -v -run 'TestReview' ./internal/... ./cmd/...
go test -run '^$' -bench Review -benchmem ./internal/txlog/ # A's CDB parsing benchmarks
```
Requirements and notes:
- `a/internal/store/review_test.go` (`TestReview_TimeoutAfterWriteStillCounts`) needs a real Redis on
port 16401: `redis-server --port 16401 --save '' --appendonly no`. The test uses `DEBUG SLEEP` to make the
reply arrive after the client timeout. It fails to connect if no Redis is running.
- `b/bad.cdb` is a three-row fixture with one malformed row (`bad.com x12|A/i=5`). It is used by
`b/internal/server/review_b4_test.go`, whose path was updated to `../../review/_proof_tests/b/bad.cdb`.
- `a/run/` holds the config and the app log from reviewer A's manual reproduction of "SIGHUP during startup
kills the daemon" (merged item #10). The paths in `config.toml` point at the reviewer's former scratch copy,
so edit them before reuse. The log stops after `domains db loaded`, which is the evidence.
## Map to the merged report (`../README.md`)
| Merged # | Proof |
|---|---|
| 1 | `a/internal/server/review_test.go` `TestReview_ClientDisconnectLosesCount`, `TestReview_ClientDisconnectNoDelay` |
| 2 | `b/internal/server/review_b_test.go` `TestReviewB_InMailFromDropsRecipientCount`, `TestReviewB_OutRecipientDropsSenderCount`; `a/…` `TestReview_MasterLoginAndOddSenders` |
| 3 | `a/internal/server/review_test.go` `TestReview_MasterLoginAndOddSenders` (first case) |
| 4 | `b/internal/server/review_b2_test.go` `TestReviewB_LostCrossingOnReplyTimeout`; `a/internal/server/review_test.go` `TestReview_PartialExecFailure`; `a/internal/store/review_test.go` (real Redis) |
| 5 | `a/internal/txlog/review_bench_test.go` |
| 6 | `b/internal/config/review_b_test.go` `TestReviewB_MisconfigStarts`; `b/…/review_b_test.go` `TestReviewB_EmptyTokenOpen` |
| 7 | `b/internal/server/review_b_test.go` `TestReviewB_PlusTagSeparateCounters` |
| 8 | `b/internal/server/review_b4_test.go`; `a/internal/counting/review_test.go` |
| 10 | `a/run/` (manual reproduction) |
| 11 | `b/internal/server/review_b_test.go` `TestReviewB_RejectLogHasNoTraceID` |
| 13 | `b/internal/server/review_b3_test.go` |
| 14 | `b/internal/server/review_b_test.go` `TestReviewB_ErrorLeak` |
| 15 | `b/internal/config/review_b_test.go` (case "sentinel without password") |
| 16 | `b/cmd/counterctl/review_b_test.go` `TestReviewB_31DayWarningOffByOne` |
| 17 | `b/internal/server/review_b_test.go` `TestReviewB_PanicNotInMetrics` |
## Fixed in round 1 (2026-10-14) — regression tests now in the suite
| Merged # | Regression test |
|---|---|
| 1 | `internal/server` `TestClientDisconnectStillCounts`, `TestAcceptedBeforeCounting`, `TestQueueFull`, `TestDrainCountsEverything`; `internal/counting/queue_test.go` |
| 2 | `internal/server` `TestIn_OddMailFromStillCountsRecipient`, `TestOut_OddRecipientStillCountsSender`; `internal/api` `TestValidateOut`, `TestValidateIn`, `TestNormalizeEmail` |
| 3 | `internal/server` `TestOut_LoginSuffixCountsBaseAddress`; `internal/api` `TestNormalizeAuthUser` |
| 4 | `internal/server` `TestPartialExecFailureStillNotifies`, `TestLostReplyStillNotifies`; `internal/counting` `TestCounter_PartialExecFailure`, `TestCounter_LostReplyReReadsMaster`; `internal/store` `TestIncr_PartialFailure`, `TestOutcomeUnknown` |
| 5 | `internal/domains` `TestLookup_CachesParsedRecord`, `TestLookup_CacheBounded`, `BenchmarkLookupSmall`/`BenchmarkLookupHuge` |
| 7 | `internal/server` `TestIn_PlusTagCountsMailbox`; `internal/counting` `TestPlan_PlusTagCountsMailbox`; `internal/domains` `TestMailbox` (decision 2026-10-15: strip the tag, as rcptd does) |
| 8 | `internal/server` `TestMalformedRowCountsOtherSide`; `internal/counting` `TestPlan_MalformedRowIsNotHosted`; `internal/domains` `TestLookup_MalformedRow`; `internal/txlog` `TestTrail_MalformedRow` |
| 6 | `internal/config` `TestValidate_Errors` (auth_token / unknown keys), `TestLoad_TokenFromEnvOnly`, `TestLoad_Defaults` (`:5555`); `cmd/migadu_counters` `TestRun_FlagsAndExitCodes` |
The proof files stay here as the review record; they no longer compile against the current API
(`CountResponse`, `store.Incr`) and are not meant to.
@@ -0,0 +1,34 @@
package counting
import (
"path/filepath"
"testing"
"time"
"github.com/colinmarc/cdb"
"migadu_counters/internal/domains"
)
func TestReview_MalformedRowFailsWholeRequest(t *testing.T) {
path := filepath.Join(t.TempDir(), "d.cdb")
w, err := cdb.Create(path)
if err != nil {
t.Fatal(err)
}
w.Put([]byte("good.example"), []byte("7|A/o=5,A/i=5"))
w.Put([]byte("bad.example"), []byte("|A/i=5")) // org id missing (malformed row)
if _, err := w.Freeze(); err != nil {
t.Fatal(err)
}
db, err := domains.Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
p := &Planner{Lookup: db, Now: time.Now}
plan, err := p.PlanOut("t", "x@good.example", "x@good.example", "y@bad.example")
t.Logf("PlanOut(good sender -> malformed-row recipient): plan=%v err=%v", plan, err)
plan, err = p.PlanIn("t", "s@gmail.com", "y@bad.example", false)
t.Logf("PlanIn(malformed-row recipient): plan=%v err=%v", plan, err)
}
@@ -0,0 +1,96 @@
package server
import (
"fmt"
"net"
"strings"
"testing"
"time"
"migadu_counters/internal/counting"
"migadu_counters/internal/domains"
)
func rawPostAndClose(t *testing.T, addr, path, body string) {
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() // fire-and-forget client that does not wait for the reply
}
func TestReview_ClientDisconnectLosesCount(t *testing.T) {
h := newHarness(t, options{})
real := *h.lookup.Load()
slow := counting.LookupFunc(func(d string) (*domains.Domain, error) {
time.Sleep(50 * time.Millisecond) // any latency before the Redis call
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":"t","mail_from":"s@gmail.com","recipient":"a@yagolivas.com"}`)
}
time.Sleep(1500 * time.Millisecond)
v, _ := h.write.Get("cnt/A/a@yagolivas.com/d/" + day + "/i")
t.Logf("sent %d /in requests, counter = %q", n, v)
t.Logf("txlog tail:\n%s", lastLines(h.tx.String(), 3))
}
func TestReview_ClientDisconnectNoDelay(t *testing.T) {
h := newHarness(t, options{})
addr := strings.TrimPrefix(h.api.URL, "http://")
const n = 300
for i := 0; i < n; i++ {
rawPostAndClose(t, addr, "/in", `{"trace_id":"t","mail_from":"s@gmail.com","recipient":"b@yagolivas.com"}`)
}
time.Sleep(2 * time.Second)
v, _ := h.write.Get("cnt/A/b@yagolivas.com/d/" + day + "/i")
t.Logf("sent %d /in requests without delay, counter = %q", n, v)
}
func lastLines(s string, n int) string {
l := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(l) > n {
l = l[len(l)-n:]
}
return strings.Join(l, "\n")
}
func TestReview_MasterLoginAndOddSenders(t *testing.T) {
h := newHarness(t, options{})
cases := []struct{ path, body string }{
{"/out", `{"trace_id":"m1","auth_user":"x@yagolivas.com@SUPPORTTOKEN","mail_from":"x@yagolivas.com","recipient":"y@yagolivas.com"}`},
{"/in", `{"trace_id":"i1","mail_from":"a b@spammer.example","recipient":"y@yagolivas.com"}`},
{"/in", `{"trace_id":"i2","mail_from":"u@[192.0.2.1]","recipient":"y@yagolivas.com"}`},
{"/in", `{"trace_id":"i3","mail_from":"u@münchen.de","recipient":"y@yagolivas.com"}`},
{"/in", `{"trace_id":"i4","mail_from":"u@example.com.","recipient":"y@yagolivas.com"}`},
{"/in", `{"trace_id":"i5","mail_from":"a@b@example.com","recipient":"y@yagolivas.com"}`},
}
for _, c := range cases {
st, body := h.post(c.path, c.body)
t.Logf("%s %s -> %d %s", c.path, c.body, st, strings.TrimSpace(body))
}
t.Logf("keys in redis: %v", h.write.Keys())
}
func TestReview_PartialExecFailure(t *testing.T) {
h := newHarness(t, options{})
// A counter key holding a non-integer (or wrong type) value.
h.write.Set("cnt/O/1049/d/"+day+"/o", "garbage")
// A/o limit is 20 for yagolivas.com; preset A/o to 19 so this request crosses it.
h.write.Set("cnt/A/x@yagolivas.com/d/"+day+"/o", "19")
st, body := h.post("/out", `{"trace_id":"p1","auth_user":"x@yagolivas.com","recipient":"z@gmail.com"}`)
t.Logf("status %d body %s", st, strings.TrimSpace(body))
a, _ := h.write.Get("cnt/A/x@yagolivas.com/d/" + day + "/o")
d, _ := h.write.Get("cnt/D/yagolivas.com/d/" + day + "/o")
t.Logf("after: A/o=%s (limit 20 crossed) D/o=%s", a, d)
time.Sleep(300 * time.Millisecond)
t.Logf("address notifications sent: %d", h.addrURL.count())
t.Logf("txlog:\n%s", h.tx.String())
}
@@ -0,0 +1,36 @@
package store
import (
"context"
"testing"
"time"
"github.com/redis/go-redis/v9"
"migadu_counters/internal/config"
)
func TestReview_TimeoutAfterWriteStillCounts(t *testing.T) {
st, err := New(config.RedisConfig{WriteAddr: "127.0.0.1:16401", PoolSize: 4, TimeoutMS: 500})
if err != nil {
t.Fatal(err)
}
defer st.Close()
admin := redis.NewClient(&redis.Options{Addr: "127.0.0.1:16401"})
defer admin.Close()
key := "cnt/A/review@example.com/d/2026-10-14/o"
admin.Del(context.Background(), key)
admin.Set(context.Background(), key, "99", 0) // limit 100: next increment crosses
// warm the pool
if err := st.Ping(context.Background()); err != nil {
t.Fatal(err)
}
go admin.Do(context.Background(), "DEBUG", "SLEEP", "1") // server stall > timeout_ms
time.Sleep(50 * time.Millisecond)
_, err = st.Incr(context.Background(), []string{key}, 1)
t.Logf("Incr error: %v", err)
time.Sleep(1500 * time.Millisecond)
v, _ := admin.Get(context.Background(), key).Result()
t.Logf("key after the 'failed' Incr: %s", v)
admin.Del(context.Background(), key)
}
@@ -0,0 +1,63 @@
package txlog
import (
"io"
"testing"
"time"
"migadu_counters/internal/counting"
"migadu_counters/internal/domains"
)
const cdbPath = "../../test_files/rspamd_domains_db.cdb"
func BenchmarkReview_Open(b *testing.B) {
for i := 0; i < b.N; i++ {
db, err := domains.Open(cdbPath)
if err != nil {
b.Fatal(err)
}
db.Close()
}
}
func benchLookup(b *testing.B, dom string) {
db, err := domains.Open(cdbPath)
if err != nil {
b.Fatal(err)
}
defer db.Close()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := db.Lookup(dom); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkReview_LookupSmall(b *testing.B) { benchLookup(b, "yagolivas.com") }
func BenchmarkReview_LookupHuge(b *testing.B) { benchLookup(b, "boxmailhere.com") }
func BenchmarkReview_InRequestHuge(b *testing.B) {
db, err := domains.Open(cdbPath)
if err != nil {
b.Fatal(err)
}
defer db.Close()
p := &counting.Planner{Lookup: db, Now: time.Now}
l := New(io.Discard, "text", nil)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
plan, err := p.PlanIn("t", "s@gmail.com", "someone@boxmailhere.com", false)
if err != nil {
b.Fatal(err)
}
out := &counting.Outcome{Plan: plan}
for _, inc := range plan.Increments {
out.Results = append(out.Results, counting.Result{Increment: inc, Count: 1})
}
l.Outcome(out, nil)
}
}
+1
View File
@@ -0,0 +1 @@
time=2026-10-14T08:24:13.995+02:00 level=INFO msg="domains db loaded" path=/tmp/review_a/test_files/rspamd_domains_db.cdb domains=93549
+13
View File
@@ -0,0 +1,13 @@
addr = "127.0.0.1:18095"
admin_addr = "127.0.0.1:19095"
auth_token = "tok"
domains_cdb = "/tmp/review_a/test_files/rspamd_domains_db.cdb"
[logging]
output = "/tmp/review_a/run/app.log"
counting_output = "/tmp/review_a/run/counting.log"
[redis]
write_addr = "127.0.0.1:16401"
timeout_ms = 500
[notify]
org_url = ""
address_url = ""
Binary file not shown.
@@ -0,0 +1,60 @@
package main
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"migadu_counters/internal/config"
"migadu_counters/internal/domains"
"migadu_counters/internal/server"
"migadu_counters/internal/store"
)
// Date exactly 31 days back: its keys are still alive (TTL refreshed by the
// day's last increment, so they live until ~23:59 today) but counterctl claims
// it is "more than 31 days ago" and "will read as 0".
func TestReviewB_31DayWarningOffByOne(t *testing.T) {
f := &fakeAPI{body: sampleResponse()}
s := httptest.NewServer(f)
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
code, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", "2026-09-12")
t.Logf("now=%s --date 2026-09-12 (31 days back) exit=%d stderr=%q", now, code, errOut)
lastIncr := time.Date(2026, 9, 12, 23, 59, 0, 0, time.UTC)
t.Logf("a key last incremented at %s expires at %s (still alive now)", lastIncr, lastIncr.Add(store.TTL))
if strings.Contains(errOut, "will read as 0") {
t.Errorf("warning fires for a date whose keys still exist")
}
}
// --url with a trailing slash, against the real API handler.
func TestReviewB_URLTrailingSlash(t *testing.T) {
mr := miniredis.RunT(t)
st, err := store.New(config.RedisConfig{WriteAddr: mr.Addr(), PoolSize: 2, TimeoutMS: 1000})
if err != nil {
t.Fatal(err)
}
db, err := domains.Open("../../test_files/rspamd_domains_db.cdb")
if err != nil {
t.Fatal(err)
}
srv := server.New(server.Deps{AuthToken: "t", Store: st, Domains: func() *domains.DB { return db }, Now: func() time.Time { return now }})
s := httptest.NewServer(srv.Handler())
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
for _, u := range []string{s.URL, s.URL + "/", s.URL + "/api"} {
code, out, errOut := runCLI(t, noEnv, "--config", cfg, "--url", u, "--address", "a@yagolivas.com")
t.Logf("--url %q -> exit %d stdout=%q stderr=%q", u, code, firstLine(out), strings.TrimSpace(errOut))
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
@@ -0,0 +1,45 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestReviewB_MisconfigStarts(t *testing.T) {
dir := t.TempDir()
for name, body := range map[string]string{
"typo token, open API": `auth-token = "s3cret"
domains_cdb = "x.cdb"
allowed_cidr = ["10.0.0.0/8"]
[redis]
write_addr = "127.0.0.1:6379"`,
"sentinel without password": `auth_token = "t"
domains_cdb = "x.cdb"
[redis]
master_name = "mymaster"
sentinels = ["10.0.0.10:26379"]`,
"notify creds in URL / http basic": `auth_token = "t"
domains_cdb = "x.cdb"
[redis]
write_addr = "127.0.0.1:6379"
[notify]
org_url = "http://admin:hunter2@admin.migadu.com/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
user = "u"
password = "p"`,
"public admin": `auth_token = "t"
domains_cdb = "x.cdb"
admin_addr = "0.0.0.0:9095"
[redis]
write_addr = "127.0.0.1:6379"`,
} {
p := filepath.Join(dir, "c.toml")
os.WriteFile(p, []byte(body), 0o600)
cfg, err := Load(p)
if err != nil {
t.Logf("%s: Load error: %v", name, err)
continue
}
t.Logf("%s: Load OK; AuthToken=%q prefixes=%v redis.password=%q warnings=%q", name, cfg.AuthToken, cfg.AllowedPrefixes(), cfg.Redis.Password, cfg.Warnings())
}
}
@@ -0,0 +1,87 @@
package server
import (
"io"
"net"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"migadu_counters/internal/store"
)
// delayProxy forwards to backend; replies are held back by *delay.
func delayProxy(t *testing.T, backend string, delay *atomic.Int64) string {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
b, err := net.Dial("tcp", backend)
if err != nil {
c.Close()
continue
}
go func() { io.Copy(b, c); b.Close() }()
go func() {
buf := make([]byte, 32*1024)
for {
n, err := b.Read(buf)
if n > 0 {
time.Sleep(time.Duration(delay.Load()))
if _, werr := c.Write(buf[:n]); werr != nil {
return
}
}
if err != nil {
c.Close()
return
}
}
}()
}
}()
return ln.Addr().String()
}
// F: the EXEC reply is lost (read timeout) after Redis applied the increments:
// the service answers 503 "nothing counted", the crossing increment is never
// reported, and the web app is never notified for that day.
func TestReviewB_LostCrossingOnReplyTimeout(t *testing.T) {
backend := miniredis.RunT(t)
var delay atomic.Int64
proxy := delayProxy(t, backend.Addr(), &delay)
h := newHarness(t, options{writeAddr: proxy, handlerTimeout: 10 * time.Second})
key := store.Key("A", "a@yagolivas.com", day, "o") // A/o = 20
backend.Set(key, "19")
// warm the pool so the handshake is done before replies are delayed
if st, body := h.post("/in", `{"recipient":"w@sttml.ru"}`); st != 200 {
t.Fatalf("warmup %d %s", st, body)
}
delay.Store(int64(2500 * time.Millisecond)) // > timeout_ms 2000 of the harness
st, body := h.post("/out", `{"trace_id":"t1","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
t.Logf("request 1: %d %s", st, strings.TrimSpace(body))
v, _ := backend.Get(key)
t.Logf("redis value after 503: %s", v)
t.Logf("txlog:\n%s", h.tx.String())
delay.Store(0)
time.Sleep(3 * time.Second)
st, body = h.post("/out", `{"trace_id":"t2","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
t.Logf("request 2: %d %s", st, strings.TrimSpace(body))
time.Sleep(200 * time.Millisecond)
if h.addrURL.count() == 0 {
t.Errorf("A/o limit 20 was crossed (value %s -> now >20) but no notification was ever sent", v)
}
}
@@ -0,0 +1,32 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"migadu_counters/internal/notify"
"migadu_counters/internal/txlog"
)
func TestReviewB_NotifyURLCredentialsLogged(t *testing.T) {
var gotAuth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
}))
defer ts.Close()
u := strings.Replace(ts.URL, "http://", "http://admin:hunter2@", 1) + "/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
buf := &syncBuffer{}
tx := txlog.New(buf, "text", nil)
n := notify.New(notify.Config{OrgURL: u, Timeout: time.Second}, NotifyHooks(tx, nil))
n.Notify(notify.Event{TraceID: "t", Route: "out", Level: "O", ID: "1049", Flow: "o", Address: "a@yagolivas.com", Count: 20, Limit: 20})
n.Shutdown(context.Background())
t.Logf("server saw Authorization=%q", gotAuth)
t.Logf("counting log: %s", buf.String())
if strings.Contains(buf.String(), "hunter2") {
t.Errorf("notify credentials written to the counting log")
}
}
@@ -0,0 +1,27 @@
package server
import (
"strings"
"testing"
"migadu_counters/internal/counting"
"migadu_counters/internal/domains"
)
func TestReviewB_MalformedOrgIDDropsOtherParty(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)
st, body := h.post("/out", `{"trace_id":"t","auth_user":"a@good.com","recipient":"b@bad.com"}`)
t.Logf("/out good.com -> bad.com: %d %s keys=%v", st, strings.TrimSpace(body), h.write.Keys())
st, body = h.post("/in", `{"trace_id":"t","recipient":"b@bad.com"}`)
t.Logf("/in bad.com: %d %s", st, strings.TrimSpace(body))
if len(h.write.Keys()) == 0 {
t.Errorf("sender a@good.com outgoing not counted because the recipient's CDB row is malformed")
}
}
@@ -0,0 +1,142 @@
package server
import (
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"migadu_counters/internal/counting"
"migadu_counters/internal/domains"
"migadu_counters/internal/store"
)
// F1: an /in request whose (never counted) mail_from is unusual-but-SMTP-valid
// is rejected wholesale, so the recipient's incoming counters are not bumped.
func TestReviewB_InMailFromDropsRecipientCount(t *testing.T) {
h := newHarness(t, options{})
for _, mf := range []string{
"john smith@example.com", // go-smtp unquotes MAIL FROM:<"john smith"@example.com>
"a@b@example.com", // MAIL FROM:<"a@b"@example.com>
"info@bücher.de", // SMTPUTF8 U-label sender domain
"postmaster@[192.0.2.1]", // address literal
"bounce@mail_relay.example.com", // underscore host
strings.Repeat("v", 250) + "@example.com", // > 254 chars
} {
st, body := h.post("/in", `{"trace_id":"t","mail_from":"`+mf+`","recipient":"b@yagolivas.com"}`)
t.Logf("mail_from=%.40q -> %d %s", mf, st, strings.TrimSpace(body))
}
t.Logf("keys written: %v", h.write.Keys())
if !h.write.Exists(store.Key("A", "b@yagolivas.com", day, "i")) {
t.Errorf("recipient b@yagolivas.com never counted although every message was accepted by mizu")
}
}
// F1b: /out where one recipient has a domain the validator dislikes drops the
// sender's outgoing increment too.
func TestReviewB_OutRecipientDropsSenderCount(t *testing.T) {
h := newHarness(t, options{})
for _, rc := range []string{"x@[192.0.2.1]", "x@bücher.de", "x@host_name.example.com"} {
st, body := h.post("/out", `{"trace_id":"t","auth_user":"a@yagolivas.com","mail_from":"a@yagolivas.com","recipient":"`+rc+`"}`)
t.Logf("recipient=%q -> %d %s", rc, st, strings.TrimSpace(body))
}
if !h.write.Exists(store.Key("A", "a@yagolivas.com", day, "o")) {
t.Errorf("sender a@yagolivas.com outgoing never counted")
}
}
// F2: plus-addressing creates a separate A counter per tag.
func TestReviewB_PlusTagSeparateCounters(t *testing.T) {
h := newHarness(t, options{})
for _, r := range []string{"b+1@yagolivas.com", "b+2@yagolivas.com", "B+3@yagolivas.com"} {
if st, body := h.post("/in", `{"recipient":"`+r+`"}`); st != 200 {
t.Fatalf("%d %s", st, body)
}
}
t.Logf("keys: %v", h.write.Keys())
if !h.write.Exists(store.Key("A", "b@yagolivas.com", day, "i")) {
t.Errorf("mailbox b@yagolivas.com A/i counter (what the web app reads) never incremented")
}
}
// F5: a recovered panic is answered 500 but not counted in requests_total.
func TestReviewB_PanicNotInMetrics(t *testing.T) {
h := newHarness(t, options{})
boom := counting.LookupFunc(func(string) (*domains.Domain, error) { panic("boom") })
h.lookup.Store(&boom)
st, _ := h.post("/in", `{"recipient":"b@yagolivas.com"}`)
got := h.metric(h.m.Requests.WithLabelValues("/in", "500"))
t.Logf("status %d, requests_total{/in,500}=%v", st, got)
if got != 1 {
t.Errorf("500 from recovered panic not counted in requests_total")
}
}
// F6: rejection log has no trace_id.
func TestReviewB_RejectLogHasNoTraceID(t *testing.T) {
h := newHarness(t, options{})
st, _ := h.post("/in", `{"trace_id":"TRACE-XYZ","recipient":"nobody"}`)
if st != 400 {
t.Fatal(st)
}
if !strings.Contains(h.log.String(), "TRACE-XYZ") {
t.Errorf("rejection log lacks trace_id:\n%s", h.log.String())
}
}
// Leakage: 503 body / health carry raw redis errors with internal addresses.
func TestReviewB_ErrorLeak(t *testing.T) {
h := newHarness(t, options{})
addr := h.write.Addr()
h.write.Close()
st, body := h.post("/in", `{"recipient":"b@yagolivas.com"}`)
t.Logf("api: %d %s", st, strings.TrimSpace(body))
resp, err := http.Get(h.admin.URL + "/health")
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("health: %d %s", resp.StatusCode, strings.TrimSpace(string(b)))
if strings.Contains(body, addr) {
t.Logf("API 503 body leaks redis address %s", addr)
}
}
// Chunked body over 64 KB is rejected 413.
func TestReviewB_ChunkedTooLarge(t *testing.T) {
h := newHarness(t, options{})
pr, pw := io.Pipe()
go func() {
pw.Write([]byte(`{"recipient":"b@yagolivas.com","pad":"`))
for i := 0; i < 100; i++ {
pw.Write([]byte(strings.Repeat("x", 1024)))
}
pw.Write([]byte(`"}`))
pw.Close()
}()
req, _ := http.NewRequest("POST", h.api.URL+"/in", pr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("chunked 100KB: %d %s (cl=%d)", resp.StatusCode, b, req.ContentLength)
if resp.StatusCode != 413 {
t.Errorf("want 413")
}
}
// Empty configured token = no auth at all.
func TestReviewB_EmptyTokenOpen(t *testing.T) {
h := newHarness(t, options{token: "x"})
_ = h
}
var _ = net.Listen
var _ = time.Second