Files
mail-server/admin/messages_test.go

279 lines
8.6 KiB
Go

package main
import (
"encoding/json"
"errors"
"io/fs"
"net/http"
"strings"
"testing"
)
// Fixture additions (testdata/mail/produktor.io):
//
// ano/cur/2.multipart — multipart/mixed: alternative (text/plain+text/html)
// + image/png attachment (cid logo-cid-1), RFC 2047 encoded subject.
// ano/cur/3.searchme — simple message with subject/from search targets.
//
// Combined with the pre-existing fixtures the unified inbox contains:
// info 3 (cur 1.fixture1, 2.fixture2 + new 3.fixture3), ano 3, postmaster 1.
func TestListMessagesCounts(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 7 {
t.Fatalf("got %d messages, want 7 (info 3 + ano 3 + postmaster 1)", len(msgs))
}
byMailbox := map[string]int{}
for _, m := range msgs {
byMailbox[m.Mailbox]++
if m.Size <= 0 {
t.Errorf("%s/%s: size = %d, want > 0", m.Mailbox, m.Filename, m.Size)
}
if m.Filename == "" {
t.Error("empty filename in listing")
}
}
want := map[string]int{
"info@produktor.io": 3,
"ano@produktor.io": 3,
"postmaster@produktor.io": 1,
}
for mb, n := range want {
if byMailbox[mb] != n {
t.Errorf("mailbox %s: got %d messages, want %d", mb, byMailbox[mb], n)
}
}
}
func TestListMessagesSortNewestFirst(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 500)
if err != nil {
t.Fatal(err)
}
// 3.searchme (Date 11:00 +0100) is newer than 2.multipart (10:00 +0100);
// messages without a parseable Date fall back to file mtime.
if msgs[0].Filename != "3.searchme" {
t.Errorf("first = %s/%s, want ano/3.searchme", msgs[0].Mailbox, msgs[0].Filename)
}
if msgs[1].Filename != "2.multipart" {
t.Errorf("second = %s/%s, want ano/2.multipart", msgs[1].Mailbox, msgs[1].Filename)
}
}
func TestListMessagesMailboxFilter(t *testing.T) {
msgs, err := listMessages("testdata/mail", "info@produktor.io", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Fatalf("got %d, want 3 for info@produktor.io", len(msgs))
}
for _, m := range msgs {
if m.Mailbox != "info@produktor.io" {
t.Errorf("mailbox = %q, want info@produktor.io", m.Mailbox)
}
}
// localpart-only filter is accepted too
msgs, err = listMessages("testdata/mail", "postmaster", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("localpart filter: got %d, want 1", len(msgs))
}
}
func TestListMessagesQuery(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "acme", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Filename != "3.searchme" {
t.Fatalf("q=acme: got %+v, want 1 hit (3.searchme)", msgs)
}
// case-insensitive on subject
msgs, err = listMessages("testdata/mail", "", "INVOICE", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Subject != "Invoice #42 from Acme Corp" {
t.Fatalf("q=INVOICE: got %+v, want 3.searchme", msgs)
}
msgs, err = listMessages("testdata/mail", "", "no such term", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Fatalf("q=no-match: got %d, want 0", len(msgs))
}
}
func TestListMessagesLimit(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 2)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("limit 2: got %d, want 2", len(msgs))
}
}
func TestReadMessageMultipart(t *testing.T) {
d, err := readMessage("testdata/mail", "ano@produktor.io", "2.multipart")
if err != nil {
t.Fatal(err)
}
if got := d.Headers.Get("Subject"); !strings.Contains(got, "Multipart fixture with") {
t.Errorf("Subject header = %q, want RFC 2047 decoded", got)
}
if !strings.Contains(d.Text, "hello plain body") {
t.Errorf("Text = %q, want plain body", d.Text)
}
if !strings.Contains(d.HTML, "<b>html</b>") {
t.Errorf("HTML = %q, want html body", d.HTML)
}
if len(d.Attachments) != 1 {
t.Fatalf("attachments = %d, want 1", len(d.Attachments))
}
a := d.Attachments[0]
if a.Filename != "logo.png" || a.CID != "logo-cid-1" || a.Size != 8 {
t.Errorf("attachment = %+v, want logo.png cid=logo-cid-1 size=8", a)
}
if d.Headers.Get("From") == "" || d.Headers.Get("To") == "" || d.Headers.Get("Date") == "" {
t.Error("expected from/to/date headers")
}
}
func TestReadMessageSimple(t *testing.T) {
d, err := readMessage("testdata/mail", "ano@produktor.io", "3.searchme")
if err != nil {
t.Fatal(err)
}
if d.Subject() != "Invoice #42 from Acme Corp" {
t.Errorf("subject = %q", d.Subject())
}
if !strings.Contains(d.Text, "Please pay") {
t.Errorf("Text = %q, want body", d.Text)
}
if d.HTML != "" {
t.Errorf("HTML = %q, want empty for plain message", d.HTML)
}
if len(d.Attachments) != 0 {
t.Errorf("attachments = %d, want 0", len(d.Attachments))
}
}
func TestReadMessagePathSafety(t *testing.T) {
cases := []struct{ mailbox, filename string }{
{"../../etc", "passwd"},
{"ano@produktor.io", "../../etc/passwd"},
{"ano@produktor.io", "..%2F1.fixtureano"},
{"ano@produktor.io", "nonexistent"},
{"ANO@produktor.io", "1.fixtureano"}, // uppercase rejected
{"ano..@produktor.io", "1.fixtureano"},
{"ano@produktor.io/../info", "1.fixture1"},
}
for _, c := range cases {
if _, err := readMessage("testdata/mail", c.mailbox, c.filename); err == nil {
t.Errorf("readMessage(%q, %q): expected error, got nil", c.mailbox, c.filename)
} else if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, errBadMailbox) {
t.Errorf("readMessage(%q, %q): err = %v, want errBadMailbox or fs.ErrNotExist", c.mailbox, c.filename, err)
}
}
}
func TestAPIMessages(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/api/messages", "bot", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String())
}
var resp messagesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 7 || len(resp.Messages) != 7 {
t.Fatalf("count = %d len = %d, want 7", resp.Count, len(resp.Messages))
}
if resp.Messages[0].Filename != "3.searchme" {
t.Errorf("first = %+v, want 3.searchme (newest first)", resp.Messages[0])
}
rec = doAuth(t, h, "/admin/api/messages?mailbox=postmaster@produktor.io", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 || resp.Messages[0].Mailbox != "postmaster@produktor.io" {
t.Errorf("mailbox filter: count = %d, want 1 postmaster", resp.Count)
}
rec = doAuth(t, h, "/admin/api/messages?q=acme", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 {
t.Errorf("q filter: count = %d, want 1", resp.Count)
}
rec = doAuth(t, h, "/admin/api/messages?limit=2", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Messages) != 2 {
t.Errorf("limit=2: got %d, want 2", len(resp.Messages))
}
}
func TestAPIMessageDetail(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/api/messages/ano@produktor.io/2.multipart", "bot", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String())
}
var d messageDetail
if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
t.Fatal(err)
}
if !strings.Contains(d.Text, "hello plain body") || !strings.Contains(d.HTML, "<b>html</b>") {
t.Errorf("detail body: text=%q html=%q", d.Text, d.HTML)
}
if len(d.Attachments) != 1 || d.Attachments[0].Filename != "logo.png" {
t.Errorf("attachments = %+v, want logo.png", d.Attachments)
}
}
func TestAPIMessageDetailUnsafe(t *testing.T) {
h := testServer(t).handler()
for _, p := range []string{
"/admin/api/messages/..%2F..%2Fetc/passwd",
"/admin/api/messages/ano@produktor.io/..%2F..%2Fetc/passwd",
"/admin/api/messages/ANO@produktor.io/1.fixtureano",
"/admin/api/messages/ano@produktor.io/nonexistent",
"/admin/api/messages/ano@produktor.io/1.fixtureano/extra",
} {
rec := doAuth(t, h, p, "bot", "s3cret")
if rec.Code == http.StatusOK {
t.Errorf("%s: code = %d, want 4xx", p, rec.Code)
}
}
}
func TestMessagesPagesRender(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/messages", "bot", "s3cret")
body := rec.Body.String()
if !strings.Contains(body, "All messages") || !strings.Contains(body, "ano@produktor.io") {
t.Error("messages page missing title/mailbox grouping")
}
rec = doAuth(t, h, "/admin/messages", "bot", "s3cret")
rec = doAuth(t, h, "/admin/message/ano@produktor.io/2.multipart", "bot", "s3cret")
body = rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(body, "hello plain body") {
t.Errorf("message page: code = %d, body missing text: %s", rec.Code, body[:min(len(body), 200)])
}
}