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>
278 lines
10 KiB
Go
278 lines
10 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 to publish")
|
|
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/new", 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
|
|
}
|
|
|
|
// setApplicationLimit saves a manual level-2 limit (guide § Rate limiting) on
|
|
// an application. The application form is one POST, so the sender rule goes
|
|
// with it: the application stays on the domain wildcard it was created with.
|
|
func (c *panelClient) setApplicationLimit(domainID, appID string, maxMessages, windowSeconds int) error {
|
|
vals := url.Values{
|
|
"mode": {"wildcard"},
|
|
"rl_mode": {"manual"},
|
|
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
|
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
|
|
}
|
|
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/"+appID, 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 row of the domain
|
|
// page, keyed by login — needed to build the application's own address
|
|
// (/outbound/domains/{id}/applications/{aid}), which the add-application
|
|
// response (just the login/password) does not carry. The id is taken from the
|
|
// row's Edit link, so only the login cell 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)<strong>` + regexp.QuoteMeta(login) + `</strong></td>.*?/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 copy field — a "<label class=\"label\">
|
|
// LABEL</label>" followed by its read-only input or textarea (the panel's
|
|
// copy_field partial, internal/web/view/templates/components.html) — from a
|
|
// rendered panel page. 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 — a DKIM
|
|
// value's base64 "+" comes back as "+" — so the match is HTML-unescaped
|
|
// before returning.
|
|
func extractCodeRow(body, label string) (string, bool) {
|
|
pattern := `<label class="label">` + regexp.QuoteMeta(label) + `</label>\s*<div class="field has-addons"><div class="control is-expanded">` +
|
|
`(?:<input class="input" readonly value="([^"]*)">|<textarea class="textarea" rows="\d+" readonly>([^<]*)</textarea>)`
|
|
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
value := m[1]
|
|
if value == "" {
|
|
value = m[2]
|
|
}
|
|
return html.UnescapeString(strings.TrimSpace(value)), true
|
|
}
|