184 lines
5.8 KiB
Go
184 lines
5.8 KiB
Go
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
|
||
|
|
}
|