verstak/internal/core/files/file_test.go

360 lines
9.4 KiB
Go

package files
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestAddExternal(t *testing.T) {
db := openTestDB(t)
// Run migration 002 manually since storage.Open already applied it.
// We can verify the table exists by inserting.
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
// Create a real temp file to register.
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte("hello world"), 0o640); err != nil {
t.Fatal(err)
}
rec, err := filesSvc.AddExternal("node-1", tmpFile)
if err != nil {
t.Fatalf("AddExternal: %v", err)
}
if rec.ID == "" {
t.Fatal("empty id")
}
if rec.Filename != "test.txt" {
t.Errorf("filename = %q", rec.Filename)
}
if rec.StorageMode != "external" {
t.Errorf("mode = %q", rec.StorageMode)
}
if rec.Size != 11 {
t.Errorf("size = %d, want 11", rec.Size)
}
// Verify stored.
got, err := filesSvc.Get(rec.ID)
if err != nil {
t.Fatal(err)
}
if got.Filename != "test.txt" {
t.Errorf("got filename = %q", got.Filename)
}
}
func TestCopyIntoVault(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
// Source file.
srcDir := t.TempDir()
srcFile := filepath.Join(srcDir, "doc.pdf")
os.WriteFile(srcFile, []byte("PDF content here"), 0o640)
rec, err := svc.CopyIntoVault("node-1", srcFile, "")
if err != nil {
t.Fatalf("CopyIntoVault: %v", err)
}
if rec.SHA256 == "" {
t.Error("expected sha256")
}
if rec.StorageMode != "vault" {
t.Errorf("mode = %q", rec.StorageMode)
}
// Verify file on disk.
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); err != nil {
t.Errorf("file on disk: %v", err)
}
}
func TestListByNode(t *testing.T) {
db := openTestDB(t)
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
f1 := filepath.Join(t.TempDir(), "a1.txt")
f2 := filepath.Join(t.TempDir(), "a2.txt")
os.WriteFile(f1, []byte("a"), 0o640)
os.WriteFile(f2, []byte("bb"), 0o640)
svc.AddExternal("node-a", f1)
svc.AddExternal("node-a", f2)
list, err := svc.ListByNode("node-a")
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Errorf("list len = %d, want 2", len(list))
}
}
func TestDeleteToTrash(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
src := filepath.Join(t.TempDir(), "important.pdf")
os.WriteFile(src, []byte("important data"), 0o640)
rec, _ := svc.CopyIntoVault("node-x", src, "")
if err := svc.DeleteToTrash(rec.ID); err != nil {
t.Fatalf("DeleteToTrash: %v", err)
}
// File record should be gone.
if _, err := svc.Get(rec.ID); err == nil {
t.Error("expected error after trash")
}
// Original file should not exist anymore (moved to trash).
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); !os.IsNotExist(err) {
t.Error("expected file to be moved from original location")
}
// Trash dir should have it.
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
entries, _ := os.ReadDir(trashDir)
if len(entries) != 1 {
t.Errorf("trash entries = %d, want 1", len(entries))
}
}
func TestAddPathCopySingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
src := filepath.Join(t.TempDir(), "doc.pdf")
os.WriteFile(src, []byte("file content"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, src)
if err != nil {
t.Fatalf("AddPathCopy: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
if nodes[0].Type != "file" {
t.Errorf("type = %q", nodes[0].Type)
}
// Source intact.
if _, err := os.Stat(src); err != nil {
t.Error("source should remain intact")
}
// File record created.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Errorf("file records = %d", len(records))
}
}
func TestAddPathLinkSingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
src := filepath.Join(t.TempDir(), "linked.pdf")
os.WriteFile(src, []byte("linked"), 0o640)
nodes, err := svc.AddPathLink(parent.ID, src)
if err != nil {
t.Fatalf("AddPathLink: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
// File record should have external storage mode.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Fatalf("file records = %d", len(records))
}
if records[0].StorageMode != "external" {
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
}
}
func TestAddPathCopyDirectory(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
if err != nil {
t.Fatalf("AddPathCopy dir: %v", err)
}
// Should create: folder node + file node + sub folder node + file node in sub.
if len(nodes) < 3 {
t.Errorf("expected 3+ nodes, got %d", len(nodes))
}
// Verify structure: root folder + children.
var folders, files int
for i := range nodes {
if nodes[i].Type == "folder" {
folders++
} else {
files++
}
}
if folders < 1 {
t.Error("expected at least 1 folder")
}
if files < 1 {
t.Error("expected at least 1 file")
}
}
func TestDeleteNodeAndChildren(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create(nil, "case", "To Delete", 0, "", "")
child, _ := nodeRepo.Create(&parent.ID, "file", "child.txt", 0, "", "")
// Add file record to child.
src := filepath.Join(t.TempDir(), "child.txt")
os.WriteFile(src, []byte("data"), 0o640)
svc.CopyIntoVault(child.ID, src, "")
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
t.Fatalf("DeleteNodeAndChildren: %v", err)
}
// Parent should be soft-deleted.
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
t.Error("parent should be deleted")
}
// Child should be soft-deleted.
if _, err := nodeRepo.GetActive(child.ID); err == nil {
t.Error("child should be deleted")
}
}
func TestNameConflict(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create(nil, "case", "Test", 0, "", "")
src := filepath.Join(t.TempDir(), "conflict.pdf")
os.WriteFile(src, []byte("data"), 0o640)
// Import twice with same filename.
n1, _ := svc.AddPathCopy(parent.ID, src)
n2, _ := svc.AddPathCopy(parent.ID, src)
if n1[0].Title == n2[0].Title {
t.Error("expected unique name on conflict")
}
if n2[0].Title == "conflict.pdf" {
t.Errorf("title unchanged = %q", n2[0].Title)
}
}
func TestPreviewImportDir(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
sum, err := svc.PreviewImport(srcDir)
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
if sum.Files != 2 {
t.Errorf("files = %d, want 2", sum.Files)
}
if sum.Folders != 2 { // root + sub
t.Errorf("folders = %d, want 2", sum.Folders)
}
}
func TestVaultPathSiblingPrefixEscape(t *testing.T) {
vaultRoot := "/tmp/vault"
svc := &Service{vaultRoot: vaultRoot}
// Normal path should pass
_, err := svc.vaultPath("some/file.txt")
if err != nil {
t.Fatalf("expected OK, got: %v", err)
}
// Sibling-prefix escape should fail
// vault=/tmp/vault, path goes to /tmp/vault_evil -> should be rejected
_, err = svc.vaultPath("../vault_evil/file.txt")
if err == nil {
t.Error("expected error for sibling-prefix escape, got nil")
}
// Direct escape with ../..
_, err = svc.vaultPath("../../etc/passwd")
if err == nil {
t.Error("expected error for path escape, got nil")
}
// Absolute path
_, err = svc.vaultPath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path, got nil")
}
// Empty path
_, err = svc.vaultPath("")
if err == nil {
t.Error("expected error for empty path, got nil")
}
}
func TestGuessMIME(t *testing.T) {
cases := map[string]string{
"a.md": "text/plain",
"a.png": "image/png",
"a.pdf": "application/pdf",
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"a.go": "text/plain",
"a.unknown": "application/octet-stream",
}
for name, want := range cases {
got := guessMIME(name)
if got != want {
t.Errorf("guessMIME(%q) = %q, want %q", name, got, want)
}
}
}