Files
mailgoserver/internal/webui/webmail_smoke_journey_test.go
T

187 lines
7.3 KiB
Go

package webui
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/pgp"
)
// TestWebmailComposeFrictionFixesFullJourney is a live-HTTP smoke test (real
// httptest.NewServer + a cookie-jar client, not raw httptest.NewRecorder calls) that
// walks the exact journey a user would take through all six friction-fix milestones
// in one continuous session: generate a passwordless S/MIME cert, fail a send and
// see the real error with content preserved, save a draft, reopen and re-save it,
// pick a PGP recipient from the dropdown (not by address auto-match), and finally
// send successfully. Session cookies flow exactly as a browser would send them.
func TestWebmailComposeFrictionFixesFullJourney(t *testing.T) {
app := newTestApp(t)
srv := httptest.NewServer(app.Mux())
defer srv.Close()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "journey-sender@example.com", domainID, "sender-password-1!")
recipID := createTestMailboxWithPassword(t, app, "journey-recip@example.com", domainID, "recip-password-1!")
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // inspect redirects ourselves, like a test proxy would
},
}
post := func(path string, form url.Values) *http.Response {
t.Helper()
resp, err := client.PostForm(srv.URL+path, form)
if err != nil {
t.Fatal(err)
}
return resp
}
get := func(path string) (*http.Response, string) {
t.Helper()
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(body)
}
// --- Login ---
loginResp := post(MailboxPrefix+"/login", url.Values{"email": {"journey-sender@example.com"}, "password": {"sender-password-1!"}})
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusFound {
t.Fatalf("login: status=%d", loginResp.StatusCode)
}
// --- Milestone 1: generate an S/MIME cert with NO passphrase fields at all ---
genResp := post(MailboxPrefix+"/smime/identity/generate", url.Values{})
genResp.Body.Close()
if genResp.StatusCode != http.StatusFound {
t.Fatalf("smime generate: status=%d", genResp.StatusCode)
}
identities, err := app.DB.ListSMIMEIdentities(senderID)
if err != nil || len(identities) != 1 {
t.Fatalf("expected 1 S/MIME identity, got %d (err=%v)", len(identities), err)
}
// --- Milestones 2+3: a rejected send (missing subject) shows the specific
// validation error rather than a generic message, and redisplays the form with
// the already-typed body still filled in instead of wiping it via a redirect ---
failResp := post(MailboxPrefix+"/mail/compose", url.Values{
"to": {"journey-recip@example.com"}, "subject": {""}, "body_html": {"partially written thought"},
})
failBody, _ := io.ReadAll(failResp.Body)
failResp.Body.Close()
if failResp.StatusCode != http.StatusOK {
t.Fatalf("expected the failed send to redisplay the form (200), got %d", failResp.StatusCode)
}
if !strings.Contains(string(failBody), "partially written thought") {
t.Fatalf("expected the typed body preserved after a failed send, got: %s", failBody)
}
if !strings.Contains(string(failBody), "Please add a subject") {
t.Fatalf("expected the specific validation error shown, got: %s", failBody)
}
// --- Milestone 4: save a draft, reopen it, re-save it (no duplicate) ---
post(MailboxPrefix+"/mail/save-draft", url.Values{"subject": {"Draft subject"}, "body_html": {"draft body"}}).Body.Close()
drafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected 1 draft, got %d (err=%v)", len(drafts), err)
}
draftID := drafts[0].ID
_, openBody := get(MailboxPrefix + "/mail/compose?draft=" + strconv.FormatInt(draftID, 10) + "&folder=Drafts")
if !strings.Contains(openBody, "Draft subject") || !strings.Contains(openBody, "draft body") {
t.Fatalf("expected the reopened draft prefilled, got: %s", openBody)
}
post(MailboxPrefix+"/mail/save-draft", url.Values{
"subject": {"Draft subject"}, "body_html": {"draft body, edited"}, "draft_id": {strconv.FormatInt(draftID, 10)},
}).Body.Close()
drafts, err = app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected still 1 draft after re-save (no duplicate), got %d (err=%v)", len(drafts), err)
}
draftID = drafts[0].ID
// --- Milestone 5: PGP encrypt using the recipient picker (not address matching)
// — the sender needs their own PGP key too, and the recipient's key is filed as a
// contact under a DIFFERENT email than the actual To address, proving the picker
// (not auto-match-by-address) is what makes this work.
senderPub, senderPriv, err := pgp.GenerateKeyPair("journey-sender@example.com", "sender pgp pass")
if err != nil {
t.Fatal(err)
}
if err := app.storePGPIdentity(senderID, "", "journey-sender@example.com", senderPub, senderPriv); err != nil {
t.Fatal(err)
}
recipPub, _, err := pgp.GenerateKeyPair("journey-recip@example.com", "recip pgp pass")
if err != nil {
t.Fatal(err)
}
entity, err := pgp.ParsePublicKey(recipPub)
if err != nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "filed-under-a-different-address@example.com", "Journey recipient", pgp.Fingerprint(entity), string(recipPub)); err != nil {
t.Fatal(err)
}
contact, err := app.DB.GetPGPContact(senderID, "filed-under-a-different-address@example.com")
if err != nil || contact == nil {
t.Fatal(err)
}
sendResp := post(MailboxPrefix+"/mail/compose", url.Values{
"to": {"journey-recip@example.com"}, "subject": {"Final message"}, "body_html": {"the actual content"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(contact.ID, 10)},
"draft_id": {strconv.FormatInt(draftID, 10)},
})
sendBody, _ := io.ReadAll(sendResp.Body)
sendResp.Body.Close()
if sendResp.StatusCode != http.StatusFound {
t.Fatalf("expected the send to succeed, got status=%d body=%s", sendResp.StatusCode, sendBody)
}
msgs, err := app.DB.ListMessagesInFolder(recipID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message delivered, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "the actual content") {
t.Fatal("expected the body encrypted, not plaintext, in the stored message")
}
// Sending with draft_id set removes the draft, same as any real mail client.
remainingDrafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(remainingDrafts) != 0 {
t.Fatalf("expected the draft gone after sending, got %d (err=%v)", len(remainingDrafts), err)
}
// --- Milestone 6: the Mail view renders the compose popup widget, wired to the
// real openCompose(...) calls, not the old plain navigation links. ---
_, folderBody := get(MailboxPrefix + "/mail/INBOX")
if !strings.Contains(folderBody, "openCompose('/webmail/mail/compose')") {
t.Fatalf("expected the Compose button wired to the popup widget, got: %s", folderBody)
}
if !strings.Contains(folderBody, "composePopup") {
t.Fatalf("expected the compose popup widget markup present on the mail view, got: %s", folderBody)
}
}