Files
mailgoserver/internal/webui/multitenant_test.go
T

270 lines
9.2 KiB
Go
Raw Normal View History

2026-08-12 12:56:22 +01:00
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/db"
)
// scopedLogin creates a domain-scoped (non-global) admin with access to exactly
// domainIDs and returns its session cookie. CreateScopedAdminUser always sets
// must_change_password (matching the real "you can't keep an admin-picked initial
// password" flow), so this clears it the same way completing /first-login would —
// otherwise every protected route redirects to /first-login before the scoping logic
// these tests exercise ever runs.
func scopedLogin(t *testing.T, app *App, username string, domainIDs []int64) *http.Cookie {
t.Helper()
hash, err := db.HashPassword("scoped-password-123!")
if err != nil {
t.Fatal(err)
}
userID, err := app.DB.CreateScopedAdminUser(username, hash, 0, domainIDs)
if err != nil {
t.Fatal(err)
}
if err := app.DB.UpdateAdminCredentials(userID, username, hash); err != nil {
t.Fatal(err)
}
token, err := app.DB.CreateSession(userID, true, sessionTTL)
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: sessionCookieName, Value: token}
}
// setupTwoTenants seeds two separate domains, each with its own sender, whitelisted
// IP, and DKIM key, and returns everything needed to test cross-tenant isolation.
func setupTwoTenants(t *testing.T, app *App) (domainA, domainB db.Domain, senderA, senderB *db.Sender) {
t.Helper()
aID, err := app.DB.CreateDomain("tenant-a.example")
if err != nil {
t.Fatal(err)
}
bID, err := app.DB.CreateDomain("tenant-b.example")
if err != nil {
t.Fatal(err)
}
da, _ := app.DB.GetDomainByID(aID)
db_, _ := app.DB.GetDomainByID(bID)
hash, _ := db.HashPassword("password123")
saID, err := app.DB.CreateSender("alice@tenant-a.example", hash, aID, false, false)
if err != nil {
t.Fatal(err)
}
sbID, err := app.DB.CreateSender("bob@tenant-b.example", hash, bID, false, false)
if err != nil {
t.Fatal(err)
}
sa, _ := app.DB.GetSenderByID(saID)
sb, _ := app.DB.GetSenderByID(sbID)
if _, err := app.DKIM.GenerateDKIMKeypair("tenant-a.example", "", false); err != nil {
t.Fatal(err)
}
if _, err := app.DKIM.GenerateDKIMKeypair("tenant-b.example", "", false); err != nil {
t.Fatal(err)
}
return *da, *db_, sa, sb
}
func TestScopedAdminOnlySeesOwnDomainInList(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, domainA.DomainName) {
t.Error("scoped admin's own domain should appear in the domains list")
}
if strings.Contains(body, domainB.DomainName) {
t.Error("scoped admin must NOT see a domain outside their assignment")
}
}
func TestScopedAdminCannotAccessOtherTenantSenderByID(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, senderB := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
// Direct URL access to a sender belonging to a domain they don't manage.
req := httptest.NewRequest(http.MethodGet, Prefix+"/senders/"+strconv.FormatInt(senderB.ID, 10)+"/edit", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a sender outside scope, got %d", rec.Code)
}
// Same for the mutating route — must not be able to disable it either.
form := url.Values{}
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/senders/"+strconv.FormatInt(senderB.ID, 10)+"/delete", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNotFound {
t.Fatalf("expected 404 disabling a sender outside scope, got %d", rec2.Code)
}
stillActive, err := app.DB.GetSenderByID(senderB.ID)
if err != nil || stillActive == nil || !stillActive.IsActive {
t.Fatal("sender outside scope must not have been modified")
}
}
func TestScopedAdminCannotCreateSenderOnUnownedDomain(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"local_part": {"mallory"},
"domain_id": {strconv.FormatInt(domainB.ID, 10)}, // not theirs
"password": {"password123"},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/senders/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.StatusNotFound {
t.Fatalf("expected 404 creating a sender on an unowned domain, got %d", rec.Code)
}
if s, _ := app.DB.GetSenderByEmail("mallory@" + domainB.DomainName); s != nil {
t.Fatal("sender must not have been created on a domain outside the admin's scope")
}
}
func TestNewDomainAutoGrantedToScopedCreator(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
userID, err := app.DB.GetAdminUserByUsername("tenant-a-admin")
if err != nil || userID == nil {
t.Fatal("expected the scoped admin to exist")
}
form := url.Values{"domain_name": {"brand-new.example"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/domains/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 domain, got %d: %s", rec.Code, rec.Body.String())
}
newDomain, err := app.DB.GetDomainByNameExact("brand-new.example")
if err != nil || newDomain == nil {
t.Fatalf("expected domain to be created: %v", err)
}
ids, err := app.DB.AccessibleDomainIDs(userID.ID)
if err != nil {
t.Fatal(err)
}
found := false
for _, id := range ids {
if id == newDomain.ID {
found = true
}
}
if !found {
t.Fatal("a scoped admin who creates a domain must automatically get access to it")
}
}
func TestScopedAdminCannotDelegateUnownedDomain(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"username": {"sub-admin"},
"password": {"sub-admin-password-1!"},
"domain_ids": {strconv.FormatInt(domainA.ID, 10), strconv.FormatInt(domainB.ID, 10)}, // B isn't theirs
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/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 (with an error flash), got %d", rec.Code)
}
if u, _ := app.DB.GetAdminUserByUsername("sub-admin"); u != nil {
t.Fatal("admin creation must be rejected outright when it tries to delegate a domain outside the creator's own scope")
}
}
func TestScopedAdminCanDelegateOwnedDomainAndManageResultingAdmin(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"username": {"sub-admin"},
"password": {"sub-admin-password-1!"},
"domain_ids": {strconv.FormatInt(domainA.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/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 delegating an owned domain, got %d: %s", rec.Code, rec.Body.String())
}
subAdmin, err := app.DB.GetAdminUserByUsername("sub-admin")
if err != nil || subAdmin == nil {
t.Fatalf("expected sub-admin to be created: %v", err)
}
// The delegating admin must be able to see and manage the new sub-admin, per the
// "any admin within scope, not just ones I personally created" rule.
listReq := httptest.NewRequest(http.MethodGet, Prefix+"/admins", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if !strings.Contains(listRec.Body.String(), "sub-admin") {
t.Fatal("delegating admin should see the newly created sub-admin in their admin list")
}
}
func TestGlobalAdminSeesEverything(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := loginSession(t, app) // global admin
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, domainA.DomainName) || !strings.Contains(body, domainB.DomainName) {
t.Fatal("global admin must see every domain regardless of scoped assignments")
}
}