Files
selfpost/internal/backup/backup_test.go
T
mixandClaude Opus 5.5 8c9a71e6a0 backup: the panel's full backup works, without the queue
Found by the e2e restore scenario of the next commit: the Full backup
button answered 200 with ten bytes. The archive includes the Postfix
queue, Postfix keeps every queue file and directory readable by its own
user alone (not configurable), and the panel runs as its own user - the
walk stopped at /data/postfix/queue/active with "permission denied"
after the headers were out. So since the queue moved under /data in
1.3.0. Confirmed on the test server: selfpost-backup as root makes the
whole archive, the same command as user panel fails the same way.

Owner, 2026-10-11: the panel's archive comes without the queue.

- backup.Params.Unprivileged: the queue is left out, and so is a file
  under certs/ that cannot be opened (the private key on the test
  server is root and Postfix's group only), named through OnWarn. The
  selfpost-backup command leaves it unset and takes both, as before.
- backup.Check reads through everything Create would archive, writing
  nothing. The panel runs it before the first byte: a failure is a
  message on the page, not a download cut short.
- The box, Help (design commit before this one) and the guide say what
  the panel's archive leaves out; the guide's comparison table has rows
  for the queue and the private key, and tells to put the key back
  before starting a restored instance.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server, with the restore scenario: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:18 +03:00

282 lines
9.0 KiB
Go

package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude. It also
// seeds a deploy root beside data/ with compose, .env, and certs/.
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
t.Helper()
deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatalf("add domain: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
// Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
// Postfix's delivery log and its rotated files: diagnostic output, not
// state, and the bulkiest thing under /data.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
return dataDir, dbPath, deployRoot
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// readArchive returns the set of regular-file entries (name -> content) in a
// gzip tar produced by Create.
func readArchive(t *testing.T, data []byte) map[string]string {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
out := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
b, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("tar read %s: %v", hdr.Name, err)
}
out[hdr.Name] = string(b)
}
return out
}
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
// Present.
for _, name := range []string{
DataArchivePrefix + ManifestName,
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName,
EnvFileName,
CertsDirName + "/fullchain.pem",
CertsDirName + "/privkey.pem",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
// Excluded.
for _, name := range []string{
DataArchivePrefix + "setup-token",
DataArchivePrefix + "selfpost.db-wal",
DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1",
} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
}
}
// Manifest is well-formed and carries the version.
var m Manifest
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
t.Fatalf("manifest json: %v", err)
}
if m.Format != FormatFull || m.Version != "1.2.3" {
t.Errorf("manifest = %+v, want format=%s version=1.2.3", m, FormatFull)
}
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db")
if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
t.Fatalf("write snapshot: %v", err)
}
st, err := store.Open(snapPath)
if err != nil {
t.Fatalf("open snapshot: %v", err)
}
defer st.Close()
domains, err := st.ListDomains()
if err != nil {
t.Fatalf("list domains from snapshot: %v", err)
}
if len(domains) != 1 || domains[0].Name != "example.com" {
t.Errorf("snapshot domains = %+v, want one example.com", domains)
}
}
func TestCreateRequiresDeployRoot(t *testing.T) {
dataDir, dbPath, _ := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
t.Fatal("Create without DeployRoot succeeded")
}
}
func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper()
path := filepath.Join(dir, ManifestName)
b, _ := json.Marshal(Manifest{Format: format, Version: version, CreatedAt: "now"})
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0")
if err != nil {
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
}
if restored {
t.Error("CheckRestore with no manifest reported a restore")
}
}
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
restored, err := CheckRestore(path, "1.0.0")
if err != nil {
t.Fatalf("CheckRestore matching = %v, want nil", err)
}
if !restored {
t.Fatal("CheckRestore matching did not report a restore")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
}
}
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
restored, err := CheckRestore(path, "2.0.0")
if err == nil {
t.Fatal("CheckRestore mismatch = nil, want error")
}
if restored {
t.Error("CheckRestore mismatch reported a restore")
}
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
t.Errorf("error should name both versions: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("manifest must be kept on mismatch so the operator can switch images: %v", statErr)
}
}
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "something-else", "1.0.0")
restored, err := CheckRestore(path, "1.0.0")
if err == nil {
t.Error("CheckRestore accepted a non-backup manifest")
}
if restored {
t.Error("CheckRestore wrong format reported a restore")
}
}
// The panel's archive: no Postfix queue, everything else as before, and Check
// agrees that it can be made.
func TestCreateUnprivilegedLeavesTheQueueOut(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot, Unprivileged: true}
if err := Check(p); err != nil {
t.Fatalf("Check: %v", err)
}
var buf bytes.Buffer
if err := Create(&buf, p); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
for name := range files {
if strings.HasPrefix(name, DataArchivePrefix+QueueDir) {
t.Errorf("the panel's archive holds %s", name)
}
}
for _, name := range []string{
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
ComposeFileName, EnvFileName,
CertsDirName + "/fullchain.pem",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
}
// Check finds what Create would stumble over, before anything is written.
func TestCheckReportsWhatCannotBeRead(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
if err := os.Remove(filepath.Join(deployRoot, EnvFileName)); err != nil {
t.Fatal(err)
}
if err := Check(Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err == nil {
t.Fatal("Check passed with .env missing from the deploy root")
}
}