Files
mailgoserver/internal/webui/auth_audit_log_test.go
T
2026-08-13 10:40:27 +01:00

234 lines
7.4 KiB
Go

package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// authLogsFor issues an authenticated GET /logs?type=auth and returns the raw body,
// used below to check which auth-log rows a given admin session can see.
func authLogsFor(t *testing.T, mux http.Handler, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, Prefix+"/logs?type=auth", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/logs?type=auth: status=%d", rec.Code)
}
return rec.Body.String()
}
// TestAdminLoginLogsAuthAttempts confirms both a failed and a successful admin
// dashboard login are recorded to the audit log.
func TestAdminLoginLogsAuthAttempts(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
hash, err := db.HashPassword("correct-horse-battery-1!")
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAdminUser("audituser", hash, false); err != nil {
t.Fatal(err)
}
// Wrong password.
form := url.Values{"username": {"audituser"}, "password": {"wrong-password"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(httptest.NewRecorder(), req)
// Correct password.
form = url.Values{"username": {"audituser"}, "password": {"correct-horse-battery-1!"}}
req = httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(httptest.NewRecorder(), req)
logs, err := app.DB.ListRecentAuthLogs(50)
if err != nil {
t.Fatal(err)
}
var sawFail, sawSuccess bool
for _, l := range logs {
if l.AuthType != "admin_login" || l.Identifier != "audituser" {
continue
}
if !l.Success {
sawFail = true
} else {
sawSuccess = true
}
}
if !sawFail {
t.Error("expected a failed admin_login entry for the wrong-password attempt")
}
if !sawSuccess {
t.Error("expected a successful admin_login entry for the correct-password attempt")
}
}
// TestAdminMFAEventsLogged confirms enabling/disabling TOTP, and an admin resetting
// another admin's MFA, all produce admin_mfa audit entries.
func TestAdminMFAEventsLogged(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)
}
if err := app.DB.SetAdminTOTPSecret(sess.UserID, "JBSWY3DPEHPK3PXP", false); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/confirm", strings.NewReader(url.Values{"code": {totpCodeFor(t, "JBSWY3DPEHPK3PXP")}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
req = httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/disable", nil)
req.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
targetID, err := app.DB.CreateAdminUser("reset-target", mustHash(t), false)
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(cookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
logs, err := app.DB.ListRecentAuthLogs(50)
if err != nil {
t.Fatal(err)
}
var sawEnabled, sawDisabled, sawReset bool
for _, l := range logs {
if l.AuthType != "admin_mfa" {
continue
}
switch {
case strings.Contains(l.Message, "enabled") && l.Identifier == "test-admin":
sawEnabled = true
case strings.Contains(l.Message, "disabled") && l.Identifier == "test-admin":
sawDisabled = true
case strings.Contains(l.Message, "reset by admin") && l.Identifier == "reset-target":
sawReset = true
}
}
if !sawEnabled {
t.Error("expected an admin_mfa entry for TOTP enabled")
}
if !sawDisabled {
t.Error("expected an admin_mfa entry for TOTP disabled")
}
if !sawReset {
t.Error("expected an admin_mfa entry for the admin-initiated reset")
}
}
// TestWebmailLoginLogsAuthAttempts mirrors TestAdminLoginLogsAuthAttempts for the
// self-service webmail portal.
func TestWebmailLoginLogsAuthAttempts(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
mailboxes, err := app.DB.ListMailboxes()
if err != nil || len(mailboxes) == 0 {
t.Fatal("no seeded mailbox")
}
email := mailboxes[0].Email
form := url.Values{"email": {email}, "password": {"wrong-password"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(httptest.NewRecorder(), req)
form = url.Values{"email": {email}, "password": {"testpass123"}}
req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(httptest.NewRecorder(), req)
logs, err := app.DB.ListRecentAuthLogs(50)
if err != nil {
t.Fatal(err)
}
var sawFail, sawSuccess bool
for _, l := range logs {
if l.AuthType != "webmail_login" || l.Identifier != email {
continue
}
if !l.Success {
sawFail = true
} else {
sawSuccess = true
}
}
if !sawFail {
t.Error("expected a failed webmail_login entry for the wrong-password attempt")
}
if !sawSuccess {
t.Error("expected a successful webmail_login entry for the correct-password attempt")
}
}
// TestAdminAuditLogsHiddenFromScopedAdmins confirms admin_login/admin_mfa entries
// (identified by admin username, with no domain to attribute them to) are visible
// only to global admins, while webmail_login/mailbox_mfa entries (identified by
// mailbox email) remain visible to a scoped admin for their own domain.
func TestAdminAuditLogsHiddenFromScopedAdmins(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, err := app.DB.ListDomains()
if err != nil || len(domains) == 0 {
t.Fatal("no seeded domain")
}
domainName := domains[0].DomainName
if err := app.DB.LogAuthAttempt("admin_login", "some-admin-username", "127.0.0.1", true, "Login successful"); err != nil {
t.Fatal(err)
}
if err := app.DB.LogAuthAttempt("webmail_login", "owner@"+domainName, "127.0.0.1", true, "Login successful"); err != nil {
t.Fatal(err)
}
scopedCookie := scopedLogin(t, app, "scoped-log-viewer", []int64{domains[0].ID})
scopedBody := authLogsFor(t, mux, scopedCookie)
if strings.Contains(scopedBody, "some-admin-username") {
t.Error("a scoped admin should not see admin_login entries at all")
}
if !strings.Contains(scopedBody, "owner@"+domainName) {
t.Error("a scoped admin should see webmail_login entries for their own domain")
}
globalCookie := loginSession(t, app)
globalBody := authLogsFor(t, mux, globalCookie)
if !strings.Contains(globalBody, "some-admin-username") {
t.Error("a global admin should see admin_login entries")
}
}
// totpCodeFor generates a valid current TOTP code for a secret — used to drive
// account.go's totpSetupConfirm through a real form submission.
func totpCodeFor(t *testing.T, secret string) string {
t.Helper()
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatal(err)
}
return code
}