core/backup/archive_test.go
Alex Dunmow d7c1abbfb6 style(backup): satisfy errcheck on deferred Close calls
Wrap deferred rc.Close() in closures to explicitly discard the error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:41:07 +08:00

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 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)
}
}