Initial commit: stalwart-migrator design and scaffolding
In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with checkpointed rollback and post-migration validation. Design stage; see ARCHITECTURE.md.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user