package vault import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "sync" "testing" "time" ) func TestTrashPurgeFailurePreservesMetadata(t *testing.T) { if os.Geteuid() == 0 { t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") } for _, operation := range []string{"single", "all", "expired"} { for _, blocked := range []string{"payload", "parent"} { t.Run(operation+"/"+blocked, func(t *testing.T) { v := newVault(t) if _, err := v.Write("folder/locked/note.md", []byte("content"), Precondition{}); err != nil { t.Fatal(err) } setIcon(t, v, "folder", &TreeIcon{Kind: "emoji", Value: "📚"}) item, err := v.Delete("folder") if err != nil { t.Fatal(err) } metaPath := filepath.Join(v.trash, item.ID, "meta.json") data, err := os.ReadFile(metaPath) if err != nil { t.Fatal(err) } var metadata trashMetadata if err := json.Unmarshal(data, &metadata); err != nil { t.Fatal(err) } metadata.DeletedAt = time.Now().Add(-40 * 24 * time.Hour).UnixMilli() data, err = json.Marshal(metadata) if err != nil { t.Fatal(err) } if err := os.WriteFile(metaPath, data, 0600); err != nil { t.Fatal(err) } locked := filepath.Join(v.trash, item.ID, "folder", "locked") if blocked == "parent" { locked = v.trash } if err := os.Chmod(locked, 0500); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(locked, 0755) }) purge := func() error { if operation == "single" { return v.Purge(item.ID) } days := int64(0) if operation == "expired" { days = 30 } result, err := v.PurgeTrash(days, time.Now()) if err == nil && len(result.Failed) > 0 { if len(result.Deleted) != 0 || len(result.Failed) != 1 || result.Failed[0].ID != item.ID { t.Fatalf("部分失败结果错误: %+v", result) } return errors.New(result.Failed[0].Error) } return err } if err := purge(); err == nil { t.Fatal("权限错误应报告失败") } after, err := os.ReadFile(metaPath) if err != nil || !bytes.Equal(data, after) { t.Fatalf("删除失败必须保留完整元数据: %v", err) } items, err := v.TrashList() if err != nil || len(items) != 1 || items[0].ID != item.ID { t.Fatalf("删除失败的条目必须仍可列出: %+v %v", items, err) } if err := os.Chmod(locked, 0755); err != nil { t.Fatal(err) } if err := purge(); err != nil { t.Fatalf("修复权限后应可继续清理: %v", err) } if _, err := os.Stat(filepath.Dir(metaPath)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("成功清理后不应残留目录: %v", err) } }) } } } func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem { t.Helper() if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil { t.Fatal(err) } item, err := v.Delete(path) if err != nil { t.Fatal(err) } meta, err := v.readTrashMetadata(item.ID) if err != nil { t.Fatal(err) } meta.DeletedAt = when.UnixMilli() data, err := json.Marshal(meta) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(v.trash, item.ID, "meta.json"), data, 0600); err != nil { t.Fatal(err) } return meta.TrashItem } func TestTrashRetentionAndInvalidMetadata(t *testing.T) { v := newVault(t) now := time.Now() old := trashAt(t, v, "old.md", now.Add(-31*24*time.Hour)) boundary := trashAt(t, v, "boundary.md", now.Add(-30*24*time.Hour)) fresh := trashAt(t, v, "fresh.md", now.Add(-29*24*time.Hour)) future := trashAt(t, v, "future.md", now.Add(time.Hour)) broken := trashAt(t, v, "broken.md", now.Add(-40*24*time.Hour)) if err := os.WriteFile(filepath.Join(v.trash, broken.ID, "meta.json"), []byte(`{"deletedAt":0}`), 0600); err != nil { t.Fatal(err) } result, err := v.PurgeTrash(30, now) if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 || result.Failed[0].ID != broken.ID { t.Fatalf("结果异常: %+v, %v", result, err) } for _, item := range []TrashItem{old, boundary} { if _, err := os.Stat(filepath.Join(v.trash, item.ID)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("过期条目未删除: %v", err) } } for _, item := range []TrashItem{fresh, future, broken} { if _, err := os.Stat(filepath.Join(v.trash, item.ID)); err != nil { t.Fatalf("不应删除未过期/无效条目: %v", err) } } // 极大天数不应溢出成过期期限。 result, err = v.PurgeTrash(1<<53-1, now) if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 { t.Fatalf("极大期限: %+v, %v", result, err) } result, err = v.PurgeTrash(0, now) if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 { t.Fatalf("清空全部应独立报告损坏条目: %+v, %v", result, err) } } func TestTrashRestoreAndPurgeSerialize(t *testing.T) { for range 20 { v := newVault(t) item := trashAt(t, v, "note.md", time.Now().Add(-40*24*time.Hour)) var restoreErr, purgeErr error var result TrashPurgeResult var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); _, restoreErr = v.Restore(item.ID) }() go func() { defer wg.Done(); result, purgeErr = v.PurgeTrash(30, time.Now()) }() wg.Wait() if purgeErr != nil || len(result.Failed) != 0 { t.Fatalf("并发清理失败: %+v %v", result, purgeErr) } if restoreErr == nil { if len(result.Deleted) != 0 { t.Fatal("已恢复的内容不可再次清理") } if body, err := os.ReadFile(filepath.Join(v.Root(), item.Path)); err != nil || string(body) != "content" { t.Fatalf("恢复内容丢失: %v", err) } } else if !errors.Is(restoreErr, ErrNotFound) || len(result.Deleted) != 1 { t.Fatalf("清理先完成时应明确不存在: %v %+v", restoreErr, result) } } }