release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes
[1.2.0] The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user. Added: - panel: **domain-admin role** — global administrators manage panel users and Fixed: - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the - panel: on signed-in pages with a narrow card (**Settings**, the user form) Changed: - panel: navigation session icons are distinct — **Settings** uses a gear, - panel: `/` redirects domain administrators to `/domains`; global users still - panel: **Sign out** in the navigation column uses the same type size and - panel: **visual style** brought in line with the SelfPost mark — brick accent - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown … (1 more in CHANGELOG) [1.2.1] Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration. Fixed: - panel: status badge text sat low in the box (and below the heading or label - panel: the **Users** navigation icon was two full silhouettes with staggered - panel: the **Settings** navigation icon was a sun-with-rays (circle plus - panel: the user create/edit form placed «Back to users» at the bottom of the Changed: - panel: the user edit form disables role change and delete for the only global - panel: the user create/edit form hides **Assigned domains** when the role is - panel: **Settings** shows panel credentials and DMARC aggregate reports side [1.2.2] Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration. Changed: - panel: **Status** is wide again so paired cards fill the column. Layout: Fixed: - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid - panel: on the user create/edit form, **Assigned domains** checkboxes stacked [1.2.3] Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration. Changed: - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC; - panel: Domains list — **Add domain** sits beside the domain field; the - panel: page URLs, browser titles, and headings are aligned — **Settings** is Fixed: - image: `mail.log` rotation no longer silently stops when the build context [1.2.4] Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration. Changed: - rate limiting (level 2): domain ceilings apply to every client IP (no IP Fixed: - panel: on the domain DNS status grid, the Type (TXT) field height matches [1.2.5] Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration. Changed: - panel: rate-limit forms refined — level-1 backstop as muted copy on the Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5]. Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
This commit is contained in:
@@ -29,79 +29,6 @@ func TestEveryPageResolvesNav(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The section index each long page shows in the navigation column works by
|
||||
// overriding an empty "sections" block defined in the layout, which only holds
|
||||
// as long as the layout is parsed before the page's own files (see pageFiles).
|
||||
// Reverse that order and every index would silently disappear — the empty
|
||||
// definition would win and no page would fail to render — so the two ends are
|
||||
// asserted here: the long pages produce a list, and a page that defines nothing
|
||||
// produces nothing at all.
|
||||
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// Anchors the index links to, taken from the page's own cards.
|
||||
wantAnchors := map[string]string{
|
||||
"status": `href="#certificate"`,
|
||||
"domain_detail": `href="#danger"`,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
// The domain page's index hides the freshly generated credential entry
|
||||
// unless one is on the page, so the data map carries the key it reads.
|
||||
if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
|
||||
t.Fatalf("execute sections for %q: %v", name, err)
|
||||
}
|
||||
out := buf.String()
|
||||
anchor, wanted := wantAnchors[name]
|
||||
switch {
|
||||
case wanted && !strings.Contains(out, anchor):
|
||||
t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
|
||||
case !wanted && strings.TrimSpace(out) != "":
|
||||
t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A section link that points at no card is a link that does nothing, and
|
||||
// nothing about rendering the page says so. Every anchor the index offers must
|
||||
// name an element the same page defines an id for.
|
||||
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// The pages that carry an index; both are checked with a credential shown,
|
||||
// which is the domain page's one conditional entry.
|
||||
for _, name := range []string{"status", "domain_detail"} {
|
||||
var index bytes.Buffer
|
||||
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
|
||||
t.Fatalf("execute sections for %q: %v", name, err)
|
||||
}
|
||||
// The cards are spread over the page's template files, so the ids are
|
||||
// collected from the files rather than from a rendered page — rendering
|
||||
// one would need the whole of a handler's data map.
|
||||
ids := map[string]bool{}
|
||||
for _, file := range pageFiles[name] {
|
||||
body, err := fs.ReadFile(assetsFS, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
// Cards only: a form field's id is not somewhere a section link may
|
||||
// land, so matching those too would weaken the check.
|
||||
for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
|
||||
ids[m[1]] = true
|
||||
}
|
||||
}
|
||||
for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
|
||||
if !ids[m[1]] {
|
||||
t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The version comes from render(), not from each handler's data map, so the
|
||||
// footer is only correct as long as every page composes with the layout and
|
||||
// render keeps supplying the key. Both are asserted here rather than trusted.
|
||||
@@ -207,8 +134,9 @@ func TestNavMarksActivePage(t *testing.T) {
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
@@ -234,8 +162,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
@@ -261,7 +190,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
|
||||
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
@@ -277,6 +206,101 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The domain page pairs cards the same way Status does: three .split rows
|
||||
// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
|
||||
// and Domain settings are full-width; DNS status and Domain settings (and the
|
||||
// application Edit panel) use .check-cols. Losing a row silently stacks again.
|
||||
func TestDomainDetailPageHasPairedCards(t *testing.T) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read domain_detail: %v", err)
|
||||
}
|
||||
src := string(body)
|
||||
if got := strings.Count(src, `class="split"`); got != 3 {
|
||||
t.Errorf("domain detail has %d .split rows, want 3", got)
|
||||
}
|
||||
if !strings.Contains(src, `class="check-cols"`) {
|
||||
t.Error("domain detail is missing the check-cols grid")
|
||||
}
|
||||
if !strings.Contains(src, `class="panel-toggle t-edit"`) {
|
||||
t.Error("application Edit should be a single panel-toggle")
|
||||
}
|
||||
if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) ||
|
||||
strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) {
|
||||
t.Error("application Edit mode and Rate limit should be one Edit button")
|
||||
}
|
||||
for _, id := range []string{
|
||||
`id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
|
||||
`id="connection"`, `id="add-application"`, `id="applications"`,
|
||||
`id="domain-settings"`, `id="export"`, `id="danger"`,
|
||||
} {
|
||||
if !strings.Contains(src, id) {
|
||||
t.Errorf("domain detail is missing %s", id)
|
||||
}
|
||||
}
|
||||
if strings.Contains(src, `id="rate-limit"`) {
|
||||
t.Error("domain rate limit should live inside domain-settings, not its own card")
|
||||
}
|
||||
if strings.Contains(src, `id="d_ips"`) {
|
||||
t.Error("domain rate limit must not ask for client IPs")
|
||||
}
|
||||
if !strings.Contains(src, "{{.L1Messages}}") && !strings.Contains(src, "{{$.L1Messages}}") {
|
||||
t.Error("domain rate limit should show the L1 message count")
|
||||
}
|
||||
if !strings.Contains(src, "Level 1 backstop") {
|
||||
t.Error("domain rate limit should show a Level 1 backstop line")
|
||||
}
|
||||
if !strings.Contains(src, "Trusted client IPs") {
|
||||
t.Error("application override should ask for trusted client IPs")
|
||||
}
|
||||
if strings.Contains(src, `id="spf-dmarc"`) {
|
||||
t.Error("SPF should sit with DKIM, not with DMARC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/settings.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
src := string(body)
|
||||
if !strings.Contains(src, `id="rate-limits"`) {
|
||||
t.Error("settings should include a sending rate limits card")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"Level 2 — domain",
|
||||
"trusted IPs",
|
||||
"{{.L1Messages}} messages / {{.L1Window}} seconds",
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("settings rate limits card missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if !drillDown[name] {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(body, `template "back_link"`) {
|
||||
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
|
||||
}
|
||||
backIdx := strings.Index(body, `template "back_link"`)
|
||||
cardIdx := strings.Index(body, `class="card`)
|
||||
if cardIdx >= 0 && backIdx > cardIdx {
|
||||
t.Errorf("%s places the back link after the first card", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Since the panel root redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one.
|
||||
@@ -351,17 +375,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
// Three .split rows inside the polled fragment: machine|processes,
|
||||
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
||||
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
||||
`action="/status/recheck"`,
|
||||
// The machine card: the bars carry their reading in an attribute
|
||||
// (the CSP rules out sizing them with a style), and the figures are
|
||||
// printed beside them for anything that does not render a meter.
|
||||
`<meter value="12"`, `<meter value="50"`,
|
||||
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB",
|
||||
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
|
||||
"eth0: 1.0 MiB in, 512.0 KiB out",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(out, `class="split"`); got != 3 {
|
||||
t.Errorf("status page has %d .split rows, want 3", got)
|
||||
}
|
||||
// Hostname must live inside the fragment so a poll refresh keeps it beside
|
||||
// sockets; Configuration stays outside (static reload control).
|
||||
body := strings.Index(out, `id="status-body"`)
|
||||
conf := strings.Index(out, `id="configuration"`)
|
||||
if body < 0 || conf < 0 || conf < body {
|
||||
t.Fatal("status-body or configuration card missing or out of order")
|
||||
}
|
||||
frag := out[body:conf]
|
||||
if !strings.Contains(frag, `id="hostname"`) {
|
||||
t.Error("hostname card is outside the polled status-body fragment")
|
||||
}
|
||||
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/reload"`) {
|
||||
t.Error("configuration reload must stay outside the polled fragment")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read — no /proc, or a first reading
|
||||
@@ -425,13 +470,13 @@ func statusPageData() map[string]any {
|
||||
},
|
||||
"Machine": health.Machine{
|
||||
CPU: health.CPU{
|
||||
Measured: true, BusyPct: 12.4, Cores: 4,
|
||||
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
|
||||
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
||||
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19",
|
||||
Status: health.StatusOK, Detail: "4 cores · 4 threads",
|
||||
},
|
||||
Memory: health.Memory{
|
||||
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.",
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
|
||||
},
|
||||
Network: health.Network{
|
||||
Measured: true, RxRate: 2048, TxRate: 1024,
|
||||
@@ -444,7 +489,7 @@ func statusPageData() map[string]any {
|
||||
Status: health.StatusOK,
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
},
|
||||
"SocketStatus": health.StatusOK,
|
||||
"OverallStatus": health.StatusError,
|
||||
|
||||
Reference in New Issue
Block a user