password-reset, agreed 2026-10-13 ("the e-mail must be confirmed") and
settled 2026-10-17 ("Без подтверждения слать": notifications go to an
unconfirmed address, a reset link only to a confirmed one); mockups
2f5f5b2.
Public, as /setup/ is: /login/reset (the form that asks for a link),
/login/reset/<token> (the new password from the link), /confirm/<token>
(the address confirmed from its link) - each link is opened from a mail,
signed in or not. Behind a session: POST /account/email/confirm, Send the
link again on Account.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
8.9 KiB
Go
221 lines
8.9 KiB
Go
package web
|
|
|
|
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
|
|
// together with internal/web, and never to make an implementation pass.
|
|
//
|
|
// The route table of panel-redesign.md § Routes as a program: a URL reads like
|
|
// the menu, the pre-2.0 paths are gone without redirects, and no template
|
|
// points anywhere the router does not answer.
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The 2.0 routes behind a session, pattern for pattern as registered.
|
|
var routes = struct{ always, inbound, dmarc []string }{
|
|
always: []string{
|
|
"GET /{$}",
|
|
"GET /overview", "GET /overview/fragment",
|
|
"GET /server/health", "GET /server/health/fragment",
|
|
"POST /server/health/recheck", "POST /server/health/reload", "POST /server/health/test-message",
|
|
"GET /outbound/domains", "POST /outbound/domains",
|
|
"GET /outbound/domains/{id}", "POST /outbound/domains/{id}/dns-recheck",
|
|
"GET /outbound/domains/{id}/settings",
|
|
"POST /outbound/domains/{id}/settings/reports",
|
|
"POST /outbound/domains/{id}/settings/ratelimit",
|
|
"POST /outbound/domains/{id}/settings/ratelimit/recalc",
|
|
"POST /outbound/domains/{id}/settings/export",
|
|
"GET /outbound/domains/{id}/delete", "POST /outbound/domains/{id}/delete",
|
|
"GET /outbound/domains/{id}/applications/new", "POST /outbound/domains/{id}/applications/new",
|
|
"GET /outbound/domains/{id}/applications/{aid}", "POST /outbound/domains/{id}/applications/{aid}",
|
|
"POST /outbound/domains/{id}/applications/{aid}/password",
|
|
"POST /outbound/domains/{id}/applications/{aid}/ratelimit/recalc",
|
|
"POST /outbound/domains/{id}/applications/{aid}/delete",
|
|
"GET /outbound/log", "GET /outbound/log/fragment", "GET /outbound/log/{id}",
|
|
"GET /outbound/queue", "GET /outbound/queue/fragment",
|
|
"GET /server/log", "GET /server/log/fragment",
|
|
"GET /server/backup", "POST /server/backup", "POST /server/backup/import",
|
|
"GET /server/users",
|
|
"GET /server/users/new", "POST /server/users/new",
|
|
"GET /server/users/{uid}", "POST /server/users/{uid}",
|
|
"GET /server/users/{uid}/delete", "POST /server/users/{uid}/delete",
|
|
"GET /server/settings", "POST /server/settings", "POST /server/settings/dns-recheck",
|
|
"GET /server/components",
|
|
"GET /account", "POST /account/profile", "POST /account/password", "POST /account/email/confirm",
|
|
"GET /help",
|
|
},
|
|
inbound: []string{
|
|
"GET /inbound/domains", "POST /inbound/domains",
|
|
"GET /inbound/domains/{id}",
|
|
"POST /inbound/domains/{id}/dns-recheck",
|
|
"POST /inbound/domains/{id}/upstream",
|
|
"POST /inbound/domains/{id}/recipients",
|
|
"GET /inbound/domains/{id}/delete", "POST /inbound/domains/{id}/delete",
|
|
"GET /inbound/log",
|
|
"GET /inbound/filter-lists", "POST /inbound/filter-lists",
|
|
"POST /inbound/filter-lists/{id}/delete",
|
|
},
|
|
dmarc: []string{
|
|
"GET /outbound/dmarc", "GET /outbound/dmarc/domains/{id}", "GET /outbound/dmarc/reports/{id}",
|
|
},
|
|
}
|
|
|
|
// Requests the pre-2.0 panel answered. None of them is answered now — not by a
|
|
// page and not by a redirect (§ No compatibility).
|
|
var oldRequests = []string{
|
|
"GET /status", "GET /status/fragment", "POST /status/recheck", "POST /reload",
|
|
"GET /domains", "POST /domains", "POST /domains/import",
|
|
"GET /domains/7", "POST /domains/7/dns-recheck", "GET /domains/7/delete", "POST /domains/7/delete",
|
|
"POST /domains/7/applications", "POST /domains/7/ratelimit", "POST /domains/7/ratelimit/recalc",
|
|
"POST /domains/7/dmarc", "POST /domains/7/export",
|
|
"POST /applications/7/mode", "POST /applications/7/authips", "POST /applications/7/password",
|
|
"POST /applications/7/ratelimit", "POST /applications/7/ratelimit/recalc", "POST /applications/7/delete",
|
|
"GET /inbound", "POST /inbound", "GET /inbound/7", "POST /inbound/7/dns-recheck",
|
|
"POST /inbound/7/upstream", "POST /inbound/7/recipients", "GET /inbound/7/delete", "POST /inbound/7/delete",
|
|
"GET /dmarc", "GET /dmarc/reports/7", "GET /dmarc/domains/7",
|
|
"GET /settings", "POST /settings", "POST /account",
|
|
"GET /users", "GET /users/new", "POST /users/new", "GET /users/7", "POST /users/7",
|
|
"GET /users/7/delete", "POST /users/7/delete",
|
|
"GET /backup", "POST /backup",
|
|
"GET /deliveries", "GET /deliveries/rows", "GET /deliveries/7",
|
|
"GET /mail-queue", "GET /mail-queue/body", "GET /system-log", "GET /system-log/body",
|
|
}
|
|
|
|
// routesStillOld reports whether view/legacy_pages.txt lists @routes: the
|
|
// pre-2.0 table is in place and stage 1 has not renamed it yet. The entry can
|
|
// only be removed (.github/scripts/design-first.sh).
|
|
func routesStillOld(t *testing.T) bool {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("view", "legacy_pages.txt"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if strings.TrimSpace(line) == "@routes" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// answers reports the pattern that serves a request, "" when nothing does.
|
|
func answers(mux *http.ServeMux, method, target string) string {
|
|
_, pattern := mux.Handler(httptest.NewRequest(method, target, nil))
|
|
return pattern
|
|
}
|
|
|
|
// concrete turns a pattern into a request for it: {id} becomes a number.
|
|
func concrete(pattern string) (method, target string) {
|
|
method, target, _ = strings.Cut(pattern, " ")
|
|
target = strings.ReplaceAll(target, "{$}", "")
|
|
return method, regexp.MustCompile(`\{[a-z]+\}`).ReplaceAllString(target, "7")
|
|
}
|
|
|
|
func TestRoutesFollowNavigation(t *testing.T) {
|
|
if routesStillOld(t) {
|
|
t.Skip("view/legacy_pages.txt still lists @routes: the route rename of stage 1 has not landed")
|
|
}
|
|
on := &Server{cfg: Config{InboundEnabled: true, DMARCEnabled: true}}
|
|
public, authed := on.muxes()
|
|
|
|
// Every path of the table is registered with the listed method.
|
|
for _, group := range [][]string{routes.always, routes.inbound, routes.dmarc} {
|
|
for _, want := range group {
|
|
method, target := concrete(want)
|
|
if got := answers(authed, method, target); got != want {
|
|
t.Errorf("%s %s is answered by %q, want the route %q", method, target, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The feature flags still take their routes away entirely.
|
|
_, bare := (&Server{}).muxes()
|
|
for _, group := range [][]string{routes.inbound, routes.dmarc} {
|
|
for _, p := range group {
|
|
method, target := concrete(p)
|
|
if got := answers(bare, method, target); got != "" {
|
|
t.Errorf("%s %s is answered (%q) with its feature switched off", method, target, got)
|
|
}
|
|
}
|
|
}
|
|
for _, p := range routes.always {
|
|
method, target := concrete(p)
|
|
if got := answers(bare, method, target); got != p {
|
|
t.Errorf("%s %s is answered by %q with inbound and DMARC off, want %q", method, target, got, p)
|
|
}
|
|
}
|
|
|
|
// The old paths are gone, GET and POST alike.
|
|
for _, req := range oldRequests {
|
|
method, target, _ := strings.Cut(req, " ")
|
|
if got := answers(authed, method, target); got != "" {
|
|
t.Errorf("the pre-2.0 request %s is still answered, by %q — old paths are removed, not redirected", req, got)
|
|
}
|
|
if got := answers(public, method, target); got != "" && got != "/" {
|
|
t.Errorf("the pre-2.0 request %s is answered by the public route %q", req, got)
|
|
}
|
|
}
|
|
|
|
// What stays as it was.
|
|
for _, p := range []string{"/healthz", "/license", "/static/panel.css", "/setup/token", "/login", "/logout",
|
|
"/login/reset", "/login/reset/token", "/confirm/token"} {
|
|
if got := answers(public, http.MethodGet, p); got == "" || got == "/" {
|
|
t.Errorf("GET %s is no longer a public route", p)
|
|
}
|
|
}
|
|
|
|
// No template points at a path the router does not answer: every menu
|
|
// entry, link, form and polled fragment resolves to a registered route.
|
|
// An old path is such a dead end, so this is also what keeps a pre-2.0
|
|
// template from coming back.
|
|
files, err := filepath.Glob(filepath.Join("view", "templates", "*.html"))
|
|
if err != nil || len(files) == 0 {
|
|
t.Fatalf("no templates found: %v", err)
|
|
}
|
|
var (
|
|
link = regexp.MustCompile(`\b(href|action|hx-get|hx-post)\s*=\s*"([^"]*)"`)
|
|
action = regexp.MustCompile(`(?s)\{\{.*?\}\}`)
|
|
menu = 0
|
|
)
|
|
for _, f := range files {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := filepath.Base(f)
|
|
for _, m := range link.FindAllStringSubmatch(action.ReplaceAllString(string(b), "7"), -1) {
|
|
attr, target := m[1], m[2]
|
|
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
|
|
continue // another site, a fragment, or a value the handler supplies whole
|
|
}
|
|
target, _, _ = strings.Cut(target, "#")
|
|
target, _, _ = strings.Cut(target, "?")
|
|
if attr == "hx-get" && !strings.HasSuffix(target, "/fragment") {
|
|
t.Errorf("%s polls %s; a polled page answers <page>/fragment", name, target)
|
|
}
|
|
if name == "layout.html" && attr == "href" {
|
|
menu++
|
|
}
|
|
found := false
|
|
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
|
if p := answers(public, method, target); p != "" && p != "/" {
|
|
found = true
|
|
}
|
|
found = found || answers(authed, method, target) != ""
|
|
}
|
|
if !found {
|
|
t.Errorf("%s: %s=%q is not a route of the panel", name, attr, m[2])
|
|
}
|
|
}
|
|
}
|
|
if menu < 10 {
|
|
t.Errorf("layout.html links to %d paths; the menu was not found", menu)
|
|
}
|
|
}
|