Files
selfpost/test/e2e/notifications_test.go
T
mixandClaude Opus 5.5 14de9a05c8 panel-dkim-signing stage 1: the panel's DKIM signer, beside OpenDKIM
internal/milter/sign.go: a milter on two sockets (cmd/panel/signer.go):
sign.sock for 465/587, the key of the domain the SASL login belongs to
(Bindings.DomainOf, the sender_login_maps Postfix reads), and
sign-local.sock for the panel's own sendmail(1) mail, the server's name.
go-msgauth/dkim v0.7.0, relaxed/simple, OpenDKIM's dkim_should_signhdrs[]
(libopendkim/dkim.c:223-246) in message order plus From once more; keys
and selectors from the KeyTable the panel writes, a key path outside the
key directory refused; strict - whatever cannot be signed is deferred.

From the sources: Postfix 3.10.13 milter8.c:2329-2400 hands a folded
header \n-separated, so the signer joins its lines with CRLF; inserts are
1-based in cleanup_ins_header() (cleanup_milter.c:1193), so index 0 is the
top, where OpenDKIM puts its own (opendkim.c:3673). go-milter v0.4.1 calls
no Abort when a connection ends, so a watchdog closes go-msgauth's
streaming signer after an hour and a late end of message is deferred.

Postfix: smtpd_milters = sender check, signer, OpenDKIM, journal;
non_smtpd_milters = the signer's local socket, OpenDKIM - both sign every
message until stage 2. disable_mime_output_conversion=yes (default off,
mail_params.h:3088): an 8-bit message is no longer re-encoded after it is
signed.

Tests: unit tests sign and verify folded, tab-led and space-free headers,
an added From:, a changed body, two messages on a connection, odd bodies,
and five cases deferred. e2e: the sink image has python3-dkim (dkimpy
1.1.8-2) and the stand's DNS; on an application's message and on a
notification the two signatures must agree on h=, bh=, c=, s=, a= and each
must pass dkimverify. On the server: make e2e exit 0 (714 s), go vet + go
test 27 packages ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:14 +03:00

141 lines
4.7 KiB
Go
Raw 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 (
"fmt"
"net/url"
"strings"
"testing"
"time"
)
// The server's own name in the run, and where the administrator is written to:
// a mailbox behind the sink-MX, so a notification can be read back.
const (
serverName = "mail.e2e.test"
adminEmail = "admin@sink.e2e.test"
)
// serverMailRecord scrapes the DKIM record of the server's own name off
// Server › Settings, where the panel tells the administrator to publish it.
func (c *panelClient) serverMailRecord() (name, value string, err error) {
_, body, err := c.get("/server/settings")
if err != nil {
return "", "", err
}
name, ok := extractCodeRow(body, "Host / name")
if !ok {
return "", "", fmt.Errorf("could not find the server's DKIM record name on Settings")
}
value, ok = extractCodeRow(body, "Value to publish")
if !ok {
return "", "", fmt.Errorf("could not find the server's DKIM record value on Settings")
}
return name, value, nil
}
// setAccountEmail saves the signed-in user's profile with an e-mail and
// notifications on.
func (c *panelClient) setAccountEmail(username, email string) error {
resp, body, err := c.postForm("/account/profile", url.Values{
"username": {username},
"email": {email},
"notify": {"on"},
})
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("save profile: status %d: %s", resp.StatusCode, body)
}
_, page, err := c.get("/account")
if err != nil {
return err
}
if !strings.Contains(page, email) {
return fmt.Errorf("the Account page does not show %s after saving it", email)
}
return nil
}
// testNotificationsSetUp gives the administrator a mailbox and publishes the
// DKIM record of the server's own name, as an operator would after reading
// Server › Settings. It runs before the step that makes a rate limit refuse
// mail, so that the refusal finds someone to tell.
func testNotificationsSetUp(t *testing.T, sc *scenario) {
if err := sc.panel.setAccountEmail(adminUser, adminEmail); err != nil {
t.Fatalf("set the administrator's e-mail: %v", err)
}
name, value, err := sc.panel.serverMailRecord()
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(name, "._domainkey."+serverName) {
t.Fatalf("the record on Settings is %q, want a DKIM key of %s", name, serverName)
}
rel, ok := trimZoneSuffix(name)
if !ok {
t.Fatalf("record name %q is not under e2e.test", name)
}
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: rel, value: value})
if err != nil {
t.Fatalf("publish the server's dkim record to the fake zone: %v", err)
}
sc.zoneRecords = records
}
// testRateLimitRefusalIsNotified checks the whole path of a notification: the
// level-2 refusal of the previous step is noticed by the notifier (it looks
// every minute), written to the administrator, submitted by the unprivileged
// panel through sendmail, signed by OpenDKIM with the key of the server's own
// name and delivered. It also holds what a notification is not: a row of the
// Outbound log. That it is said once is for the unit tests to hold
// (internal/notify): here it would be a minute of waiting for nothing to happen.
func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
subject := "[SelfPost] " + senderDomain + ": a rate limit is refusing mail"
file, raw, err := findSinkFile(h.stageDir, subject, 100*time.Second)
if err != nil {
t.Fatalf("no notification reached %s: %v\npanel log:\n%s", adminEmail, err, tailLines(h.logs("selfpost"), "notify", 20))
}
msg := string(raw)
for _, want := range []string{
"From: SelfPost <postmaster@" + serverName + ">",
"To: <" + adminEmail + ">",
"Auto-Submitted: auto-generated",
"refused",
} {
if !strings.Contains(msg, want) {
t.Errorf("the notification lacks %q:\n%s", want, msg)
}
}
if err := checkBothSigners(h, file, raw, serverName); err != nil {
t.Errorf("the panel's signer on its own mail: %v", err)
}
if err := verifyDKIM(raw, serverName); err != nil {
t.Errorf("the notification is not signed as %s with the key Settings shows: %v", serverName, err)
}
// The panel's own mail is not an application's: no row in the log.
rows, err := sc.panel.sendLogRows(senderDomain, "")
if err != nil {
t.Fatalf("read the send log: %v", err)
}
if strings.Contains(rows, "rate limit is refusing") || strings.Contains(rows, "postmaster@"+serverName) {
t.Errorf("a notification is listed in the Outbound log:\n%s", rows)
}
}
// tailLines returns the last n lines of log that contain filter.
func tailLines(log, filter string, n int) string {
var keep []string
for _, line := range strings.Split(log, "\n") {
if strings.Contains(line, filter) {
keep = append(keep, line)
}
}
if len(keep) > n {
keep = keep[len(keep)-n:]
}
return strings.Join(keep, "\n")
}