Files
selfpost/test/e2e/panel_client.go
T
mixandClaude Opus 5.5 bd746c61d7 panel-redesign: paths follow the menu; Account and Server settings split
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:51:55 +03:00

270 lines
9.6 KiB
Go

package e2e
import (
"fmt"
"html"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
)
// panelBaseURL is where compose.override.yml publishes the panel's plain-HTTP
// port (PANEL_COOKIE_SECURE=false — no reverse proxy in this stand).
const panelBaseURL = "http://127.0.0.1:20080"
// panelClient drives the panel exactly as a browser would: an HTTP client
// with a cookie jar, HTML forms posted as the templates render them, and
// responses scraped for the bits a later step needs (a domain's id, an
// application's one-shot password, the DKIM record to publish). This is
// deliberate — it is also the only way to prove that what the panel tells an
// administrator to publish is the record that actually verifies (plan C.4).
type panelClient struct {
http *http.Client
}
func newPanelClient() (*panelClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &panelClient{http: &http.Client{
Jar: jar,
Timeout: 15 * time.Second,
}}, nil
}
// waitForPanelReady polls the host-published panel port until /healthz
// returns 200. Supervisor reporting the panel process RUNNING is not enough:
// the setup-token file is written in Start() before ListenAndServe, and
// Docker's host-port publish can lag the in-container bind — either race
// makes the first setup POST fail and leaves sc.panel nil for later steps.
func waitForPanelReady() error {
client := &http.Client{Timeout: 2 * time.Second}
return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) {
resp, err := client.Get(panelBaseURL + "/healthz")
if err != nil {
return false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("status %d", resp.StatusCode)
}
return true, nil
})
}
// readSetupToken reads the one-time setup URL from /data/setup-token inside
// the running selfpost container. The file is mode 0600 owned by the panel
// UID (security.md / setup.go); on a typical CI bind mount that is not the
// host runner's UID, so a host-side os.ReadFile returns permission denied
// even though the panel already wrote the token (CI: setup_and_login).
// Reading via compose exec matches docs/guide.md ("docker compose exec
// selfpost cat /data/setup-token").
func readSetupToken(s *stack) (string, error) {
var raw string
err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) {
out, err := s.execIn("selfpost", "cat", "/data/setup-token")
if err != nil {
return false, err
}
raw = out
return strings.TrimSpace(out) != "", nil
})
if err != nil {
return "", err
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse setup token file: %w", err)
}
return strings.TrimPrefix(u.Path, "/setup/"), nil
}
func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) {
resp, err := c.http.PostForm(panelBaseURL+path, form)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return resp, string(body), err
}
func (c *panelClient) get(path string) (*http.Response, string, error) {
resp, err := c.http.Get(panelBaseURL + path)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return resp, string(body), err
}
// setup creates the administrator through the one-time /setup/<token> form.
func (c *panelClient) setup(token, username, password string) error {
resp, body, err := c.postForm("/setup/"+token, url.Values{
"username": {username},
"password": {password},
"password_confirm": {password},
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("setup: status %d: %s", resp.StatusCode, body)
}
return nil
}
// login authenticates and stores the session cookie in the client's jar.
func (c *panelClient) login(username, password string) error {
resp, body, err := c.postForm("/login", url.Values{
"username": {username},
"password": {password},
})
if err != nil {
return err
}
if resp.Request.URL.Path != "/overview" {
return fmt.Errorf("login did not land on /overview (landed on %s): %s", resp.Request.URL.Path, body)
}
return nil
}
// addDomain submits the add-domain form and returns its assigned id, read
// back from the redirect target /outbound/domains/{id}.
func (c *panelClient) addDomain(name string) (string, error) {
resp, body, err := c.postForm("/outbound/domains", url.Values{"name": {name}})
if err != nil {
return "", err
}
m := regexp.MustCompile(`^/outbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
if m == nil {
return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body)
}
return m[1], nil
}
// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells
// the administrator to publish.
func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) {
_, body, err := c.get("/outbound/domains/" + domainID)
if err != nil {
return "", "", err
}
name, ok := extractCodeRow(body, "Host / name")
if !ok {
return "", "", fmt.Errorf("could not find DKIM record name on domain page")
}
value, ok = extractCodeRow(body, "Value")
if !ok {
return "", "", fmt.Errorf("could not find DKIM record value on domain page")
}
return name, value, nil
}
// addApplication submits the add-application form and returns the one-shot
// login/password the panel renders inline (security.md — never recoverable
// later, so this is the only place to read it).
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications", url.Values{
"login": {login},
"mode": {mode},
"addresses": {addresses},
})
if err != nil {
return "", "", err
}
if resp.StatusCode != http.StatusCreated {
return "", "", fmt.Errorf("add application %q: status %d: %s", login, resp.StatusCode, body)
}
appLogin, ok := extractCodeRow(body, "Login")
if !ok {
return "", "", fmt.Errorf("could not find new application login in response")
}
password, ok = extractCodeRow(body, "Password")
if !ok {
return "", "", fmt.Errorf("could not find new application password in response")
}
return appLogin, password, nil
}
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// or domain (/applications/{id}/ratelimit or /outbound/domains/{id}/settings/ratelimit).
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
vals := url.Values{
"max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
}
resp, body, err := c.postForm(path, vals)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("set rate limit: status %d: %s", resp.StatusCode, body)
}
return nil
}
// sendLogRows returns the raw /outbound/log/fragment HTML fragment, filtered to one
// domain and optionally to one application login, for polling a row's status
// without parsing full HTML into structs. The application is a filter rather
// than something to search the returned rows for: the log's columns identify a
// message (time, from, to, subject, status) and the sending application is only
// named on a row's own /outbound/log/{id} page.
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
q := url.Values{"domain": {domain}}
if app != "" {
q.Set("app", app)
}
_, body, err := c.get("/outbound/log/fragment?" + q.Encode())
return body, err
}
// status fetches the authenticated landing page — used after a container
// restart to confirm the session cookie is still accepted (plan C.4 check 8).
func (c *panelClient) status() (*http.Response, error) {
resp, _, err := c.get("/overview")
return resp, err
}
// applicationID scrapes an application's numeric id off its block on the domain
// page, keyed by login — needed to build /applications/{id}/ratelimit, which the
// add-application response (just the login/password) does not carry. The id is
// taken from the first action posted under that login, whichever it is, so
// reordering the block's controls does not break the scrape; only the login
// heading itself is anchored on.
func (c *panelClient) applicationID(domainID, login string) (string, error) {
_, body, err := c.get("/outbound/domains/" + domainID)
if err != nil {
return "", err
}
pattern := `(?s)<p class="app-login">` + regexp.QuoteMeta(login) + `</p>.*?/applications/(\d+)/`
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
if m == nil {
return "", fmt.Errorf("could not find application id for login %q", login)
}
return m[1], nil
}
// extractCodeRow scrapes the value of a "<label>LABEL</label> ... <span
// class=\"code\">VALUE</span>" pair from a rendered panel page (see
// internal/web/templates/domain_detail.html). It is deliberately anchored to
// the label text rather than position, so it survives unrelated template
// reordering. html/template's escaper is conservative about which characters
// it entity-encodes in text nodes — a DKIM value's base64 "+" comes back as
// "&#43;" — so the match is HTML-unescaped before returning.
func extractCodeRow(body, label string) (string, bool) {
pattern := `<label>` + regexp.QuoteMeta(label) + `</label>\s*<div class="code-row">\s*<span class="code">([^<]*)</span>`
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
if m == nil {
return "", false
}
return html.UnescapeString(strings.TrimSpace(m[1])), true
}