Files
mailgoserver/internal/webui/admin_mfa_reset_test.go
T

184 lines
5.8 KiB
Go
Raw Normal View History

2026-08-13 08:07:19 +01:00
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"mailgoserver/internal/db"
)
// TestResetAdminMFA confirms a manager can clear another admin's TOTP and passkeys
// (e.g. after a lost device), and that only an admin who could otherwise manage that
// target (per the existing delegation rule) is allowed to.
func TestResetAdminMFA(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
targetID, err := app.DB.CreateAdminUser("has-mfa-admin", mustHash(t), false)
if err != nil {
t.Fatal(err)
}
if err := app.DB.SetAdminTOTPSecret(targetID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
if err := app.DB.CreateWebAuthnCredential(targetID, "yubikey", "cred-id-1", "cred-data-1"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+strconv.FormatInt(targetID, 10)+"/reset_mfa", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
target, err := app.DB.GetAdminUserByID(targetID)
if err != nil || target == nil {
t.Fatal(err)
}
if target.TOTPEnabled || target.TOTPSecret != "" {
t.Error("TOTP should be cleared")
}
creds, err := app.DB.ListWebAuthnCredentials(targetID)
if err != nil || len(creds) != 0 {
t.Errorf("expected no passkeys left, got %d (err=%v)", len(creds), err)
}
}
// TestResetAdminMFADeniedOutsideDelegationScope confirms a scoped admin can't reset
// MFA for an admin outside their delegation scope (mirrors the existing remove/edit
// access checks — resetting someone's MFA is just as sensitive an action).
func TestResetAdminMFADeniedOutsideDelegationScope(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
_ = domainA
cookieA := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
targetID, err := app.DB.CreateScopedAdminUser("tenant-b-admin", mustHash(t), 0, []int64{domainB.ID})
if err != nil {
t.Fatal(err)
}
if err := app.DB.SetAdminTOTPSecret(targetID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+strconv.FormatInt(targetID, 10)+"/reset_mfa", nil)
req.AddCookie(cookieA)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for an out-of-scope target, got %d", rec.Code)
}
target, err := app.DB.GetAdminUserByID(targetID)
if err != nil || target == nil {
t.Fatal(err)
}
if !target.TOTPEnabled {
t.Error("TOTP should NOT have been reset for an out-of-scope admin")
}
}
// TestAdminCannotRemoveOrResetOwnAccount confirms the existing self-management
// blocks (canManageAdmin already rejects target.ID == user.ID) also apply to the new
// reset_mfa route, and that the admins.html list hides both actions for your own row.
func TestAdminCannotRemoveOrResetOwnAccount(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
sess, err := app.DB.GetSession(cookie.Value)
if err != nil || sess == nil {
t.Fatal(err)
}
selfID := strconv.FormatInt(sess.UserID, 10)
for _, action := range []string{"remove", "reset_mfa"} {
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+selfID+"/"+action, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("%s on own account: status=%d, want 404", action, rec.Code)
}
}
stillThere, err := app.DB.GetAdminUserByID(sess.UserID)
if err != nil || stillThere == nil {
t.Fatal("own account should not have been removed")
}
// The admins list must not render a Remove/Reset MFA button for your own row.
req := httptest.NewRequest(http.MethodGet, Prefix+"/admins", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/admins: status=%d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "/admins/"+selfID+"/remove") {
t.Error("admins.html should not render a Remove action for the signed-in admin's own row")
}
if strings.Contains(body, "/admins/"+selfID+"/reset_mfa") {
t.Error("admins.html should not render a Reset MFA action for the signed-in admin's own row")
}
if !strings.Contains(body, "This is you") {
t.Error("expected the signed-in admin's own row to be marked, not just have its buttons hidden")
}
}
// TestResetMailboxMFA confirms an admin can clear a mailbox owner's TOTP and passkeys.
func TestResetMailboxMFA(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
mailboxes, err := app.DB.ListMailboxes()
if err != nil || len(mailboxes) == 0 {
t.Fatal("no seeded mailbox")
}
mboxID := mailboxes[0].ID
if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
if err := app.DB.CreateMailboxWebAuthnCredential(mboxID, "phone", "mcred-1", "mcred-data-1"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mboxID, 10)+"/reset_mfa", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
updated, err := app.DB.GetMailboxByID(mboxID)
if err != nil || updated == nil {
t.Fatal(err)
}
if updated.TOTPEnabled || updated.TOTPSecret != "" {
t.Error("TOTP should be cleared")
}
creds, err := app.DB.ListMailboxWebAuthnCredentials(mboxID)
if err != nil || len(creds) != 0 {
t.Errorf("expected no passkeys left, got %d (err=%v)", len(creds), err)
}
}
func mustHash(t *testing.T) string {
t.Helper()
hash, err := db.HashPassword("some-strong-password-1!")
if err != nil {
t.Fatal(err)
}
return hash
}