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} }