added IMAP, LetsEncrypt, update layout
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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")
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
||||
req2.AddCookie(sessionCookie)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to render, got %d: %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec2.Body.String(), "portaluser@example.com") {
|
||||
t.Fatal("expected the dashboard 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); 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}
|
||||
}
|
||||
Reference in New Issue
Block a user