256 lines
11 KiB
Go
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)
|
|
}
|
|
}
|