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) } }