239 lines
7.0 KiB
Go
239 lines
7.0 KiB
Go
package mailstore
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
func newTestDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "test.db")
|
|
database, err := db.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
return database
|
|
}
|
|
|
|
// newTestMailbox creates a domain + mailbox with a real wrapped DEK, using a Store
|
|
// built against a random master key, and returns both.
|
|
func newTestMailbox(t *testing.T, quotaBytes int64) (*Store, int64) {
|
|
t.Helper()
|
|
database := newTestDB(t)
|
|
domainID, err := database.CreateDomain("example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
masterKey := GenerateDEK() // 32 random bytes, reused here as a throwaway master key
|
|
s := New(database, masterKey, t.TempDir())
|
|
|
|
dek := GenerateDEK()
|
|
wrapped, nonce, err := s.WrapDEK(dek)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := db.HashPassword("irrelevant-portal-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mailboxID, err := database.CreateMailbox("user@example.com", hash, domainID, quotaBytes, wrapped, nonce)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, mailboxID
|
|
}
|
|
|
|
func TestWrapUnwrapDEK(t *testing.T) {
|
|
database := newTestDB(t)
|
|
s := New(database, GenerateDEK(), t.TempDir())
|
|
|
|
dek := GenerateDEK()
|
|
wrapped, nonce, err := s.WrapDEK(dek)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.UnwrapDEK(wrapped, nonce)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(dek, got) {
|
|
t.Fatalf("unwrapped DEK does not match original: got %x, want %x", got, dek)
|
|
}
|
|
}
|
|
|
|
func TestStoreFetchRoundTrip(t *testing.T) {
|
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
|
raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\nhello world")
|
|
|
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.FetchMessage(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(raw, got) {
|
|
t.Fatalf("fetched message does not match stored: got %q, want %q", got, raw)
|
|
}
|
|
|
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
onDisk, err := os.ReadFile(msg.StoragePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Equal(onDisk, raw) {
|
|
t.Fatal("on-disk file matches plaintext — message was not actually encrypted")
|
|
}
|
|
|
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mbox.UsedBytes != int64(len(raw)) {
|
|
t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw))
|
|
}
|
|
if msg.CachedPreview != "hello world" {
|
|
t.Errorf("CachedPreview = %q, want %q", msg.CachedPreview, "hello world")
|
|
}
|
|
}
|
|
|
|
// TestStoreMessagePreviewTruncatesLongBodyRuneSafely confirms the cached preview is
|
|
// capped at previewSnippetLen characters (not bytes — a naive byte-slice cap could
|
|
// split a multi-byte UTF-8 character) and that non-ASCII text survives intact.
|
|
func TestStoreMessagePreviewTruncatesLongBodyRuneSafely(t *testing.T) {
|
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
|
longBody := strings.Repeat("héllo ", 100) // well over previewSnippetLen once joined
|
|
raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\n" + longBody)
|
|
|
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := len([]rune(msg.CachedPreview)); n != previewSnippetLen {
|
|
t.Errorf("preview length = %d runes, want %d", n, previewSnippetLen)
|
|
}
|
|
if !utf8.ValidString(msg.CachedPreview) {
|
|
t.Error("preview is not valid UTF-8 — truncation split a multi-byte character")
|
|
}
|
|
}
|
|
|
|
// TestRebuildMessageCacheRederivesFromExistingContent simulates a message stored
|
|
// before the "cache the From: header's display name" fix existed: cached_from was
|
|
// passed as the bare envelope address even though the stored raw content always had
|
|
// the full header. RebuildMessageCache should bring it up to date without needing the
|
|
// message re-delivered.
|
|
func TestRebuildMessageCacheRederivesFromExistingContent(t *testing.T) {
|
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
|
raw := []byte("From: Bob Marley <bob@example.com>\r\nTo: user@example.com\r\nSubject: One love\r\n\r\nHello there, this is the body.")
|
|
|
|
// "bob@example.com" mimics what the old (pre-fix) code would have cached — the
|
|
// bare envelope address — despite the header above always having the display name.
|
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "bob@example.com", "One love")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before.CachedFrom != "bob@example.com" {
|
|
t.Fatalf("test setup: expected the stale bare address before rebuild, got %q", before.CachedFrom)
|
|
}
|
|
|
|
updated, skipped := s.RebuildMessageCache(mailboxID)
|
|
if len(skipped) != 0 {
|
|
t.Fatalf("expected no skipped messages, got %v", skipped)
|
|
}
|
|
if updated != 1 {
|
|
t.Fatalf("expected 1 message updated, got %d", updated)
|
|
}
|
|
|
|
after, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.CachedFrom != "Bob Marley <bob@example.com>" {
|
|
t.Errorf("CachedFrom after rebuild = %q, want the header's display name", after.CachedFrom)
|
|
}
|
|
if after.CachedPreview != "Hello there, this is the body." {
|
|
t.Errorf("CachedPreview after rebuild = %q", after.CachedPreview)
|
|
}
|
|
|
|
// Re-running is a safe no-op once everything's already correct.
|
|
updated2, _ := s.RebuildMessageCache(mailboxID)
|
|
if updated2 != 0 {
|
|
t.Errorf("expected 0 messages updated on a second run, got %d", updated2)
|
|
}
|
|
}
|
|
|
|
func TestQuotaExceeded(t *testing.T) {
|
|
s, mailboxID := newTestMailbox(t, 10) // tiny quota
|
|
raw := []byte("this message is definitely longer than ten bytes")
|
|
|
|
_, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
|
if err != ErrQuotaExceeded {
|
|
t.Fatalf("err = %v, want ErrQuotaExceeded", err)
|
|
}
|
|
|
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mbox.UsedBytes != 0 {
|
|
t.Fatalf("used_bytes = %d after a rejected store, want 0", mbox.UsedBytes)
|
|
}
|
|
|
|
entries, err := os.ReadDir(s.BasePath)
|
|
if err == nil && len(entries) != 0 {
|
|
t.Fatalf("expected no files written under %s after a rejected store, found %d entries", s.BasePath, len(entries))
|
|
}
|
|
}
|
|
|
|
func TestDeleteMessageFreesQuota(t *testing.T) {
|
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
|
raw := []byte("From: a@example.com\r\nSubject: bye\r\n\r\ngoodbye")
|
|
|
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<def@example.com>", "a@example.com", "bye")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
storagePath := msg.StoragePath
|
|
|
|
if err := s.DeleteMessage(mailboxID, uid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mbox.UsedBytes != 0 {
|
|
t.Fatalf("used_bytes = %d after delete, want 0", mbox.UsedBytes)
|
|
}
|
|
if _, err := os.Stat(storagePath); !os.IsNotExist(err) {
|
|
t.Fatalf("ciphertext file %s still exists after delete", storagePath)
|
|
}
|
|
}
|