Files
selfpost/cmd/panel/restore_cut_test.go
T
mixandClaude Opus 5.5 8e3241ef98
test / test (push) Canceled after 0s
audit 3: a restore is ended by its Resync, and half an archive is refused
The restore manifest was consumed as soon as its version was checked,
before store.Open and the Resync; a start that failed in between left a
data directory the next start took for an ordinary one (M1). openData now
runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore.

A truncated archive had nothing to refuse it: the manifest is the first
entry, so a download cut short or a tar stopped by a damaged block was a
restore on half a database. Every archive now ends with data/contents.json,
the size and SHA-256 of each data/ file (deploy files are the operator's to
edit), and CheckRestore compares /data with it before the database is
opened, then renames it contents.checked.json so a start after a failed one
does not compare again unless the manifest changed.

Tests: a start whose Resync fails, then a start that resyncs and consumes;
an archive cut in the database, before the deploy files and in its last
entry (refused, database untouched, no -wal); a damaged key named; the
record's names confined to data/; the domain import of a cut plain export,
a cut and an altered .spde (refused, nothing created). e2e on the test
server: full_backup_restore now also sees contents.json in the archive and
gone after the first start, through the real entrypoint and daemons (all
pass, 762 s); go vet + go test of 24 packages pass there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:53:46 +03:00

185 lines
6.1 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/store"
)
// A restore that goes wrong half way must leave the operator where they
// started: the archive is refused before the database is opened, and a start
// that fails after the guard is a restore again the next time (audit
// 2026-10-12, M1 and stage 3).
// A start that fails after the guard - here its Resync - leaves the manifest,
// so the next start is a restore again and runs the Resync the failed one did
// not finish. The failed start had the database open, so its files are no
// longer the archive's; the next start does not compare them again.
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
broken := errors.New("postfix reload failed")
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Fatalf("the failed start consumed the manifest: %v", err)
}
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
st, restored, err := openData(cfg, testResync(cfg))
if err != nil {
t.Fatalf("the start after a failed one: %v", err)
}
st.Close()
if !restored {
t.Fatal("the start after a failed one did not take the data directory for a restore")
}
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
t.Errorf("sender map = %q: the second start did not run the Resync", got)
}
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
}
}
st, restored, err = openData(cfg, func(*store.Store) error {
t.Error("an ordinary start ran the restore Resync")
return nil
})
if err != nil {
t.Fatalf("the ordinary start after the restore: %v", err)
}
st.Close()
if restored {
t.Error("the start after a finished restore took it for a restore again")
}
}
// An archive unpacked in part - a download cut short, a tar stopped by a
// damaged block - is refused before the database is opened, with a message
// that says so, and nothing in the directory is changed: the database the
// archive left is as it left it, and the manifest is there for the next try.
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
archive := archiveOf(t, seedPanelProject(t))
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
for _, c := range []struct {
name string
cut string
at int64
}{
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
{"before the deploy files", backup.ComposeFileName, 0},
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
} {
t.Run(c.name, func(t *testing.T) {
target := t.TempDir()
extractCut(t, archive, target, c.cut, c.at)
cfg := panelConfig(t, target)
before, err := os.ReadFile(cfg.dbPath)
if err != nil {
t.Fatalf("the cut archive left no database: %v", err)
}
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
}
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
t.Error("the refused start changed the database")
}
for _, side := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
}
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Errorf("the refused start removed the manifest: %v", err)
}
})
}
}
// A file the archive holds and the data directory holds otherwise - damaged
// on the way, or unpacked from another archive - is refused by its name.
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
key := filepath.Join(cfg.dataDir, "opendkim", "keys", restoreDomain, "selfpost.private")
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
t.Fatalf("open key: %v", err)
}
f.WriteString("x")
f.Close()
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
}
}
// archiveOf takes a full backup of a seeded project for this binary's version.
func archiveOf(t *testing.T, deployRoot string) []byte {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: buildinfo.Version,
DeployRoot: deployRoot,
}); err != nil {
t.Fatalf("create backup: %v", err)
}
return archive.Bytes()
}
// entrySize is the size of the archive's entry name.
func entrySize(t *testing.T, archive []byte, name string) int64 {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
t.Fatalf("the archive has no %s", name)
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
if hdr.Name == name {
return hdr.Size
}
}
}