204 lines
7.5 KiB
Go
204 lines
7.5 KiB
Go
package webui
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"io"
|
||
|
|
"mime/multipart"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/mail"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"mailgoserver/internal/smime"
|
||
|
|
)
|
||
|
|
|
||
|
|
// parseRawAsEntity is a test-only helper that reads a stored raw RFC822 message's
|
||
|
|
// Content-Type header and body into a smime.Entity — enough to feed into
|
||
|
|
// smime.VerifySigned/Decrypt without needing to export parseEntity from the smime
|
||
|
|
// package just for tests.
|
||
|
|
func parseRawAsEntity(t *testing.T, raw []byte) smime.Entity {
|
||
|
|
t.Helper()
|
||
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("parse raw message: %v", err)
|
||
|
|
}
|
||
|
|
body, err := io.ReadAll(msg.Body)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("read body: %v", err)
|
||
|
|
}
|
||
|
|
entity := smime.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body}
|
||
|
|
if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" {
|
||
|
|
entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte)
|
||
|
|
}
|
||
|
|
return entity
|
||
|
|
}
|
||
|
|
|
||
|
|
// genIdentity generates and stores an S/MIME identity for a mailbox directly
|
||
|
|
// (bypassing HTTP — the identity-management HTTP flow itself is covered separately
|
||
|
|
// in webmail_smime_test.go), returning its ID.
|
||
|
|
func genIdentity(t *testing.T, app *App, mailboxID int64, email string) int64 {
|
||
|
|
t.Helper()
|
||
|
|
certPEM, keyPEM, err := smime.GenerateSelfSigned(email, smime.DefaultValidity)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := app.storeIdentity(mailboxID, certPEM, keyPEM); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
|
||
|
|
if err != nil || len(identities) == 0 {
|
||
|
|
t.Fatalf("expected the identity to be stored, err=%v", err)
|
||
|
|
}
|
||
|
|
return identities[0].ID // most recently created
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebmailComposeSignSend confirms checking "Sign" produces a message the
|
||
|
|
// recipient (or anyone) can verify as genuinely from the sender — no passphrase
|
||
|
|
// involved, since S/MIME keys are stored plain in this codebase.
|
||
|
|
func TestWebmailComposeSignSend(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "signer@example.com", domainID, "signer-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "signee@example.com", domainID, "signee-password-1!")
|
||
|
|
genIdentity(t, app, senderID, "signer@example.com")
|
||
|
|
cookie := webmailLoginSession(t, app, senderID)
|
||
|
|
|
||
|
|
form := url.Values{
|
||
|
|
"to": {"signee@example.com"}, "subject": {"Signed"}, "body_html": {"trust me"},
|
||
|
|
"smime_sign": {"1"},
|
||
|
|
}
|
||
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.AddCookie(cookie)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(rec, req)
|
||
|
|
if rec.Code != http.StatusFound {
|
||
|
|
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||
|
|
if err != nil || len(msgs) != 1 {
|
||
|
|
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
|
||
|
|
}
|
||
|
|
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
entity := parseRawAsEntity(t, raw)
|
||
|
|
inner, signer, err := smime.VerifySigned(entity)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("VerifySigned: %v", err)
|
||
|
|
}
|
||
|
|
if signer.EmailAddresses[0] != "signer@example.com" {
|
||
|
|
t.Fatalf("unexpected signer: %v", signer.EmailAddresses)
|
||
|
|
}
|
||
|
|
if !bytes.Contains(inner.Body, []byte("trust me")) {
|
||
|
|
t.Fatalf("expected the body preserved inside the signed entity, got %q", inner.Body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebmailComposeSignedMessageWithAttachmentsShowsBody confirms a real body
|
||
|
|
// message text still renders on read alongside its attachments when the message is
|
||
|
|
// also S/MIME-signed — the exact combination (signed + multiple attachments + a
|
||
|
|
// typed body) that was never actually exercised by any test before this one
|
||
|
|
// (TestWebmailComposeSendMultipleAttachments checked attachments but not signing or
|
||
|
|
// the body text; the sign-only tests never included attachments).
|
||
|
|
func TestWebmailComposeSignedMessageWithAttachmentsShowsBody(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "signedattach-sender@example.com", domainID, "sender-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "signedattach-recip@example.com", domainID, "recip-password-1!")
|
||
|
|
genIdentity(t, app, senderID, "signedattach-sender@example.com")
|
||
|
|
cookie := webmailLoginSession(t, app, senderID)
|
||
|
|
|
||
|
|
var buf bytes.Buffer
|
||
|
|
mw := multipart.NewWriter(&buf)
|
||
|
|
mw.WriteField("to", "signedattach-recip@example.com")
|
||
|
|
mw.WriteField("subject", "test message")
|
||
|
|
mw.WriteField("body_html", "<p>this is my real message text</p>")
|
||
|
|
mw.WriteField("smime_sign", "1")
|
||
|
|
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
|
||
|
|
fw, err := mw.CreateFormFile("attachments", name)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
fw.Write([]byte("contents of " + name))
|
||
|
|
}
|
||
|
|
mw.Close()
|
||
|
|
|
||
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
|
||
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||
|
|
req.AddCookie(cookie)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(rec, req)
|
||
|
|
if rec.Code != http.StatusFound {
|
||
|
|
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||
|
|
if err != nil || len(msgs) != 1 {
|
||
|
|
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
|
||
|
|
}
|
||
|
|
|
||
|
|
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||
|
|
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
|
||
|
|
viewReq.AddCookie(recipientCookie)
|
||
|
|
viewRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(viewRec, viewReq)
|
||
|
|
if viewRec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
|
||
|
|
}
|
||
|
|
body := viewRec.Body.String()
|
||
|
|
if !strings.Contains(body, "this is my real message text") {
|
||
|
|
t.Fatalf("expected the message body rendered, got: %s", body)
|
||
|
|
}
|
||
|
|
if strings.Contains(body, "empty message body") {
|
||
|
|
t.Fatal("expected the body NOT reported as empty when real text was sent")
|
||
|
|
}
|
||
|
|
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
|
||
|
|
if !strings.Contains(body, name) {
|
||
|
|
t.Errorf("expected attachment %q listed alongside the body", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebmailComposeSignWithoutIdentityFails confirms checking "Sign" with no
|
||
|
|
// identity on file fails cleanly instead of sending unsigned.
|
||
|
|
func TestWebmailComposeSignWithoutIdentityFails(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "nokey@example.com", domainID, "nokey-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "recip4@example.com", domainID, "recip4-password-1!")
|
||
|
|
cookie := webmailLoginSession(t, app, senderID)
|
||
|
|
|
||
|
|
form := url.Values{
|
||
|
|
"to": {"recip4@example.com"}, "subject": {"x"}, "body_html": {"x"},
|
||
|
|
"smime_sign": {"1"},
|
||
|
|
}
|
||
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.AddCookie(cookie)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(rec, req)
|
||
|
|
if rec.Code != http.StatusFound && rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status=%d", rec.Code)
|
||
|
|
}
|
||
|
|
|
||
|
|
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||
|
|
if err != nil || len(msgs) != 0 {
|
||
|
|
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
|
||
|
|
}
|
||
|
|
}
|