Files
selfpost/test/e2e/health_served_test.go
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00

200 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package e2e
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testHealthShowsWhatIsServed: Server › Health dials the milter sockets as
// Postfix does, rather than finding their files (assumptions A-38), and shows
// the certificate Postfix serves, naming a different mounted one as a renewal
// waiting (A-65).
func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
health := func() string {
t.Helper()
resp, body, err := sc.panel.get("/server/health")
if err != nil || resp.StatusCode != 200 {
t.Fatalf("GET /server/health: %v, %v", resp, err)
}
return body
}
box := func(body, id string) string {
t.Helper()
i := strings.Index(body, `id="`+id+`"`)
if i < 0 {
t.Fatalf("no box %s on Health:\n%.2000s", id, body)
}
rest := body[i:]
if j := strings.Index(rest[1:], `<div class="box`); j > 0 {
rest = rest[:j+1]
}
return rest
}
// The panel's user reaches all four sockets - the signer's two, the
// sender check's, the journal's - through the selfpost group, as Postfix
// does: four ok rows and no detail under any.
sockets := box(health(), "sockets")
if n := strings.Count(sockets, `<tr><td>`); n != 4 {
t.Fatalf("Milter sockets has %d rows, want 4:\n%s", n, sockets)
}
if strings.Contains(sockets, `sp-muted sp-small">`) {
t.Fatalf("a milter socket is not answering the panel's dial:\n%s", sockets)
}
// A certificate mounted with a key it does not belong to: cert-reload
// refuses to copy it (build/postfix-cert-reload.sh, same_pair), so
// Postfix keeps serving the old one and the mounted one waits.
certPath := filepath.Join(h.stageDir, "certs", "fullchain.pem")
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
served, err := servedNotAfter()
if err != nil {
t.Fatal(err)
}
renewalNotAfter := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Minute)
if err := writeCertWithoutItsKey(certPath, renewalNotAfter); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
// A pair that belongs together, picked up as any renewal is.
_ = writeSelfSignedCert(certPath, keyPath)
_ = waitFor("the mended certificate served", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
want, err := fileSerial(certPath)
if err != nil {
return false, err
}
got, err := servedSerial()
return err == nil && got.Cmp(want) == 0, err
})
})
if err := waitFor("cert-reload to refuse the mismatched pair", 20*time.Second, 500*time.Millisecond, func() (bool, error) {
return strings.Contains(h.logs("selfpost"), "do not belong together"), nil
}); err != nil {
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
}
cert := box(health(), "certificate")
for _, want := range []string{
"<dt>Expires</dt><dd class=\"sp-mono\">" + served.UTC().Format("2006-01-02 15:04"),
"<dt>Renewal expires</dt><dd class=\"sp-mono\">" + renewalNotAfter.Format("2006-01-02 15:04"),
"<dt>Renewal mounted</dt>",
"Postfix still serves the certificate above.",
} {
if !strings.Contains(cert, want) {
t.Fatalf("the TLS certificate card lacks %q:\n%s", want, cert)
}
}
}
// servedNotAfter is the expiry of the certificate port 465 presents.
func servedNotAfter() (time.Time, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return time.Time{}, err
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return time.Time{}, fmt.Errorf("no certificate presented")
}
return certs[0].NotAfter, nil
}
// writeCertWithoutItsKey writes a certificate for a key that is thrown away,
// leaving the mounted key as it was.
func writeCertWithoutItsKey(certPath string, notAfter time.Time) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: selfpostHostname},
DNSNames: []string{selfpostHostname},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
return os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
}
// testFormPostWithTheBrowsersOrigin: a browser on a plain-HTTP panel at a
// name sends no Sec-Fetch-Site (Fetch Metadata § 3: only for a potentially
// trustworthy URL), and its form POST - a navigation - carries Origin by the
// page's Referrer-Policy (Fetch Standard § 3.2): its own origin under the
// panel's same-origin, which passes; "null" under no-referrer, which the
// panel sent until 2.5.2 and which the origin check refuses (assumptions
// D-B3). The client here is not a browser: it sends what one would.
func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
resp, page, err := sc.panel.get("/server/health")
if err != nil {
t.Fatal(err)
}
if got := resp.Header.Get("Referrer-Policy"); got != "same-origin" {
t.Fatalf("Referrer-Policy %q, want same-origin", got)
}
// The setup checks of health-setup-checks, on a stand that passes them:
// the stand's certificate is for its hostname, Postfix greets with it
// (postconf -x smtpd_banner, read at the panel's start), and the client
// reaches the panel directly, with no proxy in between.
for _, want := range []string{
`<dt>Names <span class="tag is-success is-light">matches</span></dt>`,
`<div class="sp-wide"><dt>Greeting</dt><dd class="sp-mono">220 ` + selfpostHostname + ` ESMTP</dd>`,
`<dt>Your address</dt>`, `The connection&#39;s own; no proxy in between said otherwise`,
} {
if !strings.Contains(page, want) {
t.Fatalf("Server › Health lacks %q:\n%.3000s", want, page)
}
}
post := func(origin string) int {
t.Helper()
req, err := http.NewRequest(http.MethodPost, panelBaseURL+"/server/health/recheck", strings.NewReader(""))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := sc.panel.http.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
if code := post(panelBaseURL); code != http.StatusOK {
t.Fatalf("POST with the page's own Origin: status %d, want 200 after the redirect", code)
}
if code := post("null"); code != http.StatusForbidden {
t.Fatalf("POST with Origin: null: status %d, want 403", code)
}
// Neither Sec-Fetch-Site nor Origin: nothing says where it came from, and
// it is refused (origin-strict, 2.10.4; allowed before as an accepted risk).
if code := post(""); code != http.StatusForbidden {
t.Fatalf("POST with neither header: status %d, want 403", code)
}
}