Files
RawMark/internal/backup/backup_test.go
T

237 lines
7.8 KiB
Go

package backup
import (
"archive/zip"
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"rawmark/internal/auth"
"rawmark/internal/store"
)
func writeTest(t *testing.T, file, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
func database(t *testing.T, file string) {
t.Helper()
st, err := store.Open(file)
if err != nil {
t.Fatal(err)
}
if err = auth.New(st).Setup("disposable-backup-password"); err != nil {
t.Fatal(err)
}
if err = st.Close(); err != nil {
t.Fatal(err)
}
}
func fullArchive(t *testing.T, dir, content string) string {
t.Helper()
source := filepath.Join(dir, "source")
os.MkdirAll(source, 0700)
database(t, filepath.Join(source, "rawmark.db"))
writeTest(t, filepath.Join(source, "vault/note.md"), content)
archive := filepath.Join(dir, "backup.zip")
if err := Create(context.Background(), archive, "full", []Source{{"app/rawmark.db", filepath.Join(source, "rawmark.db")}, {"vault", filepath.Join(source, "vault")}}, nil); err != nil {
t.Fatal(err)
}
return archive
}
func TestRecoveryCommitAndRollback(t *testing.T) {
for _, mode := range []string{"prepared", "applying", "invalid-source", "verifying"} {
t.Run(mode, func(t *testing.T) {
data := t.TempDir()
vault := filepath.Join(data, "vault")
oldArchive := fullArchive(t, t.TempDir(), "old")
oldStage := t.TempDir()
if _, err := Extract(context.Background(), oldArchive, oldStage); err != nil {
t.Fatal(err)
}
if err := CopyTree(filepath.Join(oldStage, "app"), data); err != nil {
t.Fatal(err)
}
if err := CopyTree(filepath.Join(oldStage, "vault"), vault); err != nil {
t.Fatal(err)
}
os.MkdirAll(filepath.Join(data, "backups"), 0700)
saved := filepath.Join(data, "backups/old.zip")
if err := copyFile(oldArchive, saved, 0600); err != nil {
t.Fatal(err)
}
upload := filepath.Join(data, ".maintenance/uploads/test/stage")
os.MkdirAll(upload, 0700)
newArchive := fullArchive(t, t.TempDir(), "new")
if _, err := Extract(context.Background(), newArchive, upload); err != nil {
t.Fatal(err)
}
state := mode
if mode == "invalid-source" {
state = "prepared"
writeTest(t, filepath.Join(upload, "app/rawmark.db"), "invalid SQLite")
}
if mode == "applying" || mode == "verifying" {
writeTest(t, filepath.Join(vault, "note.md"), "partial")
}
if err := WriteRecord(data, RestoreRecord{State: state, Stage: upload, Backup: saved}); err != nil {
t.Fatal(err)
}
err := Recover(data, vault)
expected := "old"
if mode == "prepared" {
if err != nil {
t.Fatal(err)
}
if pending, _ := Pending(data); !pending {
t.Fatal("验证启动前不应提交")
}
if err = FinishRecovery(data); err != nil {
t.Fatal(err)
}
expected = "new"
} else if err == nil {
t.Fatal("回退应保留失败原因")
}
body, err := os.ReadFile(filepath.Join(vault, "note.md"))
if err != nil || string(body) != expected {
t.Fatalf("恢复内容错误: %q %v", body, err)
}
if pending, err := Pending(data); err != nil || pending {
t.Fatalf("回退或提交后仍有记录: %v", err)
}
if err = CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil {
t.Fatal(err)
}
})
}
}
func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum", "directory-settings"} {
t.Run(mode, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "invalid.zip")
f, _ := os.Create(file)
z := zip.NewWriter(f)
switch mode {
case "traversal":
out, _ := z.Create("../outside")
out.Write([]byte("bad"))
case "duplicate":
z.Create("settings.json")
z.Create("settings.json")
case "symlink":
header := &zip.FileHeader{Name: "app/fonts/link"}
header.SetMode(os.ModeSymlink | 0777)
out, _ := z.CreateHeader(header)
out.Write([]byte("/etc/passwd"))
case "checksum":
out, _ := z.Create("settings.json")
out.Write([]byte("{}"))
manifest, _ := z.Create("manifest.json")
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","size":2,"sha256":"`+strings.Repeat("0", 64)+`"}]}`)
case "directory-settings":
z.Create("settings.json/")
manifest, _ := z.Create("manifest.json")
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","dir":true,"size":0}]}`)
}
z.Close()
f.Close()
if _, err := Extract(context.Background(), file, t.TempDir()); !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("无效归档未归类为输入错误: %v", err)
}
})
}
}
func TestArchiveExtractionDistinguishesDiskFailure(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "settings.json")
writeTest(t, source, "{}")
archive := filepath.Join(dir, "settings.zip")
if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", source}}, nil); err != nil {
t.Fatal(err)
}
blocked := filepath.Join(dir, "not-a-directory")
writeTest(t, blocked, "block extraction")
_, err := Extract(context.Background(), archive, blocked)
var pathError *os.PathError
if !errors.As(err, &pathError) || errors.Is(err, ErrInvalidArchive) {
t.Fatalf("解压磁盘故障被误判为归档内容无效: %v", err)
}
}
func TestSettingsInterruptedImportRestoresPreferencesAndResources(t *testing.T) {
data := t.TempDir()
database(t, filepath.Join(data, "rawmark.db"))
st, _ := store.OpenExisting(filepath.Join(data, "rawmark.db"))
st.SetSetting("app.settings", `{"fontSize":18}`)
source := t.TempDir()
writeTest(t, filepath.Join(source, "settings.json"), `{"app.settings":{"fontSize":18},"plugin.data.example":{"content":"old"}}`)
writeTest(t, filepath.Join(source, "fonts/original/font.woff2"), "old-font")
os.MkdirAll(filepath.Join(data, "backups"), 0700)
archive := filepath.Join(data, "backups/settings.zip")
if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", filepath.Join(source, "settings.json")}, {"app/fonts", filepath.Join(source, "fonts")}}, nil); err != nil {
t.Fatal(err)
}
st.SetSetting("app.settings", `{"fontSize":24}`)
st.Close()
writeTest(t, filepath.Join(data, "fonts/new/font.woff2"), "new-font")
stage := filepath.Join(data, ".maintenance/uploads/test/stage")
os.MkdirAll(stage, 0700)
if err := WriteRecord(data, RestoreRecord{State: "applying", Kind: "settings", Stage: stage, Backup: archive}); err != nil {
t.Fatal(err)
}
if err := Recover(data, filepath.Join(data, "vault")); err == nil {
t.Fatal("应报告中断并回退")
}
st, _ = store.OpenExisting(filepath.Join(data, "rawmark.db"))
defer st.Close()
value, _, err := st.Setting("app.settings")
if err != nil || value != `{"fontSize":18}` {
t.Fatal("偏好未回退")
}
if body, err := os.ReadFile(filepath.Join(data, "fonts/original/font.woff2")); err != nil || string(body) != "old-font" {
t.Fatal("资源未回退")
}
if _, err := os.Stat(filepath.Join(data, "fonts/new")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("残留新资源")
}
}
func TestLogRotationIsBounded(t *testing.T) {
dir := t.TempDir()
log, err := OpenLog(dir)
if err != nil {
t.Fatal(err)
}
defer log.Close()
chunk := []byte(strings.Repeat("a", 6<<20))
for i := 0; i < 5; i++ {
if _, err = log.Write(chunk); err != nil {
t.Fatal(err)
}
}
dest := t.TempDir()
if err = log.Snapshot(dest); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Fatalf("轮转文件数错误: %d", len(entries))
}
for _, entry := range entries {
info, _ := entry.Info()
if info.Size() > 10<<20 || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
t.Fatalf("日志未限量或权限不符: %+v", info)
}
}
}