164 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|