270 lines
9.2 KiB
Go
270 lines
9.2 KiB
Go
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")
|
|
}
|
|
}
|