A production rehearsal reported 12,182 settings not carried over by
migrate_v016.py. As a bare number that reads as an impossible amount of
manual reconstruction, and it is misleading. Snapshotting a v0.16.14 store
migrated from a real v0.15.5 showed what those settings actually are:
8547 regenerates server.blocked-ip
3337 shipped with v0.16 lookup.url-redirectors, lookup.trusted-domains,
spam-filter.list, spam-filter.rule,
spam-filter.dnsbl, lookup.surbl-hashbl
224 already carried server.listener, signature.* (DKIM)
293 NEED YOUR REVIEW queue.schedule, config.local-keys,
server.auto-ban, spam-filter.llm, queue.tls, ...
server.blocked-ip is auto-ban state that repopulates from live traffic. The
stock groups are data v0.16 provides itself - 2,084 MemoryLookupKey, 66
SpamRule and 18 SpamDnsblServer objects were already present in the migrated
store. DKIM came across as DkimSignature objects with private keys intact,
verified on that instance. So the real worklist is ~293 keys, not 12,182.
backup.UnmigratedReport.Classify encodes this and the rehearsal now reports
the categorised view. Rules match longest-prefix-first, because
server.blocked-ip is runtime state while server.auto-ban beside it is
configuration, and an unrecognized prefix defaults to "needs review" -
assuming an unknown setting is safe to ignore is the wrong default.
This also retired the lookup and spam-filter generators that were the
planned next step. v0.15's rules are stwt_rbl_senderscore_ip; v0.16's are
STWT_RBL_SENDERSCORE_IP - the same stock set, already installed. Generating
them from v0.15 would duplicate every rule and revert upstream updates, so
they were deliberately not written. The targets worth generating are the
small site-specific groups instead: queue.schedule, queue.tls,
session.auth, server.auto-ban.
No production data in this commit: the test fixture uses the real group
names and counts with example.com standing in for customer domains.
380 lines
13 KiB
Go
380 lines
13 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDownloadFileAcceptsMatchingChecksum(t *testing.T) {
|
|
content := "print('fake migration script')\n"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sum := sha256.Sum256([]byte(content))
|
|
expected := hex.EncodeToString(sum[:])
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, expected)
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
if got != expected {
|
|
t.Errorf("returned checksum = %s, want %s", got, expected)
|
|
}
|
|
data, _ := os.ReadFile(dest)
|
|
if string(data) != content {
|
|
t.Errorf("downloaded content = %q, want %q", data, content)
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileRejectsMismatchedChecksum(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("unexpected content"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
_, err := DownloadFile(context.Background(), nil, srv.URL, dest, "0000000000000000000000000000000000000000000000000000000000000000")
|
|
if err == nil {
|
|
t.Fatal("DownloadFile should reject a checksum mismatch")
|
|
}
|
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
|
t.Error("DownloadFile should remove the file it wrote after a checksum mismatch")
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileWithoutPinReturnsComputedHash(t *testing.T) {
|
|
content := "arbitrary content"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, "")
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
sum := sha256.Sum256([]byte(content))
|
|
want := hex.EncodeToString(sum[:])
|
|
if got != want {
|
|
t.Errorf("returned checksum = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
URL: "https://mail.example.com",
|
|
Username: "admin",
|
|
Password: "hunter2",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsDump: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"/opt/migrate_v016.py", "dump", "--url https://mail.example.com", "--username admin"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'auth failed' >&2\nexit 1\n")
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsDump should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "auth failed") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"convert", "--settings", "--config", "--output"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "--patch-paths") {
|
|
t.Errorf("script invoked with %q, should not include --patch-paths when none given", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertWithPatchPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
PatchPaths: map[string]string{"/var/lib/stalwart": "/tmp/sandbox/stalwart"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
if !strings.Contains(got, "--patch-paths /var/lib/stalwart=/tmp/sandbox/stalwart") {
|
|
t.Errorf("script invoked with %q, missing the expected --patch-paths flag", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'unsupported settings key' >&2\nexit 1\n")
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsConvert should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported settings key") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
// The report this parses is the most consequential output of a real
|
|
// migration: against a production instance with 12,401 settings,
|
|
// migrate_v016.py migrated 219 of them and listed the other 12,182 here.
|
|
// Losing or ignoring this file means bringing up a server that answers on
|
|
// no ports, since server.listener is among the settings that don't carry.
|
|
const sampleUnmigrated = `# Unmigrated v0.15 settings
|
|
|
|
These v0.15 settings were not migrated by the script and must be
|
|
reviewed manually.
|
|
|
|
Total unmigrated keys: 12182 across 69 prefixes.
|
|
|
|
server.blocked-ip 8547 keys
|
|
lookup.url-redirectors 1076 keys
|
|
spam-filter.rule 424 keys
|
|
server.listener 26 keys
|
|
asn.expires 1 keys
|
|
`
|
|
|
|
func TestReadUnmigratedReportParsesTotalsAndPrefixes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unmigrated.txt")
|
|
if err := os.WriteFile(path, []byte(sampleUnmigrated), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ReadUnmigratedReport(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.TotalKeys != 12182 {
|
|
t.Errorf("TotalKeys = %d, want 12182", report.TotalKeys)
|
|
}
|
|
if len(report.Prefixes) != 5 {
|
|
t.Fatalf("parsed %d prefixes, want 5", len(report.Prefixes))
|
|
}
|
|
// Largest first, so the summary leads with what matters most.
|
|
if report.Prefixes[0].Prefix != "server.blocked-ip" || report.Prefixes[0].Keys != 8547 {
|
|
t.Errorf("first prefix = %+v, want server.blocked-ip 8547", report.Prefixes[0])
|
|
}
|
|
|
|
summary := report.Summary(3)
|
|
if !strings.Contains(summary, "12182") || !strings.Contains(summary, "must be recreated by hand") {
|
|
t.Errorf("summary should lead with the scale of the problem:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "and 2 more prefix(es)") {
|
|
t.Errorf("summary should say how much it elided:\n%s", summary)
|
|
}
|
|
}
|
|
|
|
// An older script, or a conversion with nothing left over, writes no file.
|
|
// That is not an error.
|
|
func TestReadUnmigratedReportTreatsMissingFileAsNoReport(t *testing.T) {
|
|
report, err := ReadUnmigratedReport(filepath.Join(t.TempDir(), "absent.txt"))
|
|
if err != nil {
|
|
t.Errorf("missing report should not be an error: %v", err)
|
|
}
|
|
if report != nil {
|
|
t.Errorf("report = %+v, want nil", report)
|
|
}
|
|
}
|
|
|
|
// migrate_v016.py writes unmigrated.txt into its working directory, so the
|
|
// convert has to run somewhere writable that the caller knows about.
|
|
func TestRunSettingsConvertRunsInTheGivenWorkDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
workDir := filepath.Join(dir, "work")
|
|
log := argsFile(t, dir)
|
|
withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "pwd >> "+log+"\ntouch unmigrated.txt"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
ScriptPath: "/tmp/migrate_v016.py", SettingsPath: "/tmp/s.json", PrincipalsPath: "/tmp/p.json",
|
|
ConfigPath: filepath.Join(dir, "c.json"), OutputPath: filepath.Join(dir, "e.json"),
|
|
WorkDir: workDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(readArgsFile(t, log), workDir) {
|
|
t.Errorf("script did not run in WorkDir; log:\n%s", readArgsFile(t, log))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, "unmigrated.txt")); err != nil {
|
|
t.Errorf("unmigrated.txt should land in WorkDir, not the caller's cwd: %v", err)
|
|
}
|
|
}
|
|
|
|
// The numbers here are the real ones from a production instance, because
|
|
// the point of classifying is what it does to those numbers: 12,182 reads
|
|
// as impossible, and is mostly nothing to do.
|
|
const productionUnmigrated = `# Unmigrated v0.15 settings
|
|
|
|
Total unmigrated keys: 12182 across 69 prefixes.
|
|
|
|
server.blocked-ip 8547 keys
|
|
lookup.url-redirectors 1076 keys
|
|
lookup.trusted-domains 828 keys
|
|
spam-filter.list 537 keys
|
|
spam-filter.rule 424 keys
|
|
spam-filter.dnsbl 292 keys
|
|
lookup.surbl-hashbl 180 keys
|
|
queue.schedule 41 keys
|
|
server.listener 26 keys
|
|
signature.rsa-example.com 22 keys
|
|
server.auto-ban 16 keys
|
|
session.auth 14 keys
|
|
`
|
|
|
|
func classifyProduction(t *testing.T) *ClassifiedReport {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "unmigrated.txt")
|
|
if err := os.WriteFile(path, []byte(productionUnmigrated), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := ReadUnmigratedReport(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return report.Classify()
|
|
}
|
|
|
|
func TestClassifySeparatesWorkFromNoise(t *testing.T) {
|
|
c := classifyProduction(t)
|
|
|
|
// Runtime state: auto-ban repopulates it.
|
|
if got := c.Counts[DispositionRegenerates]; got != 8547 {
|
|
t.Errorf("regenerates = %d, want 8547 (server.blocked-ip)", got)
|
|
}
|
|
// Stock data v0.16 ships: restoring v0.15's would revert it.
|
|
if got := c.Counts[DispositionShipped]; got != 1076+828+537+424+292+180 {
|
|
t.Errorf("shipped = %d, want the stock spam/lookup groups", got)
|
|
}
|
|
// Carried by another route.
|
|
if got := c.Counts[DispositionCarried]; got != 22+26 {
|
|
t.Errorf("carried = %d, want the signature and listener groups", got)
|
|
}
|
|
// What's actually left for a human.
|
|
if got := c.Counts[DispositionReview]; got != 41+16+14 {
|
|
t.Errorf("needs review = %d, want %d", got, 41+16+14)
|
|
}
|
|
}
|
|
|
|
// server.blocked-ip is runtime state; server.auto-ban sitting right next to
|
|
// it is configuration. A shortest-prefix match would get this wrong.
|
|
func TestClassifyPrefersTheMoreSpecificRule(t *testing.T) {
|
|
c := classifyProduction(t)
|
|
for _, g := range c.Groups {
|
|
switch g.Prefix {
|
|
case "server.blocked-ip":
|
|
if g.Disposition != DispositionRegenerates {
|
|
t.Errorf("server.blocked-ip = %q, want regenerates", g.Disposition)
|
|
}
|
|
case "server.auto-ban":
|
|
if g.Disposition != DispositionReview {
|
|
t.Errorf("server.auto-ban = %q, want review - it is configuration, not runtime state", g.Disposition)
|
|
}
|
|
case "server.listener":
|
|
if g.Disposition != DispositionCarried {
|
|
t.Errorf("server.listener = %q, want carried - the apply plan regenerates it", g.Disposition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifyReviewListIsTheActualWorklist(t *testing.T) {
|
|
review := classifyProduction(t).NeedsReview()
|
|
if len(review) != 3 {
|
|
t.Fatalf("review groups = %d, want 3", len(review))
|
|
}
|
|
if review[0].Prefix != "queue.schedule" {
|
|
t.Errorf("first review group = %q, want the largest (queue.schedule)", review[0].Prefix)
|
|
}
|
|
for _, g := range review {
|
|
if g.Disposition != DispositionReview {
|
|
t.Errorf("%s is in the review list with disposition %q", g.Prefix, g.Disposition)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifySummaryLeadsWithWhatMatters(t *testing.T) {
|
|
summary := classifyProduction(t).Summary("/var/lib/stalwart-migrator/runs/x/unmigrated.txt")
|
|
if !strings.Contains(summary, "NEED YOUR REVIEW") {
|
|
t.Errorf("summary should call out the review bucket:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "71 needing review are work") {
|
|
t.Errorf("summary should say how much is actually work:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "would revert them") {
|
|
t.Errorf("summary should warn against restoring stock data:\n%s", summary)
|
|
}
|
|
}
|
|
|
|
func TestClassifyUnknownPrefixesDefaultToReview(t *testing.T) {
|
|
d, note := classifyPrefix("something.nobody.has.seen")
|
|
if d != DispositionReview {
|
|
t.Errorf("unknown prefix = %q, want review - guessing that an unknown setting is safe to ignore is the wrong default", d)
|
|
}
|
|
if note != "" {
|
|
t.Errorf("note = %q, want empty for an unclassified prefix", note)
|
|
}
|
|
}
|