A domain user's log, the message page, a domain's statistics, the level-2 count and the notifier's refusal count picked send-log rows by domain name, so a domain deleted and re-added under its old name showed its earlier mail to the next assignee and went on counting it. Migration 0008 (not run on the live database yet, so extended here): send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ... ON DELETE SET NULL, filled once by name from the registry as it is at migration time, each with an (id, created_at) index. - Writes: InsertQueued / InsertRejected set domain_id to the domain of the row's login (loginDomainID) - the journal milter and the sender check's refusal rows both go through them; the inbound journal sets inbound_domain_id from the inbound domain's name at insert. - Reads: SendLogFilter.DomainIDs (was Domains, by name) and InboundSpamFilter.DomainIDs are the scope; the global role still reads everything. The message page checks row.DomainID against the user's domains. DomainSendStats and CountMessages keep their name argument and select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier joins send_log.domain_id to the domain and its application. - DMARC reports stay by name on purpose (a report is about the DNS name). Tests: store - scope by ids; a re-added domain's log, level-2 count and statistics hold its own row only, the global log both; inbound scope by ids. Handlers - the new assignee gets 404 for the old row's page and does not see it in the log, the global role sees both. Tests that wrote rows for a login that is no application now register it (a row is a domain's through its application). Schema at 0008 with the two columns and indexes. schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked, roadmap 19/62. Evidence: go vet ./... clean; go test ./... ok locally but for the two Windows-only tests; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
6.7 KiB
Go
153 lines
6.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/auth"
|
|
)
|
|
|
|
// The users form's writes, driven through the handlers (audit 2026-10-12, M7
|
|
// and M9: the save is one store call now, and these pin what it does).
|
|
|
|
// A save that changes name, e-mail and role lands whole; promoted to global
|
|
// the user keeps no assignment.
|
|
func TestUserFormSavesTheWholeChange(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
out1, _ := st.AddDomain("out.example.com", "mail")
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}})
|
|
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
|
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"global"}})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
after, err := st.GetUser(u.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.Username != "shop2" || after.Email != "shop@example.com" || after.Role != store.RoleGlobal || !after.Reach().Empty() {
|
|
t.Fatalf("after the save: %+v", after)
|
|
}
|
|
|
|
// And back to a domain user, with an assignment.
|
|
rec = send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
|
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
after, _ = st.GetUser(u.ID)
|
|
if after.Role != store.RoleDomain || len(after.DomainIDs) != 1 || after.DomainIDs[0] != out1.ID {
|
|
t.Fatalf("demoted: %+v", after)
|
|
}
|
|
}
|
|
|
|
// The last global administrator is not demoted, and a refused save changes
|
|
// nothing - not the name typed in the same form either.
|
|
func TestUserFormKeepsTheLastGlobal(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
out1, _ := st.AddDomain("out.example.com", "mail")
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(root.ID), map[string]string{"uid": idStr(root.ID)},
|
|
url.Values{"username": {"renamed"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "You cannot demote yourself without another global administrator.") {
|
|
t.Fatalf("demoting the last global = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
after, _ := st.GetUser(root.ID)
|
|
if after.Username != "root" || after.Role != store.RoleGlobal {
|
|
t.Fatalf("the refused save changed the user: %+v", after)
|
|
}
|
|
}
|
|
|
|
// A domain user must keep something assigned; the refusal changes nothing.
|
|
func TestUserFormRefusesADomainUserWithNothingAssigned(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
out1, _ := st.AddDomain("out.example.com", "mail")
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}})
|
|
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
|
url.Values{"username": {"renamed"}, "role": {"domain"}})
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Assign at least one outbound or inbound domain") {
|
|
t.Fatalf("nothing assigned = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
after, _ := st.GetUser(u.ID)
|
|
if after.Username != "shop" || len(after.DomainIDs) != 1 {
|
|
t.Fatalf("the refused save changed the user: %+v", after)
|
|
}
|
|
}
|
|
|
|
// A user is created with the e-mail and the assignment in the one save.
|
|
func TestUserFormCreatesWithEmailAndReach(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
out1, _ := st.AddDomain("out.example.com", "mail")
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil,
|
|
url.Values{"username": {"fresh"}, "password": {"a-long-enough-password"}, "email": {"fresh@example.com"},
|
|
"role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
|
|
}
|
|
u, err := st.GetUserByUsername("fresh")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.Email != "fresh@example.com" || u.Role != store.RoleDomain || len(u.DomainIDs) != 1 {
|
|
t.Fatalf("created %+v", u)
|
|
}
|
|
}
|
|
|
|
// A domain deleted and added again under its old name starts with an empty
|
|
// log for whoever it is assigned to; its earlier rows are the global role's
|
|
// alone (audit 2026-10-12, L12).
|
|
func TestADomainAddedAgainShowsItsAssigneeNothingOld(t *testing.T) {
|
|
h, st := inboundHandlers(t)
|
|
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
|
old, _ := st.AddDomain("shop.example", "mail")
|
|
st.AddApplication(old.ID, "shop", store.AddressModeWildcard, nil)
|
|
if err := st.InsertQueued(store.SendLogEntry{QueueID: "OLDQ", Domain: "shop.example", AppLogin: "shop", To: "a@example.net", Subject: "old mail"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteDomain(old.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, _ := st.AddDomain("shop.example", "mail")
|
|
st.AddApplication(again.ID, "shop", store.AddressModeWildcard, nil)
|
|
if err := st.InsertQueued(store.SendLogEntry{QueueID: "NEWQ", Domain: "shop.example", AppLogin: "shop", To: "a@example.net", Subject: "new mail"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owner := userWith(t, st, "owner", store.RoleDomain, store.Reach{DomainIDs: []int64{again.ID}})
|
|
rows, _ := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 10, 0)
|
|
var oldID, newID int64
|
|
for _, r := range rows {
|
|
if r.QueueID == "OLDQ" {
|
|
oldID = r.ID
|
|
} else {
|
|
newID = r.ID
|
|
}
|
|
}
|
|
|
|
page := func(p *auth.Principal, id int64) int {
|
|
return send(h.HandleDelivery, p, "GET", "/outbound/log/"+idStr(id), map[string]string{"id": idStr(id)}, nil).Code
|
|
}
|
|
if code := page(&owner, oldID); code != http.StatusNotFound {
|
|
t.Errorf("the old row's page for the new assignee = %d, want 404", code)
|
|
}
|
|
if code := page(&owner, newID); code != http.StatusOK {
|
|
t.Errorf("the new row's page for its assignee = %d, want 200", code)
|
|
}
|
|
if code := page(&root, oldID); code != http.StatusOK {
|
|
t.Errorf("the old row's page for the global role = %d, want 200", code)
|
|
}
|
|
body := send(h.HandleDeliveries, &owner, "GET", "/outbound/log", nil, nil).Body.String()
|
|
if strings.Contains(body, "old mail") || !strings.Contains(body, "new mail") {
|
|
t.Errorf("the new assignee's log shows the old row or misses the new one")
|
|
}
|
|
body = send(h.HandleDeliveries, &root, "GET", "/outbound/log", nil, nil).Body.String()
|
|
if !strings.Contains(body, "old mail") || !strings.Contains(body, "new mail") {
|
|
t.Errorf("the global log is missing a row")
|
|
}
|
|
}
|