187 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|