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) } }