Stage 2, step 3. Written by Sonnet, reviewed by Opus.
- Six pages named after their mockups: out-domains, out-domain,
out-domain-settings, out-app, out-app-created, out-domain-delete,
with typed page data (view/outbound.go, outbound_app.go), fixtures
and tests. The engine pages dashboard, domain_detail and
domain_delete are gone with their templates and are off
legacy_pages.txt. GET .../settings has its own handler.
- Handlers pass typed data; routes, POST targets, field names,
validation, authorization (both roles, another tenant's id is a 404)
and Cache-Control: no-store on the password page are unchanged.
- Kit, attribute only: Action.Confirm writes data-confirm on a POST
action's form (panel.js already reads it).
- test/e2e/panel_client.go scrapes the new markup (copy fields, the
application row's Edit link). Not run: no Docker here.
Fixes found on the way
- Report address of a domain that follows another user's default: the
select had one "inherit" option, selected, so saving the form
untouched moved the domain to whoever saved it. It now offers that
user's default as "keep" (selected, saves nothing) and "My default
instead" as the change, as the mockup draws it; "keep" on a domain
that does not follow someone else is refused.
- panel.js: syncShowWhen stopped at the first matching rule, so the
auto-limit fields were never toggled with the limit mode. Every
matching rule applies now.
Differences from the mockups, for the owner:
- application rows carry Edit only. New password and Delete are POSTs,
and the kit has no link-styled POST control for a table row; both are
in the application page's head with a confirmation. Drawing them in
the row needs a design: change (as Sign out was);
- the application login is read-only on edit (the handler refuses a
rename); the submit says Create application on the empty form;
- multiplier bounds are the store's 1.5-5, default 2.5;
- "Recalculate" buttons exist where the stored limit is automatic;
- a domain administrator has no add box and no Delete; Delete domain is
global-only on the domain page and on settings;
- a fourth record, Report authorization, appears when the report
address is on another domain; record states missing / weak / not
checked are tags with the explanation under the value;
- refusals are the kit flash above the first box, and those of the
settings forms now show on the settings page; successful saves still
redirect to the domain page;
- the export checkbox is ticked by default, as drawn.
Evidence
- docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
out-domain-settings.png, out-app.png, out-app-created.png,
out-domain-delete.png: 1440 px wide, headless Edge, real templates
served locally with their fixtures - NOT from the container (no
Docker on this machine). Taken before the report-address fix, which
does not change what the fixture renders.
- panel/audit.js on the six pages at 1440 and 1280 (text inside
[hidden] blocks skipped): 866 texts checked, problems: [].
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
18 KiB
Go
439 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mixeme/selfpost/internal/app"
|
|
"github.com/mixeme/selfpost/internal/domain"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/auth"
|
|
)
|
|
|
|
// userWith creates a panel user with the given role and reach and returns the
|
|
// principal the auth middleware would attach for them — read back from the
|
|
// store, so the test exercises the same mapping a real request gets.
|
|
func userWith(t *testing.T, st *store.Store, username string, role store.Role, reach store.Reach) auth.Principal {
|
|
t.Helper()
|
|
id, err := st.CreateUser(username, "test-hash", role, reach)
|
|
if err != nil {
|
|
t.Fatalf("create user %s: %v", username, err)
|
|
}
|
|
u, err := st.GetUser(id)
|
|
if err != nil {
|
|
t.Fatalf("get user %s: %v", username, err)
|
|
}
|
|
return auth.Principal{
|
|
ID: u.ID, Username: u.Username, Role: u.Role,
|
|
Domains: u.DomainIDs, AllDomains: u.AllDomains,
|
|
InboundDomains: u.InboundDomainIDs, AllInbound: u.AllInboundDomains,
|
|
}
|
|
}
|
|
|
|
func idStr(id int64) string { return strconv.FormatInt(id, 10) }
|
|
|
|
// send runs one request against a handler as p (nil: no principal at all).
|
|
func send(h http.HandlerFunc, p *auth.Principal, method, target string, pathValues map[string]string, form url.Values) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if form != nil {
|
|
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
for k, v := range pathValues {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
if p != nil {
|
|
req = auth.RequestWithPrincipal(req, *p)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// inboundRoute is one inbound handler taking an {id}.
|
|
type inboundRoute struct {
|
|
method, suffix string
|
|
handler func(*Handlers) http.HandlerFunc
|
|
form url.Values
|
|
}
|
|
|
|
// The pages and actions of ONE inbound domain: open to the global role and to a
|
|
// domain administrator this inbound domain is assigned to.
|
|
var inboundDomainRoutes = []inboundRoute{
|
|
{"GET", "", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDetail }, nil},
|
|
{"POST", "/upstream", func(h *Handlers) http.HandlerFunc { return h.HandleInboundTransport },
|
|
url.Values{"host": {"mx.internal.example"}, "port": {"25"}, "tls_mode": {"may"}}},
|
|
{"POST", "/recipients", func(h *Handlers) http.HandlerFunc { return h.HandleInboundRecipients },
|
|
url.Values{"recipient_mode": {"any"}}},
|
|
{"POST", "/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDNSRecheck }, nil},
|
|
}
|
|
|
|
// Adding and deleting an inbound domain: the global role only, whatever a
|
|
// domain administrator is assigned.
|
|
var inboundGlobalRoutes = []inboundRoute{
|
|
{"GET", "/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, nil},
|
|
{"POST", "/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, nil},
|
|
}
|
|
|
|
type inboundStand struct {
|
|
h *Handlers
|
|
a, b store.InboundDomain
|
|
global, ownsA, allIn auth.Principal
|
|
outboundOnly, sameNameAs auth.Principal
|
|
}
|
|
|
|
// newInboundStand is a panel with two inbound domains and one user of each kind
|
|
// the delegation has to tell apart.
|
|
func newInboundStand(t *testing.T) inboundStand {
|
|
t.Helper()
|
|
h, st := inboundHandlers(t)
|
|
s := inboundStand{h: h}
|
|
var err error
|
|
if s.a, err = st.AddInboundDomain("a.example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.b, err = st.AddInboundDomain("b.example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A SENDING domain with the same name as inbound domain B, at an id that
|
|
// collides with inbound A's: outbound and inbound are separate entities,
|
|
// and neither the name nor the number may leak access across.
|
|
out, err := st.AddDomain("b.example.com", "mail")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.ID != s.a.ID {
|
|
t.Fatalf("the stand expects the outbound domain's id (%d) to collide with inbound A's (%d)", out.ID, s.a.ID)
|
|
}
|
|
s.global = userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
s.ownsA = userWith(t, st, "owns-a", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{s.a.ID}})
|
|
s.allIn = userWith(t, st, "all-inbound", store.RoleDomain, store.Reach{AllInbound: true})
|
|
s.outboundOnly = userWith(t, st, "outbound-only", store.RoleDomain, store.Reach{AllDomains: true})
|
|
s.sameNameAs = userWith(t, st, "same-name", store.RoleDomain, store.Reach{DomainIDs: []int64{out.ID}})
|
|
return s
|
|
}
|
|
|
|
func (s inboundStand) call(rt inboundRoute, p *auth.Principal, id int64) *httptest.ResponseRecorder {
|
|
return send(rt.handler(s.h), p, rt.method, "/inbound/domains/"+idStr(id)+rt.suffix, map[string]string{"id": idStr(id)}, rt.form)
|
|
}
|
|
|
|
// Every route of one inbound domain, for every kind of user: 404 for another
|
|
// tenant's domain, for a domain that does not exist, for a user with no inbound
|
|
// reach, for an outbound assignment of the same id or name, and with no
|
|
// principal — and the same answer in each case, so nothing tells them apart.
|
|
func TestInboundDomainRoutesAreDelegatedPerDomain(t *testing.T) {
|
|
s := newInboundStand(t)
|
|
const missing = 9999
|
|
for _, rt := range inboundDomainRoutes {
|
|
name := rt.method + " /inbound/domains/{id}" + rt.suffix
|
|
denied := []struct {
|
|
who string
|
|
p *auth.Principal
|
|
id int64
|
|
}{
|
|
{"a domain administrator of inbound A, on B", &s.ownsA, s.b.ID},
|
|
{"a domain administrator of inbound A, on a missing id", &s.ownsA, missing},
|
|
{"a domain administrator with outbound reach only", &s.outboundOnly, s.a.ID},
|
|
{"a domain administrator of the outbound domain with the same id", &s.sameNameAs, s.a.ID},
|
|
{"a domain administrator of the outbound domain with the same name", &s.sameNameAs, s.b.ID},
|
|
{"no principal", nil, s.a.ID},
|
|
}
|
|
var bodies []string
|
|
for _, c := range denied {
|
|
rec := s.call(rt, c.p, c.id)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s as %s = %d, want 404:\n%s", name, c.who, rec.Code, rec.Body.String())
|
|
}
|
|
bodies = append(bodies, rec.Body.String())
|
|
}
|
|
for _, b := range bodies[1:] {
|
|
if b != bodies[0] {
|
|
t.Errorf("%s: the 404 for another tenant's domain differs from the 404 for a missing one — the panel confirms what exists", name)
|
|
break
|
|
}
|
|
}
|
|
|
|
// The DNS re-check resolves real names, so its allowed side is not
|
|
// run here; its guard is the same call as the three above.
|
|
if rt.suffix == "/dns-recheck" {
|
|
continue
|
|
}
|
|
for _, c := range []struct {
|
|
who string
|
|
p *auth.Principal
|
|
id int64
|
|
}{
|
|
{"the global role", &s.global, s.b.ID},
|
|
{"its domain administrator", &s.ownsA, s.a.ID},
|
|
{"a domain administrator with All inbound", &s.allIn, s.b.ID},
|
|
} {
|
|
rec := s.call(rt, c.p, c.id)
|
|
if rec.Code != http.StatusOK && rec.Code != http.StatusSeeOther {
|
|
t.Errorf("%s as %s = %d, want it to go through:\n%s", name, c.who, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
|
|
s := newInboundStand(t)
|
|
for _, p := range []*auth.Principal{&s.ownsA, &s.allIn, &s.outboundOnly, nil} {
|
|
for _, rt := range inboundGlobalRoutes {
|
|
if rec := s.call(rt, p, s.a.ID); rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s /inbound/domains/{id}%s as %+v = %d, want 404", rt.method, rt.suffix, p, rec.Code)
|
|
}
|
|
}
|
|
rec := send(s.h.HandleAddInbound, p, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}})
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("POST /inbound/domains as %+v = %d, want 404", p, rec.Code)
|
|
}
|
|
}
|
|
list, err := s.h.store.ListInboundDomains()
|
|
if err != nil || len(list) != 2 {
|
|
t.Fatalf("after the refused requests there are %d inbound domains (%v), want the original 2", len(list), err)
|
|
}
|
|
|
|
// The global role still can.
|
|
if rec := send(s.h.HandleAddInbound, &s.global, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}}); rec.Code != http.StatusSeeOther {
|
|
t.Errorf("POST /inbound/domains as the global role = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := s.call(inboundGlobalRoutes[1], &s.global, s.a.ID); rec.Code != http.StatusSeeOther {
|
|
t.Errorf("POST /inbound/domains/{id}/delete as the global role = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// The list is filtered in the query: a domain administrator sees the inbound
|
|
// domains assigned to them and nothing that lets them add or delete one.
|
|
func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) {
|
|
s := newInboundStand(t)
|
|
list := func(p *auth.Principal) *httptest.ResponseRecorder {
|
|
return send(s.h.HandleInboundList, p, "GET", "/inbound/domains", nil, nil)
|
|
}
|
|
|
|
rec := list(&s.ownsA)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list as the administrator of A = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "a.example.com") || strings.Contains(body, "b.example.com") {
|
|
t.Errorf("the administrator of A must see A and not B:\n%s", body)
|
|
}
|
|
for _, globalOnly := range []string{"Add inbound domain", "/delete"} {
|
|
if strings.Contains(body, globalOnly) {
|
|
t.Errorf("the list offers a domain administrator %q", globalOnly)
|
|
}
|
|
}
|
|
|
|
// All covers a domain added after the user was given it.
|
|
if _, err := s.h.store.AddInboundDomain("later.example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body = list(&s.allIn).Body.String()
|
|
for _, want := range []string{"a.example.com", "b.example.com", "later.example.com"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("All inbound does not list %s", want)
|
|
}
|
|
}
|
|
if strings.Contains(list(&s.ownsA).Body.String(), "later.example.com") {
|
|
t.Error("a domain added later appeared for a user who was assigned A only")
|
|
}
|
|
|
|
for who, p := range map[string]*auth.Principal{"outbound reach only": &s.outboundOnly, "no principal": nil} {
|
|
if rec := list(p); rec.Code != http.StatusNotFound {
|
|
t.Errorf("list with %s = %d, want 404", who, rec.Code)
|
|
}
|
|
}
|
|
if body := list(&s.global).Body.String(); !strings.Contains(body, "Add inbound domain") || !strings.Contains(body, "b.example.com") {
|
|
t.Errorf("the global role lost the full list or the add form:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// Losing the last assigned inbound domain (it was deleted) takes the group away
|
|
// as a whole: the list answers 404, like for a user who never had inbound.
|
|
func TestInboundListWithNothingLeftIs404(t *testing.T) {
|
|
s := newInboundStand(t)
|
|
if err := s.h.store.DeleteInboundDomain(s.a.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := s.h.store.GetUser(s.ownsA.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, InboundDomains: u.InboundDomainIDs}
|
|
if rec := send(s.h.HandleInboundList, &p, "GET", "/inbound/domains", nil, nil); rec.Code != http.StatusNotFound {
|
|
t.Errorf("list after the only assigned inbound domain was deleted = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
// The outbound side of the same rule, route by route: a domain administrator
|
|
// asking for another tenant's sending domain or application by id gets 404 —
|
|
// also when the application is addressed through the caller's own domain —
|
|
// and so does a user whose reach is inbound only.
|
|
func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
|
|
h, domains := serverWithTwoDomains(t)
|
|
h.domains = domain.NewService(h.store, nil, nil, "mail")
|
|
h.apps = app.NewService(h.store, nil, nil)
|
|
first, second := domains["first.example.ru"], domains["second.example.ru"]
|
|
foreignApp, err := h.store.GetApplicationByLogin("second-app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in, err := h.store.AddInboundDomain("second.example.ru")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ownsFirst := userWith(t, h.store, "owns-first", store.RoleDomain, store.Reach{DomainIDs: []int64{first.ID}})
|
|
inboundOnly := userWith(t, h.store, "inbound-only", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{in.ID}})
|
|
|
|
did := map[string]string{"id": idStr(second.ID)}
|
|
// Another tenant's application, addressed through its own domain — and
|
|
// through the caller's own domain, which it is not in: the URL names a
|
|
// domain the caller may open, but the application still is not theirs.
|
|
aid := map[string]string{"id": idStr(second.ID), "aid": idStr(foreignApp.ID)}
|
|
smuggled := map[string]string{"id": idStr(first.ID), "aid": idStr(foreignApp.ID)}
|
|
type outRoute struct {
|
|
method, target string
|
|
handler http.HandlerFunc
|
|
pathValues map[string]string
|
|
}
|
|
routes := []outRoute{
|
|
{"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
|
|
{"GET", "/outbound/domains/{id}/settings", h.HandleDomainSettings, did},
|
|
{"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
|
|
{"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
|
|
{"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
|
|
{"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did},
|
|
{"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did},
|
|
{"GET", "/outbound/domains/{id}/applications/new", h.HandleApplicationNew, did},
|
|
{"POST", "/outbound/domains/{id}/applications/new", h.HandleApplicationCreate, did},
|
|
}
|
|
for _, pv := range []map[string]string{aid, smuggled} {
|
|
routes = append(routes,
|
|
outRoute{"GET", "/outbound/domains/{id}/applications/{aid}", h.HandleApplicationEdit, pv},
|
|
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}", h.HandleApplicationSave, pv},
|
|
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/password", h.HandleRegenPassword, pv},
|
|
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc, pv},
|
|
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/delete", h.HandleDeleteApplication, pv},
|
|
)
|
|
}
|
|
for _, rt := range routes {
|
|
for who, p := range map[string]*auth.Principal{
|
|
"the administrator of another sending domain": &ownsFirst,
|
|
"an inbound-only administrator of the same name": &inboundOnly,
|
|
"no principal": nil,
|
|
} {
|
|
rec := send(rt.handler, p, rt.method, rt.target, rt.pathValues, url.Values{})
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s %s on another tenant's id as %s = %d, want 404:\n%s", rt.method, rt.target, who, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing was changed by the refused requests.
|
|
if _, err := h.store.GetApplicationByLogin("second-app"); err != nil {
|
|
t.Errorf("the foreign application did not survive the refused requests: %v", err)
|
|
}
|
|
apps, err := h.store.ListApplicationsByDomain(second.ID)
|
|
if err != nil || len(apps) != 1 {
|
|
t.Errorf("the foreign domain has %d applications after the refused requests (%v), want 1", len(apps), err)
|
|
}
|
|
}
|
|
|
|
// The user form carries two lists. A domain administrator needs something on
|
|
// at least one of them; All wins over the rows under it; promoting to the
|
|
// global role drops the reach.
|
|
func TestUserFormAssignsBothDirections(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
out, _ := st.AddDomain("out.example.com", "mail")
|
|
in, _ := st.AddInboundDomain("in.example.com")
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
const password = "a-long-enough-password-1"
|
|
|
|
create := func(username string, extra url.Values) (*httptest.ResponseRecorder, store.User) {
|
|
t.Helper()
|
|
form := url.Values{"username": {username}, "password": {password}, "role": {"domain"}}
|
|
for k, v := range extra {
|
|
form[k] = v
|
|
}
|
|
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form)
|
|
u, _ := st.GetUserByUsername(username)
|
|
return rec, u
|
|
}
|
|
|
|
rec, u := create("nothing", nil)
|
|
if rec.Code != http.StatusBadRequest || u.ID != 0 {
|
|
t.Errorf("a domain administrator with nothing assigned = %d, user created: %v", rec.Code, u.ID != 0)
|
|
}
|
|
|
|
rec, u = create("inbound-only", url.Values{"inbound_domain_ids": {idStr(in.ID)}})
|
|
if rec.Code != http.StatusSeeOther || len(u.InboundDomainIDs) != 1 || len(u.DomainIDs) != 0 || u.AllDomains || u.AllInboundDomains {
|
|
t.Errorf("inbound only = %d, %+v:\n%s", rec.Code, u, rec.Body.String())
|
|
}
|
|
|
|
// All ticked together with rows: the rows are ignored, not stored.
|
|
rec, u = create("all-out", url.Values{"all_domains": {"1"}, "domain_ids": {idStr(out.ID)}})
|
|
if rec.Code != http.StatusSeeOther || !u.AllDomains || len(u.DomainIDs) != 0 {
|
|
t.Errorf("All outbound = %d, %+v", rec.Code, u)
|
|
}
|
|
|
|
update := func(u store.User, form url.Values) store.User {
|
|
t.Helper()
|
|
form.Set("username", u.Username)
|
|
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String())
|
|
}
|
|
got, err := st.GetUser(u.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// Both lists at once; then unticking All brings no stale row back.
|
|
u = update(u, url.Values{"role": {"domain"}, "domain_ids": {idStr(out.ID)}, "all_inbound_domains": {"1"}})
|
|
if u.AllDomains || len(u.DomainIDs) != 1 || !u.AllInboundDomains || len(u.InboundDomainIDs) != 0 {
|
|
t.Errorf("after the update: %+v", u)
|
|
}
|
|
|
|
// With inbound switched off the form has no inbound list; saving it must
|
|
// not wipe what the user had there.
|
|
h.cfg.InboundEnabled = false
|
|
u = update(u, url.Values{"role": {"domain"}, "domain_ids": {idStr(out.ID)}})
|
|
if !u.AllInboundDomains {
|
|
t.Errorf("saving the form with inbound off dropped the user's inbound reach: %+v", u)
|
|
}
|
|
h.cfg.InboundEnabled = true
|
|
|
|
// Emptying both lists is refused and changes nothing.
|
|
rec = send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
|
url.Values{"username": {u.Username}, "role": {"domain"}})
|
|
if after, _ := st.GetUser(u.ID); rec.Code != http.StatusBadRequest || after.Reach().Empty() {
|
|
t.Errorf("emptying both lists = %d, reach now %+v", rec.Code, after.Reach())
|
|
}
|
|
|
|
// Promotion drops the reach, so a later demotion starts from nothing.
|
|
u = update(u, url.Values{"role": {"global"}})
|
|
if u.Role != store.RoleGlobal || !u.Reach().Empty() {
|
|
t.Errorf("after promotion: role %s, reach %+v", u.Role, u.Reach())
|
|
}
|
|
|
|
// The form shows both lists with what is ticked.
|
|
other, _ := st.GetUserByUsername("inbound-only")
|
|
body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
|
|
for _, want := range []string{
|
|
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
|
|
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the user form is missing %q", want)
|
|
}
|
|
}
|
|
}
|