Files
counters/internal/api/api_test.go
T

256 lines
11 KiB
Go

package api
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
func TestValidateOut(t *testing.T) {
tests := []struct {
name string
in OutRequest
wantErr string // substring; "" = accepted
want ValidatedOut
}{
{"valid", OutRequest{TraceID: "tr", AuthUser: "x@example.com", MailFrom: "x@example.com", Recipient: "y@example.com"}, "",
ValidatedOut{TraceID: "tr", AuthUser: Address{Value: "x@example.com"}, MailFrom: "x@example.com", MailFromUsable: true, Recipient: Address{Value: "y@example.com"}}},
{"normalised", OutRequest{AuthUser: " X@Example.COM ", Recipient: "Y@EXAMPLE.com"}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
// Support/token logins (review #3): mizu forwards the raw SASL login
// user@dom@SUFFIX; the base address is counted, the suffix logged.
{"login suffix", OutRequest{AuthUser: "x@yagolivas.com@SUPPORTTOKEN", Recipient: "y@example.com"}, "",
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, LoginSuffix: "SUPPORTTOKEN", Recipient: Address{Value: "y@example.com"}}},
// mail_from never rejects (review #2): kept normalised when possible,
// otherwise as a sanitised string for the log.
{"mail_from empty", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: ""}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
{"mail_from garbage kept for log", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: "john smith@x.com\x1b[1m"}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, MailFrom: "john smith@x.com[1m", Recipient: Address{Value: "y@example.com"}}},
// One unusable side: the other is still counted.
{"auth_user missing, recipient ok", OutRequest{Recipient: "y@example.com"}, "",
ValidatedOut{AuthUser: Address{Reason: "missing"}, Recipient: Address{Value: "y@example.com"}}},
{"auth_user bad, recipient ok", OutRequest{AuthUser: "nobody", Recipient: "y@example.com"}, "",
ValidatedOut{AuthUser: Address{Raw: "nobody", Reason: "invalid e-mail address"}, Recipient: Address{Value: "y@example.com"}}},
{"recipient literal, sender ok", OutRequest{AuthUser: "x@example.com", Recipient: "u@[192.0.2.1]"}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Raw: "u@[192.0.2.1]", Reason: "invalid e-mail address"}}},
{"recipient underscore host, sender ok", OutRequest{AuthUser: "x@example.com", Recipient: "u@host_name.example.com"}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Raw: "u@host_name.example.com", Reason: "invalid e-mail address"}}},
// IDN domains are counted under their punycode CDB key.
{"idn recipient", OutRequest{AuthUser: "x@example.com", Recipient: "info@Bücher.de"}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "info@xn--bcher-kva.de"}}},
{"trailing dot stripped", OutRequest{AuthUser: "x@example.com", Recipient: "u@example.com."}, "",
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "u@example.com"}}},
// Neither side usable: 400 naming both.
{"both missing", OutRequest{}, "auth_user: missing; recipient: missing", ValidatedOut{}},
{"both bad", OutRequest{AuthUser: "nobody", Recipient: " "}, "auth_user: invalid e-mail address; recipient: missing", ValidatedOut{}},
{"trace sanitised", OutRequest{TraceID: "ab\ncd\x1b[31m" + strings.Repeat("z", 200), AuthUser: "x@example.com", Recipient: "y@example.com"}, "",
ValidatedOut{TraceID: "abcd[31m" + strings.Repeat("z", 120), AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ValidateOut(tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %+v\nwant %+v", got, tt.want)
}
})
}
}
func TestValidateIn(t *testing.T) {
tests := []struct {
name string
in InRequest
wantErr string
want ValidatedIn
}{
{"bounce sender", InRequest{TraceID: "t", MailFrom: "", Recipient: "Y@Example.com", Junk: true}, "",
ValidatedIn{TraceID: "t", Recipient: "y@example.com", Junk: true}},
{"valid sender", InRequest{MailFrom: "S@Gmail.com", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "s@gmail.com", MailFromUsable: true, Recipient: "y@example.com"}},
// Every odd-but-accepted-by-mizu sender keeps the recipient counted.
{"sender with space", InRequest{MailFrom: "john smith@example.com", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "john smith@example.com", Recipient: "y@example.com"}},
{"sender two ats", InRequest{MailFrom: "a@b@example.com", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "a@b@example.com", Recipient: "y@example.com"}},
{"sender idn", InRequest{MailFrom: "info@bücher.de", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "info@xn--bcher-kva.de", MailFromUsable: true, Recipient: "y@example.com"}},
{"sender literal", InRequest{MailFrom: "postmaster@[192.0.2.1]", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "postmaster@[192.0.2.1]", Recipient: "y@example.com"}},
{"sender too long", InRequest{MailFrom: strings.Repeat("v", 250) + "@example.com", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: strings.Repeat("v", 250) + "@exa", Recipient: "y@example.com"}},
{"sender control chars stripped", InRequest{MailFrom: "a\r\nb@x.com", Recipient: "y@example.com"}, "",
ValidatedIn{MailFrom: "ab@x.com", Recipient: "y@example.com"}}, // sanitised for the log, not repaired into an address
{"recipient idn", InRequest{Recipient: "info@Bücher.de"}, "", ValidatedIn{Recipient: "info@xn--bcher-kva.de"}},
{"recipient missing", InRequest{MailFrom: "s@gmail.com"}, "recipient: missing", ValidatedIn{}},
{"recipient bad", InRequest{Recipient: "nobody"}, "recipient: invalid e-mail address", ValidatedIn{}},
{"recipient literal", InRequest{Recipient: "u@[192.0.2.1]"}, "recipient: invalid e-mail address", ValidatedIn{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ValidateIn(tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
var fe *FieldError
if !errors.As(err, &fe) {
t.Errorf("error should be a *FieldError, got %T", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %+v\nwant %+v", got, tt.want)
}
})
}
}
func TestNormalizeAuthUser(t *testing.T) {
tests := []struct {
in, addr, suffix, wantErr string
}{
{"x@yagolivas.com", "x@yagolivas.com", "", ""},
{" X@Yagolivas.com@SUPPORTTOKEN ", "x@yagolivas.com", "SUPPORTTOKEN", ""},
{"x@yagolivas.com@tok@en", "x@yagolivas.com", "tok@en", ""},
{"x@yagolivas.com@", "x@yagolivas.com", "", ""},
{"nobody", "", "", "exactly one @"},
{"@dom@SUFFIX", "", "", "exactly one @"},
{"", "", "", "empty"},
}
for _, tt := range tests {
addr, suffix, err := NormalizeAuthUser(tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("NormalizeAuthUser(%q) err = %v, want %q", tt.in, err, tt.wantErr)
}
continue
}
if err != nil || addr != tt.addr || suffix != tt.suffix {
t.Errorf("NormalizeAuthUser(%q) = %q, %q, %v; want %q, %q", tt.in, addr, suffix, err, tt.addr, tt.suffix)
}
}
}
func TestNormalizeEmail(t *testing.T) {
good := map[string]string{
" A.B+tag@Sub.Example.COM ": "a.b+tag@sub.example.com",
"x@example.com\t": "x@example.com",
"x@xn--exmple-cua.com": "x@xn--exmple-cua.com",
"jürgen@example.com": "jürgen@example.com", // SMTPUTF8 local part
"x@exämple.com": "x@xn--exmple-cua.com",
"u@münchen.de": "u@xn--mnchen-3ya.de",
"u@日本.jp": "u@xn--wgv71a.jp",
"u@example.com.": "u@example.com",
}
for in, want := range good {
if got, err := NormalizeEmail(in); err != nil || got != want {
t.Errorf("NormalizeEmail(%q) = %q, %v; want %q", in, got, err, want)
}
}
bad := []string{"", "nobody", "@x.com", "x@", "a@b@c.com", "x@exam ple.com", "x y@example.com", "x\x01@example.com",
strings.Repeat("a", 250) + "@e.com", "x@.example.com", "x@a..b.com", "x@[192.0.2.1]", "x@host_name.example.com", "x@-bad.com", "x@example.com.."}
for _, in := range bad {
if got, err := NormalizeEmail(in); err == nil {
t.Errorf("NormalizeEmail(%q) = %q, want error", in, got)
}
}
}
func TestSanitizeForLog(t *testing.T) {
if got := SanitizeForLog("a\r\nb\x1b[31m" + strings.Repeat("z", 300)); !strings.HasPrefix(got, "ab[31m") || len(got) != MaxEmailLen {
t.Errorf("got %q (len %d)", got, len(got))
}
}
func TestSanitizeTraceID(t *testing.T) {
if got := SanitizeTraceID(" a-b_c.d:e "); got != "a-b_c.d:e" {
t.Errorf("got %q", got)
}
if got := SanitizeTraceID("x\r\ny\x00z\x7f"); got != "xyz" {
t.Errorf("got %q", got)
}
if got := SanitizeTraceID(""); got != "" {
t.Errorf("got %q", got)
}
}
func TestParseDate(t *testing.T) {
now := time.Date(2026, 10, 13, 23, 30, 0, 0, time.FixedZone("CEST", 2*3600)) // 21:30 UTC on the 13th
tests := []struct {
in, want, wantErr string
}{
{"", "2026-10-13", ""},
{"2026-10-13", "2026-10-13", ""},
{"2026-10-12", "2026-10-12", ""},
{"2026-10-14", "", "future"},
{"2026-1-5", "", "YYYY-MM-DD"},
{"2026-13-01", "", "YYYY-MM-DD"},
{"13.10.2026", "", "YYYY-MM-DD"},
{"2026-10-13T00:00:00Z", "", "YYYY-MM-DD"},
}
for _, tt := range tests {
got, err := ParseDate(tt.in, now)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("ParseDate(%q) err = %v, want containing %q", tt.in, err, tt.wantErr)
}
continue
}
if err != nil || got != tt.want {
t.Errorf("ParseDate(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
}
}
}
func TestCountResponseJSON(t *testing.T) {
b, err := json.Marshal(CountResponse{Accepted: true})
if err != nil {
t.Fatal(err)
}
if string(b) != `{"accepted":true}` {
t.Errorf("JSON = %s", b)
}
}
func TestCountersResponseJSON(t *testing.T) {
org := int64(1234)
in := int64(500)
resp := CountersResponse{Query: "me@example.com", Mode: "address", Date: "2026-10-13", Managed: true,
Domain: "example.com", OrgID: &org,
Levels: []CountersLevel{{Level: "A", ID: "me@example.com", In: 12, InLimit: &in, Out: 3, OutLimit: nil}}}
b, _ := json.Marshal(resp)
s := string(b)
for _, want := range []string{`"query":"me@example.com"`, `"mode":"address"`, `"date":"2026-10-13"`, `"managed":true`, `"exempt":false`,
`"domain":"example.com"`, `"org_id":1234`, `"levels":[`, `"in":12`, `"in_limit":500`, `"out":3`, `"out_limit":null`} {
if !strings.Contains(s, want) {
t.Errorf("JSON %s lacks %s", s, want)
}
}
b, _ = json.Marshal(CountersResponse{Query: "x@nowhere.tld", Mode: "address", Date: "2026-10-13", Levels: []CountersLevel{}})
if strings.Contains(string(b), "org_id") || strings.Contains(string(b), `"domain"`) {
t.Errorf("unmanaged response should omit org_id and domain: %s", b)
}
}
func TestErrorResponseJSON(t *testing.T) {
b, _ := json.Marshal(ErrorResponse{Error: "recipient: missing"})
if string(b) != `{"error":"recipient: missing"}` {
t.Errorf("got %s", b)
}
}