Files
selfpost/internal/domain/transfer_test.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00

199 lines
6.0 KiB
Go

package domain
import (
"errors"
"fmt"
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
// the import calls and hands back canned SASL secrets.
type fakeApps struct {
secrets map[string]string
imported []importedApp
importErr error
}
type importedApp struct {
domainID int64
login string
mode string
addresses []string
password string
}
func (f *fakeApps) PurgeDomainSASL(int64) error { return nil }
func (f *fakeApps) Resync() error { return nil }
func (f *fakeApps) Secret(login string) (string, error) {
pw, ok := f.secrets[login]
if !ok {
return "", fmt.Errorf("no secret for %q", login)
}
return pw, nil
}
func (f *fakeApps) ImportApplication(domainID int64, login, mode string, addresses []string, password string) error {
if f.importErr != nil {
return f.importErr
}
f.imported = append(f.imported, importedApp{domainID, login, mode, addresses, password})
return nil
}
// newTestService builds a Service over a fresh SQLite store and key tree in a
// temp dir.
func newTestService(t *testing.T, apps Applications) (*Service, *Keys) {
t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
keys := NewKeys(t.TempDir())
return NewService(st, keys, apps, "selfpost"), keys
}
func TestExportImportRoundTrip(t *testing.T) {
// Source instance: a domain with two applications and their secrets.
srcApps := &fakeApps{secrets: map[string]string{"mailer": "pw-mailer", "alerts": "pw-alerts"}}
src, srcOdk := newTestService(t, srcApps)
d, err := src.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add mailer: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
t.Fatalf("set dmarc rua: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exp.Format != FormatDomainExport || exp.Domain != "example.com" || exp.DKIMSelector != "selfpost" {
t.Fatalf("export header = %+v", exp)
}
if len(exp.Applications) != 2 {
t.Fatalf("exported %d apps, want 2", len(exp.Applications))
}
srcKey, err := srcOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read source key: %v", err)
}
if exp.DKIMPrivateKey != string(srcKey) {
t.Error("export DKIM key does not match the on-disk key")
}
// Target instance: import the file.
dstApps := &fakeApps{}
dst, dstOdk := newTestService(t, dstApps)
nd, err := dst.Import(exp)
if err != nil {
t.Fatalf("Import: %v", err)
}
// Domain row landed with the exported selector.
got, err := dst.Get(nd.ID)
if err != nil {
t.Fatalf("get imported domain: %v", err)
}
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
if got.DMARCRua != "reports@hub.example" {
t.Errorf("imported dmarc rua = %q", got.DMARCRua)
}
if exp.DMARCRua != "reports@hub.example" {
t.Errorf("exported dmarc rua = %q", exp.DMARCRua)
}
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read imported key: %v", err)
}
if string(dstKey) != string(srcKey) {
t.Error("imported DKIM key differs from the source key")
}
// Applications were re-created with their working passwords.
if len(dstApps.imported) != 2 {
t.Fatalf("imported %d apps, want 2", len(dstApps.imported))
}
byLogin := map[string]importedApp{}
for _, a := range dstApps.imported {
byLogin[a.login] = a
}
if byLogin["mailer"].password != "pw-mailer" || byLogin["alerts"].password != "pw-alerts" {
t.Errorf("imported passwords = %+v", dstApps.imported)
}
if byLogin["alerts"].mode != store.AddressModeList {
t.Errorf("alerts mode = %q", byLogin["alerts"].mode)
}
}
func TestImportRejectsWrongFormat(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Import(DomainExport{Format: "nope", Domain: "example.com"}); err == nil {
t.Error("Import accepted a non-export file")
}
}
func TestImportRejectsDuplicateDomain(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Add("example.com"); err != nil {
t.Fatalf("seed domain: %v", err)
}
// A minimal but well-formed export of the same domain.
src, srcOdk := newTestService(t, &fakeApps{})
d, _ := src.Add("example.com")
key, _ := srcOdk.ExportKey("example.com", "selfpost")
exp := DomainExport{
Format: FormatDomainExport, Domain: "example.com", DKIMSelector: "selfpost",
DKIMPrivateKey: string(key),
}
_ = d
if _, err := dst.Import(exp); !errors.Is(err, store.ErrDomainExists) {
t.Errorf("Import duplicate = %v, want ErrDomainExists", err)
}
}
func TestImportRollsBackOnAppFailure(t *testing.T) {
// Build a valid export from a source instance.
src, _ := newTestService(t, &fakeApps{secrets: map[string]string{"mailer": "pw"}})
d, _ := src.Add("example.com")
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add app: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Target rejects the application import; the whole domain must roll back.
dstApps := &fakeApps{importErr: errors.New("boom")}
dst, dstOdk := newTestService(t, dstApps)
if _, err := dst.Import(exp); err == nil {
t.Fatal("Import succeeded despite an application failure")
}
// Domain row removed.
domains, err := dst.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(domains) != 0 {
t.Errorf("expected rollback to remove the domain, got %+v", domains)
}
// DKIM key removed.
if _, err := dstOdk.ExportKey("example.com", "selfpost"); err == nil {
t.Error("expected rollback to remove the imported DKIM key")
}
}