First commit Go counter service
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user