172 lines
5.5 KiB
Go
172 lines
5.5 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func readZipEntry(t *testing.T, data []byte, name string) []byte {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("zip.NewReader: %v", err)
|
|
}
|
|
for _, f := range zr.File {
|
|
if f.Name == name {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", name, err)
|
|
}
|
|
defer rc.Close()
|
|
b, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
return b
|
|
}
|
|
}
|
|
t.Fatalf("zip entry %q not found", name)
|
|
return nil
|
|
}
|
|
|
|
func TestBuildArchiveRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mediaDir := filepath.Join(dir, "media")
|
|
if err := os.MkdirAll(filepath.Join(mediaDir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mediaDir, "sub", "a.txt"), []byte("hello"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dump := []byte("fake pg_dump custom-format payload")
|
|
var buf bytes.Buffer
|
|
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
|
Source: Source{SiteTitle: "Test Site", Domain: "test.example.com"},
|
|
Dump: dump,
|
|
Password: "correct horse",
|
|
Sections: []Section{
|
|
{Prefix: "media/", Dir: mediaDir},
|
|
{Prefix: "brand/", Dir: filepath.Join(dir, "does-not-exist")}, // missing dir → warning, not error
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildArchive: %v", err)
|
|
}
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("missing section dir must be silently skipped (cms parity), got warnings: %v", warnings)
|
|
}
|
|
if manifest.Version != FormatVersion || manifest.Format != FormatName {
|
|
t.Fatalf("manifest version/format = %d/%q", manifest.Version, manifest.Format)
|
|
}
|
|
if manifest.MediaCount != 1 || manifest.BrandCount != 0 {
|
|
t.Fatalf("counts: media=%d brand=%d", manifest.MediaCount, manifest.BrandCount)
|
|
}
|
|
wantSum := sha256.Sum256(dump)
|
|
if manifest.Checksum != base64.StdEncoding.EncodeToString(wantSum[:]) {
|
|
t.Fatalf("checksum mismatch")
|
|
}
|
|
|
|
// Read back: manifest parses, dump decrypts to the original bytes.
|
|
ra := bytes.NewReader(buf.Bytes())
|
|
got, err := ReadManifest(ra, int64(buf.Len()))
|
|
if err != nil {
|
|
t.Fatalf("ReadManifest: %v", err)
|
|
}
|
|
encDump := readZipEntry(t, buf.Bytes(), "database.dump")
|
|
plain, err := DecryptWithPassword(encDump, "correct horse", got.Encryption)
|
|
if err != nil {
|
|
t.Fatalf("DecryptWithPassword: %v", err)
|
|
}
|
|
if !bytes.Equal(plain, dump) {
|
|
t.Fatal("decrypted dump != original")
|
|
}
|
|
if _, err := DecryptWithPassword(encDump, "wrong password", got.Encryption); err == nil {
|
|
t.Fatal("wrong password must fail")
|
|
}
|
|
if string(readZipEntry(t, buf.Bytes(), "media/sub/a.txt")) != "hello" {
|
|
t.Fatal("media file missing or corrupted in archive")
|
|
}
|
|
}
|
|
|
|
// TestBuildArchiveExtraEntries locks the happy path of the Extra contract: extra
|
|
// entries land in the archive with their exact contents. The failure half of the
|
|
// contract (Create failure → skip silently, Write failure → warning, never a
|
|
// fatal error — cms cssManifestsFile parity) is documented at the Extra block in
|
|
// BuildArchive; zip.Writer failures cannot be injected deterministically without
|
|
// error-injection infrastructure, so the failure path is covered for section
|
|
// files below where the failure is controllable.
|
|
func TestBuildArchiveExtraEntries(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
_, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
|
Source: Source{SiteTitle: "Test Site"},
|
|
Dump: []byte("dump"),
|
|
Password: "correct horse",
|
|
Extra: map[string][]byte{
|
|
"state/css-manifests.json": []byte(`{"plugins":{}}`),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildArchive: %v", err)
|
|
}
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("unexpected warnings: %v", warnings)
|
|
}
|
|
if got := string(readZipEntry(t, buf.Bytes(), "state/css-manifests.json")); got != `{"plugins":{}}` {
|
|
t.Fatalf("extra entry content = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildArchiveUnreadableFileWarnsAndContinues proves the warning-not-error
|
|
// contract: a file that cannot be opened inside an existing section dir appends
|
|
// a warning and the archive still completes with every readable file present.
|
|
func TestBuildArchiveUnreadableFileWarnsAndContinues(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("running as root: chmod 0 files remain readable")
|
|
}
|
|
dir := t.TempDir()
|
|
mediaDir := filepath.Join(dir, "media")
|
|
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mediaDir, "good.txt"), []byte("ok"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
badPath := filepath.Join(mediaDir, "bad.txt")
|
|
if err := os.WriteFile(badPath, []byte("nope"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(badPath, 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
|
Source: Source{SiteTitle: "Test Site"},
|
|
Dump: []byte("dump"),
|
|
Password: "correct horse",
|
|
Sections: []Section{{Prefix: "media/", Dir: mediaDir}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unreadable file must not abort the archive, got error: %v", err)
|
|
}
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "bad.txt") {
|
|
t.Fatalf("want exactly one warning mentioning bad.txt, got: %v", warnings)
|
|
}
|
|
// Both files were collected (open fails later, at write time) — cms parity.
|
|
if manifest.MediaCount != 2 {
|
|
t.Fatalf("media count = %d, want 2", manifest.MediaCount)
|
|
}
|
|
if got := string(readZipEntry(t, buf.Bytes(), "media/good.txt")); got != "ok" {
|
|
t.Fatalf("good.txt content = %q", got)
|
|
}
|
|
}
|