package webui import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "mailgoserver/internal/db" ) // TestAdminMFAEnforcementForcesIsolatedSetupThenReleases confirms enforce_admin_mfa // redirects EVERY route — including /account itself — to the isolated /mfa-setup // page for an admin with no second factor yet, until they actually set one up, after // which normal access (including /account) resumes. func TestAdminMFAEnforcementForcesIsolatedSetupThenReleases(t *testing.T) { app := newTestApp(t) app.Cfg.Section("Auth").Key("enforce_admin_mfa").SetValue("true") mux := app.Mux() hash, err := db.HashPassword("no-mfa-yet-password-1!") if err != nil { t.Fatal(err) } userID, err := app.DB.CreateAdminUser("no-mfa-admin", hash, false) if err != nil { t.Fatal(err) } token, err := app.DB.CreateSession(userID, true, sessionTTL) if err != nil { t.Fatal(err) } cookie := &http.Cookie{Name: sessionCookieName, Value: token} // Blocked from an ordinary page, AND from /account, both redirected to /mfa-setup. for _, path := range []string{Prefix + "/domains", Prefix + "/account"} { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/mfa-setup" { t.Fatalf("%s: expected redirect to /mfa-setup, got %d Location=%q", path, rec.Code, rec.Header().Get("Location")) } } // The isolated setup page itself must be reachable and show no sidebar/nav. req := httptest.NewRequest(http.MethodGet, Prefix+"/mfa-setup", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("/mfa-setup: status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Two-factor authentication required") { t.Error("expected the MFA-required heading on /mfa-setup") } if strings.Contains(body, "sidebar") || strings.Contains(body, `href="/pymta-manager/domains"`) { t.Error("expected no sidebar/navigation on the isolated setup page") } // Once TOTP is enabled, both /domains and /account become reachable again. if err := app.DB.SetAdminTOTPSecret(userID, "JBSWY3DPEHPK3PXP", true); err != nil { t.Fatal(err) } for _, path := range []string{Prefix + "/domains", Prefix + "/account"} { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: expected reachable after enabling MFA, got %d", path, rec.Code) } } } // TestAdminMFAEnforcementOffByDefault confirms nothing changes for existing installs // unless the admin explicitly turns enforcement on. func TestAdminMFAEnforcementOffByDefault(t *testing.T) { app := newTestApp(t) mux := app.Mux() cookie := loginSession(t, app) req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected /domains reachable with enforcement off, got %d", rec.Code) } } // TestMailboxMFAEnforcementLetsLoginThroughButBlocksPasswordChange confirms // enforce_mailbox_mfa no longer blocks login itself for a mailbox with no MFA // configured — it lands them on the dashboard (which has the TOTP/passkey setup // cards) and only blocks changing the account password until MFA is set up. App // passwords are deliberately untouched throughout; see mailboxNeedsMFASetup's doc // comment. func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testing.T) { app := newTestApp(t) app.Cfg.Section("Auth").Key("enforce_mailbox_mfa").SetValue("true") mux := app.Mux() domainID, err := app.DB.CreateDomain("mfatest.example") if err != nil { t.Fatal(err) } mhash, err := db.HashPassword("mailbox-owner-password-1!") if err != nil { t.Fatal(err) } dek := make([]byte, 32) mboxID, err := app.DB.CreateMailbox("owner@mfatest.example", mhash, domainID, 1<<30, dek, dek) if err != nil { t.Fatal(err) } form := url.Values{"email": {"owner@mfatest.example"}, "password": {"mailbox-owner-password-1!"}} 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 || rec.Header().Get("Location") != MailboxPrefix+"/" { t.Fatalf("expected login itself to succeed, got %d Location=%q", rec.Code, rec.Header().Get("Location")) } var cookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == mailboxSessionCookieName { cookie = c } } if cookie == nil { t.Fatal("expected a session cookie despite no MFA configured") } // The mailbox, account page, password change, and app-password creation are ALL // redirected to the isolated setup page — nothing else is reachable in the browser. blockedGets := []string{MailboxPrefix + "/", MailboxPrefix + "/account"} for _, path := range blockedGets { req = httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/mfa-setup" { t.Fatalf("GET %s: expected redirect to /mfa-setup, got %d Location=%q", path, rec.Code, rec.Header().Get("Location")) } } pwForm := url.Values{"current_password": {"mailbox-owner-password-1!"}, "new_password": {"NewPassw0rd!!"}, "new_password_confirm": {"NewPassw0rd!!"}} req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.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.Header().Get("Location") != MailboxPrefix+"/mfa-setup" { t.Fatalf("password change: expected redirect to /mfa-setup, got %d", rec.Code) } stillOld, err := app.DB.GetMailboxByID(mboxID) if err != nil || stillOld == nil { t.Fatal(err) } if !db.CheckPassword("mailbox-owner-password-1!", stillOld.PasswordHash) { t.Fatal("password should not have changed") } appForm := url.Values{"label": {"laptop"}} req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", strings.NewReader(appForm.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.Header().Get("Location") != MailboxPrefix+"/mfa-setup" { t.Fatalf("app password creation: expected redirect to /mfa-setup, got %d", rec.Code) } if passwords, _ := app.DB.ListAppPasswordsForMailbox(mboxID); len(passwords) != 0 { t.Fatalf("app password creation should have been blocked in the browser, got %d created", len(passwords)) } // The isolated setup page itself is reachable and shows no other portal content. req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mfa-setup", nil) req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("/mfa-setup: status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Two-factor authentication required") { t.Error("expected the MFA-required heading on /mfa-setup") } if strings.Contains(body, "App Passwords") || strings.Contains(body, "Change Password") { t.Error("expected no other portal sections on the isolated setup page") } // Once TOTP is configured, everything works normally again — mailbox, account // page, password change, and app passwords. if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil { t.Fatal(err) } req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil) req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/mail/INBOX" { t.Fatalf("mailbox root: expected reachable (redirect to inbox) after enabling MFA, got %d Location=%q", rec.Code, rec.Header().Get("Location")) } req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/account", nil) req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("account page: expected reachable after enabling MFA, got %d", rec.Code) } req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) updated, err := app.DB.GetMailboxByID(mboxID) if err != nil || updated == nil { t.Fatal(err) } if !db.CheckPassword("NewPassw0rd!!", updated.PasswordHash) { t.Fatal("password should have changed once MFA is configured") } req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", strings.NewReader(appForm.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if passwords, _ := app.DB.ListAppPasswordsForMailbox(mboxID); len(passwords) != 1 { t.Fatalf("expected app password creation to succeed once MFA is configured, got %d", len(passwords)) } }