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 func() { _ = 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) } }