First commit Go counter service
This commit is contained in:
@@ -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