Files
mailgoserver/internal/webui/domain_backup_test.go
T

164 lines
6.0 KiB
Go

package webui
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// TestExportImportDomainRoundTrip exercises the admin domain page's export/import
// routes end-to-end over real HTTP (CSRF token scraped from the rendered page, same
// as csrf_test.go/backups_test.go) — export a domain from one server, import it into a
// domain on a SECOND, entirely separate server (two independent Apps/DBs, matching the
// real cross-server-portability scenario this feature exists for — a same-server
// import would trivially "already exist" since the seeded mailbox's email is global to
// one DB), confirm the mailbox landed there with its data intact.
func TestExportImportDomainRoundTrip(t *testing.T) {
srcApp := newTestApp(t)
srcSrv := httptest.NewServer(SecurityHeaders(srcApp.CSRFProtect(srcApp.Mux())))
defer srcSrv.Close()
srcCookie := loginSession(t, srcApp)
dstApp := newTestApp(t)
dstSrv := httptest.NewServer(SecurityHeaders(dstApp.CSRFProtect(dstApp.Mux())))
defer dstSrv.Close()
dstCookie := loginSession(t, dstApp)
client := &http.Client{}
// The import/export POST handlers redirect (302) or stream a download — following
// redirects automatically would turn the import assertion below into a check on
// whatever page it redirects to instead of the POST's own response.
noRedirectClient := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
scrapeToken := func(srvURL, path string, cookie *http.Cookie) string {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, srvURL+path, nil)
req.AddCookie(cookie)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status %d, body: %s", path, resp.StatusCode, body)
}
m := regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`).FindSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token found on %s: %s", path, body)
}
return string(m[1])
}
srcDomains, _ := srcApp.DB.ListDomains()
srcDomainID := srcDomains[0].ID
// newTestApp seeds "inbox@example.com" on EVERY App, so a cross-server import of
// just that address would always hit the skip path (dst already has its own copy)
// rather than exercising a genuine successful import — add a second, distinct
// mailbox on the source specifically so this test proves data actually arrives.
hash, err := db.HashPassword("second-mailbox-pw-123!")
if err != nil {
t.Fatal(err)
}
dek := mailstore.GenerateDEK()
wrapped, nonce, err := srcApp.Mailstore.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
secondMailboxID, err := srcApp.DB.CreateMailbox("second@example.com", hash, srcDomainID, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
if _, err := srcApp.Mailstore.StoreMessage(secondMailboxID, "INBOX", []byte("From: a@b.com\r\nSubject: exported\r\n\r\nexported body"), "<m1@b.com>", "a@b.com", "exported"); err != nil {
t.Fatal(err)
}
srcEditPath := Prefix + "/domains/" + itoa(srcDomainID) + "/edit"
exportToken := scrapeToken(srcSrv.URL, srcEditPath, srcCookie)
exportReq, _ := http.NewRequest(http.MethodPost, srcSrv.URL+Prefix+"/domains/"+itoa(srcDomainID)+"/export",
strings.NewReader(url.Values{"passphrase": {"export-pw"}}.Encode()))
exportReq.AddCookie(srcCookie)
exportReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
exportReq.Header.Set("X-CSRF-Token", exportToken)
exportResp, err := client.Do(exportReq)
if err != nil {
t.Fatal(err)
}
archive, _ := io.ReadAll(exportResp.Body)
exportResp.Body.Close()
if exportResp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d, body: %s", exportResp.StatusCode, archive)
}
if cd := exportResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
t.Fatalf("Content-Disposition = %q, want an attachment", cd)
}
if len(archive) == 0 {
t.Fatal("exported archive is empty")
}
dstDomains, _ := dstApp.DB.ListDomains()
dstDomainID := dstDomains[0].ID
dstEditPath := Prefix + "/domains/" + itoa(dstDomainID) + "/edit"
importToken := scrapeToken(dstSrv.URL, dstEditPath, dstCookie)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, err := mw.CreateFormFile("archive", "export.tar.gz")
if err != nil {
t.Fatal(err)
}
part.Write(archive)
mw.WriteField("passphrase", "export-pw")
mw.Close()
importReq, _ := http.NewRequest(http.MethodPost, dstSrv.URL+Prefix+"/domains/"+itoa(dstDomainID)+"/import", &body)
importReq.AddCookie(dstCookie)
importReq.Header.Set("Content-Type", mw.FormDataContentType())
importReq.Header.Set("X-CSRF-Token", importToken)
importResp, err := noRedirectClient.Do(importReq)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, importResp.Body)
importResp.Body.Close()
if importResp.StatusCode != http.StatusFound {
t.Fatalf("import: status %d, want a redirect", importResp.StatusCode)
}
// dstDomainID already has its own seeded "inbox@example.com" (every newTestApp
// seeds one) — that address collides and must be skipped, while "second@example.com"
// is new to dst and must actually arrive with its content intact.
mailboxes, err := dstApp.DB.ListMailboxesForDomain(dstDomainID)
if err != nil {
t.Fatal(err)
}
var imported *db.Mailbox
for i := range mailboxes {
if mailboxes[i].Email == "second@example.com" {
imported = &mailboxes[i]
}
}
if len(mailboxes) != 2 || imported == nil {
t.Fatalf("expected the pre-existing seeded mailbox plus the newly imported second@example.com, got %+v", mailboxes)
}
msgs, err := dstApp.DB.ListMessagesInFolder(imported.ID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 imported message, got %d (err=%v)", len(msgs), err)
}
raw, err := dstApp.Mailstore.FetchMessage(imported.ID, msgs[0].ID)
if err != nil || !strings.Contains(string(raw), "exported body") {
t.Fatalf("imported message content wrong: %q, err=%v", raw, err)
}
}