package webui import ( "bytes" "mime/multipart" "net/http" "net/http/httptest" "strconv" "strings" "testing" "mailgoserver/internal/smime" ) // TestWebmailSMIMEGenerateAndDownload confirms a mailbox owner can generate a // self-signed identity (no passphrase — S/MIME keys are stored plain, since S/MIME // is sign-only here), see it reflected on the page, and download the public // certificate. func TestWebmailSMIMEGenerateAndDownload(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "smime1@example.com", domains[0].ID, "smime-password-1!") cookie := webmailLoginSession(t, app, mailboxID) req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("generate: status=%d body=%s", rec.Code, rec.Body.String()) } identities, err := app.DB.ListSMIMEIdentities(mailboxID) if err != nil || len(identities) != 1 { t.Fatalf("expected 1 identity stored, got %d (err=%v)", len(identities), err) } identity := identities[0] if !strings.Contains(identity.KeyPEM, "PRIVATE KEY") { t.Fatal("expected the stored key to be a usable plain PEM") } cert, err := smime.ParseCertPEM([]byte(identity.CertPEM)) if err != nil { t.Fatalf("stored cert doesn't parse: %v", err) } if len(cert.EmailAddresses) != 1 || cert.EmailAddresses[0] != "smime1@example.com" { t.Fatalf("unexpected cert EmailAddresses: %v", cert.EmailAddresses) } pageReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/certs", nil) pageReq.AddCookie(cookie) pageRec := httptest.NewRecorder() mux.ServeHTTP(pageRec, pageReq) if pageRec.Code != http.StatusOK { t.Fatalf("expected the certs page to render, status=%d body=%s", pageRec.Code, pageRec.Body.String()) } dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identity.ID, 10)+"/download", nil) dlReq.AddCookie(cookie) dlRec := httptest.NewRecorder() mux.ServeHTTP(dlRec, dlReq) if dlRec.Code != http.StatusOK || dlRec.Body.String() != identity.CertPEM { t.Fatalf("expected downloaded cert to match stored cert, status=%d", dlRec.Code) } } // TestWebmailSMIMEMultipleIdentities confirms a mailbox can hold more than one // identity at once. func TestWebmailSMIMEMultipleIdentities(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "smime-multi@example.com", domains[0].ID, "smime-password-1!") cookie := webmailLoginSession(t, app, mailboxID) for i := 0; i < 2; i++ { req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil) req.AddCookie(cookie) mux.ServeHTTP(httptest.NewRecorder(), req) } identities, err := app.DB.ListSMIMEIdentities(mailboxID) if err != nil || len(identities) != 2 { t.Fatalf("expected 2 identities held simultaneously, got %d (err=%v)", len(identities), err) } } // TestWebmailSMIMERemoveIdentity confirms removal actually deletes the DB row. func TestWebmailSMIMERemoveIdentity(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "smime2@example.com", domains[0].ID, "smime-password-1!") cookie := webmailLoginSession(t, app, mailboxID) genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil) genReq.AddCookie(cookie) mux.ServeHTTP(httptest.NewRecorder(), genReq) identities, _ := app.DB.ListSMIMEIdentities(mailboxID) if len(identities) != 1 { t.Fatalf("expected 1 identity, got %d", len(identities)) } rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identities[0].ID, 10)+"/remove", nil) rmReq.AddCookie(cookie) rmRec := httptest.NewRecorder() mux.ServeHTTP(rmRec, rmReq) if rmRec.Code != http.StatusFound { t.Fatalf("remove: status=%d", rmRec.Code) } remaining, err := app.DB.ListSMIMEIdentities(mailboxID) if err != nil || len(remaining) != 0 { t.Fatalf("expected identity gone, got %d (err=%v)", len(remaining), err) } } // TestWebmailSMIMEContactAddAndRemove confirms a contact certificate can be added // (validated as a real cert), listed, and removed again. func TestWebmailSMIMEContactAddAndRemove(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "smime3@example.com", domains[0].ID, "smime-password-1!") cookie := webmailLoginSession(t, app, mailboxID) contactCertPEM, _, err := smime.GenerateSelfSigned("contact@other.example", smime.DefaultValidity) if err != nil { t.Fatal(err) } var buf bytes.Buffer mw := multipart.NewWriter(&buf) mw.WriteField("email", "contact@other.example") fw, err := mw.CreateFormFile("cert_file", "contact.pem") if err != nil { t.Fatal(err) } fw.Write(contactCertPEM) mw.Close() req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &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("add contact: status=%d body=%s", rec.Code, rec.Body.String()) } contacts, err := app.DB.ListSMIMEContacts(mailboxID) if err != nil || len(contacts) != 1 || contacts[0].Email != "contact@other.example" { t.Fatalf("expected 1 contact, got %+v (err=%v)", contacts, err) } rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil) rmReq.AddCookie(cookie) rmRec := httptest.NewRecorder() mux.ServeHTTP(rmRec, rmReq) if rmRec.Code != http.StatusFound { t.Fatalf("remove contact: status=%d", rmRec.Code) } remaining, err := app.DB.ListSMIMEContacts(mailboxID) if err != nil || len(remaining) != 0 { t.Fatalf("expected no contacts left, got %d (err=%v)", len(remaining), err) } } // TestWebmailSMIMEAddContactRejectsGarbage confirms an upload that isn't a valid // certificate is rejected rather than silently stored. func TestWebmailSMIMEAddContactRejectsGarbage(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() mailboxID := createTestMailboxWithPassword(t, app, "smime4@example.com", domains[0].ID, "smime-password-1!") cookie := webmailLoginSession(t, app, mailboxID) var buf bytes.Buffer mw := multipart.NewWriter(&buf) mw.WriteField("email", "nope@example.com") fw, err := mw.CreateFormFile("cert_file", "notacert.pem") if err != nil { t.Fatal(err) } fw.Write([]byte("this is not a certificate")) mw.Close() req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &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("status=%d", rec.Code) } contacts, err := app.DB.ListSMIMEContacts(mailboxID) if err != nil || len(contacts) != 0 { t.Fatalf("expected the invalid cert rejected, got %d contacts (err=%v)", len(contacts), err) } } // TestWebmailSMIMEScopedToOwnMailbox confirms one mailbox owner can't remove // another's contact by guessing its ID, and can't reach another's identity. func TestWebmailSMIMEScopedToOwnMailbox(t *testing.T) { app := newTestApp(t) mux := app.Mux() domains, _ := app.DB.ListDomains() victimID := createTestMailboxWithPassword(t, app, "victim3@example.com", domains[0].ID, "victim-password-1!") attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domains[0].ID, "attacker-password-1!") if err := app.DB.UpsertSMIMEContact(victimID, "someone@example.com", "irrelevant-pem-for-this-test"); err != nil { t.Fatal(err) } contacts, _ := app.DB.ListSMIMEContacts(victimID) attackerCookie := webmailLoginSession(t, app, attackerID) req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil) req.AddCookie(attackerCookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("status=%d", rec.Code) } stillThere, err := app.DB.ListSMIMEContacts(victimID) if err != nil || len(stillThere) != 1 { t.Fatalf("expected the victim's contact untouched, got %d (err=%v)", len(stillThere), err) } // Attacker downloading the victim's identity by guessing its ID should 404, not // leak the victim's cert. victimCookie := webmailLoginSession(t, app, victimID) genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil) genReq.AddCookie(victimCookie) mux.ServeHTTP(httptest.NewRecorder(), genReq) victimIdentities, _ := app.DB.ListSMIMEIdentities(victimID) if len(victimIdentities) != 1 { t.Fatalf("expected 1 victim identity, got %d", len(victimIdentities)) } dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(victimIdentities[0].ID, 10)+"/download", nil) dlReq.AddCookie(attackerCookie) dlRec := httptest.NewRecorder() mux.ServeHTTP(dlRec, dlReq) if dlRec.Code != http.StatusNotFound { t.Fatalf("expected 404 for an attacker guessing another mailbox's identity ID, got %d", dlRec.Code) } } func sessionTokenFromCookie(c *http.Cookie) string { return c.Value }