265 lines
9.8 KiB
Go
265 lines
9.8 KiB
Go
package webui
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/mailstore"
|
|
)
|
|
|
|
// createTestMailboxWithPassword mirrors createMailboxFor but with a known plaintext
|
|
// portal password, for webmail login tests.
|
|
func createTestMailboxWithPassword(t *testing.T, app *App, email string, domainID int64, password string) int64 {
|
|
t.Helper()
|
|
hash, err := db.HashPassword(password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dek := mailstore.GenerateDEK()
|
|
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, err := app.DB.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestWebmailLoginSucceedsAndReachesDashboard(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithPassword(t, app, "portaluser@example.com", domains[0].ID, "portal-password-123!")
|
|
_ = mailboxID
|
|
|
|
form := url.Values{"email": {"portaluser@example.com"}, "password": {"portal-password-123!"}}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected redirect after login, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var sessionCookie *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == mailboxSessionCookieName {
|
|
sessionCookie = c
|
|
}
|
|
}
|
|
if sessionCookie == nil {
|
|
t.Fatal("expected a mailbox session cookie to be set")
|
|
}
|
|
|
|
// The webmail root is the mailbox itself now, not account settings — it redirects
|
|
// straight to the inbox.
|
|
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
|
req2.AddCookie(sessionCookie)
|
|
rec2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec2, req2)
|
|
if rec2.Code != http.StatusFound || rec2.Header().Get("Location") != MailboxPrefix+"/mail/INBOX" {
|
|
t.Fatalf("expected the webmail root to redirect to the inbox, got %d Location=%q", rec2.Code, rec2.Header().Get("Location"))
|
|
}
|
|
|
|
req3 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/account", nil)
|
|
req3.AddCookie(sessionCookie)
|
|
rec3 := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec3, req3)
|
|
if rec3.Code != http.StatusOK {
|
|
t.Fatalf("expected the account page to render, got %d: %s", rec3.Code, rec3.Body.String())
|
|
}
|
|
if !strings.Contains(rec3.Body.String(), "portaluser@example.com") {
|
|
t.Fatal("expected the account page to show the mailbox's own email")
|
|
}
|
|
}
|
|
|
|
func TestWebmailLoginRejectsAppPassword(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithPassword(t, app, "portaluser2@example.com", domains[0].ID, "portal-password-123!")
|
|
|
|
appPwHash, err := db.HashPassword("an-app-password-not-the-portal-one")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
form := url.Values{"email": {"portaluser2@example.com"}, "password": {"an-app-password-not-the-portal-one"}}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Incorrect") {
|
|
t.Fatalf("expected login to reject an app password (portal login only accepts the portal password), got status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebmailUnauthenticatedRedirectsToLogin(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected redirect, got %d", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, MailboxPrefix+"/login") {
|
|
t.Fatalf("expected redirect to webmail login, got %q", loc)
|
|
}
|
|
}
|
|
|
|
// TestWebmailAndAdminSessionsAreIsolated confirms the two session systems really are
|
|
// separate: an admin session cookie doesn't grant webmail access and vice versa.
|
|
func TestWebmailAndAdminSessionsAreIsolated(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
createTestMailboxWithPassword(t, app, "portaluser3@example.com", domains[0].ID, "portal-password-123!")
|
|
|
|
adminCookie := loginSession(t, app)
|
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
|
req.AddCookie(&http.Cookie{Name: mailboxSessionCookieName, Value: adminCookie.Value})
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected an admin session token to NOT grant webmail access, got status %d", rec.Code)
|
|
}
|
|
|
|
form := url.Values{"email": {"portaluser3@example.com"}, "password": {"portal-password-123!"}}
|
|
loginReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
loginRec := httptest.NewRecorder()
|
|
mux.ServeHTTP(loginRec, loginReq)
|
|
var mailboxCookie *http.Cookie
|
|
for _, c := range loginRec.Result().Cookies() {
|
|
if c.Name == mailboxSessionCookieName {
|
|
mailboxCookie = c
|
|
}
|
|
}
|
|
if mailboxCookie == nil {
|
|
t.Fatal("expected a mailbox session cookie")
|
|
}
|
|
req2 := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
|
req2.AddCookie(&http.Cookie{Name: sessionCookieName, Value: mailboxCookie.Value})
|
|
rec2 := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec2, req2)
|
|
if rec2.Code != http.StatusFound {
|
|
t.Fatalf("expected a mailbox session token to NOT grant admin access, got status %d", rec2.Code)
|
|
}
|
|
}
|
|
|
|
func TestWebmailChangePassword(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithPassword(t, app, "portaluser4@example.com", domains[0].ID, "old-password-123!")
|
|
|
|
cookie := webmailLoginSession(t, app, mailboxID)
|
|
form := url.Values{
|
|
"current_password": {"old-password-123!"},
|
|
"new_password": {"new-password-456!"},
|
|
"new_password_confirm": {"new-password-456!"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", 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("expected redirect after password change, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
mbox, err := app.DB.GetMailboxByID(mailboxID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.CheckPassword("new-password-456!", mbox.PasswordHash) {
|
|
t.Fatal("expected the new password to have been saved")
|
|
}
|
|
}
|
|
|
|
func TestWebmailAppPasswordSelfService(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithPassword(t, app, "portaluser5@example.com", domains[0].ID, "password-123!")
|
|
cookie := webmailLoginSession(t, app, mailboxID)
|
|
|
|
form := url.Values{"label": {"my laptop"}}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", 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("expected redirect after creating app password, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
passwords, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
|
|
if err != nil || len(passwords) != 1 {
|
|
t.Fatalf("expected exactly 1 app password, got %d (err=%v)", len(passwords), err)
|
|
}
|
|
|
|
revokeReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/"+itoa(passwords[0].ID)+"/revoke", nil)
|
|
revokeReq.AddCookie(cookie)
|
|
revokeRec := httptest.NewRecorder()
|
|
mux.ServeHTTP(revokeRec, revokeReq)
|
|
if revokeRec.Code != http.StatusFound {
|
|
t.Fatalf("expected redirect after revoking, got %d", revokeRec.Code)
|
|
}
|
|
remaining, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
|
|
if err != nil || len(remaining) != 0 {
|
|
t.Fatalf("expected 0 app passwords after revoke, got %d (err=%v)", len(remaining), err)
|
|
}
|
|
}
|
|
|
|
func TestWebmailMFAGateRequiresCodeAfterTOTPEnabled(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithPassword(t, app, "portaluser6@example.com", domains[0].ID, "password-123!")
|
|
if err := app.DB.SetMailboxTOTPSecret(mailboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
form := url.Values{"email": {"portaluser6@example.com"}, "password": {"password-123!"}}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected redirect to MFA step, got %d", rec.Code)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if loc != MailboxPrefix+"/login/mfa" {
|
|
t.Fatalf("expected redirect to %s, got %q", MailboxPrefix+"/login/mfa", loc)
|
|
}
|
|
|
|
// No fully-verified session cookie should exist yet — only the pending-MFA cookie.
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == mailboxSessionCookieName {
|
|
t.Fatal("a fully-verified session must not be issued before MFA is satisfied")
|
|
}
|
|
}
|
|
}
|
|
|
|
// webmailLoginSession creates a fully-verified (no MFA enrolled) mailbox session
|
|
// directly via the DB, mirroring loginSession's admin equivalent.
|
|
func webmailLoginSession(t *testing.T, app *App, mailboxID int64) *http.Cookie {
|
|
t.Helper()
|
|
token, err := app.DB.CreateMailboxSession(mailboxID, true, sessionTTL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &http.Cookie{Name: mailboxSessionCookieName, Value: token}
|
|
}
|