312 lines
7.8 KiB
Go
312 lines
7.8 KiB
Go
package notes
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"verstak/internal/core/files"
|
|
"verstak/internal/core/nodes"
|
|
"verstak/internal/core/storage"
|
|
)
|
|
|
|
func setupRenameService(t *testing.T) (*Service, *nodes.Repository, string) {
|
|
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() })
|
|
nodeRepo := nodes.NewRepository(db)
|
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
|
return svc, nodeRepo, dir
|
|
}
|
|
|
|
func TestRenameNote(t *testing.T) {
|
|
svc, nodeRepo, _ := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "Original Title", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Rename
|
|
err = svc.Rename(node.ID, "New Title")
|
|
if err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
|
|
// Verify node title updated
|
|
updated, err := nodeRepo.GetActive(node.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetActive: %v", err)
|
|
}
|
|
if updated.Title != "New Title" {
|
|
t.Errorf("title = %q, want %q", updated.Title, "New Title")
|
|
}
|
|
|
|
// Verify slug updated
|
|
if !strings.Contains(updated.Slug, "new") {
|
|
t.Errorf("slug = %q, want containing 'new'", updated.Slug)
|
|
}
|
|
}
|
|
|
|
func TestRenameNoteRenamesFile(t *testing.T) {
|
|
svc, nodeRepo, vaultRoot := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "Original Title", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Write content.
|
|
content := "# Original Title\n\nSome content."
|
|
if err := svc.Save(node.ID, content); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
// Rename should rename the file on disk.
|
|
if err := svc.Rename(node.ID, "Renamed Title"); err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
|
|
// Verify node title updated.
|
|
updated, err := nodeRepo.GetActive(node.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetActive: %v", err)
|
|
}
|
|
if updated.Title != "Renamed Title" {
|
|
t.Errorf("title = %q, want %q", updated.Title, "Renamed Title")
|
|
}
|
|
|
|
// Verify old file no longer exists.
|
|
oldPath := filepath.Join(vaultRoot, "Notes", "Original Title.md")
|
|
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
|
t.Error("old file should not exist after rename")
|
|
}
|
|
|
|
// Verify new file exists with correct content.
|
|
newPath := filepath.Join(vaultRoot, "Notes", "Renamed Title.md")
|
|
data, err := os.ReadFile(newPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
if string(data) != content {
|
|
t.Errorf("content = %q, want %q", string(data), content)
|
|
}
|
|
}
|
|
|
|
func TestRenameNoteCollisionRejected(t *testing.T) {
|
|
svc, _, _ := setupRenameService(t)
|
|
|
|
// Create two notes.
|
|
node1, _, err := svc.Create("", "Note Alpha", "")
|
|
if err != nil {
|
|
t.Fatalf("Create note1: %v", err)
|
|
}
|
|
_, _, err = svc.Create("", "Note Beta", "")
|
|
if err != nil {
|
|
t.Fatalf("Create note2: %v", err)
|
|
}
|
|
|
|
// Renaming note1 to "Note Beta" should fail — file already exists.
|
|
err = svc.Rename(node1.ID, "Note Beta")
|
|
if err == nil {
|
|
t.Error("expected error for rename collision, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "already exists") {
|
|
t.Errorf("expected 'already exists' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenameNoteEmptyTitle(t *testing.T) {
|
|
svc, _, _ := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "Valid Title", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Try to rename with empty title — should fail
|
|
err = svc.Rename(node.ID, "")
|
|
if err == nil {
|
|
t.Error("expected error for empty title")
|
|
}
|
|
}
|
|
|
|
func TestDeleteNoteSoftDeletesNode(t *testing.T) {
|
|
svc, nodeRepo, _ := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "To Delete", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Delete
|
|
if err := svc.Delete(node.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
// Verify node is soft-deleted
|
|
_, err = nodeRepo.GetActive(node.ID)
|
|
if err == nil {
|
|
t.Error("expected deleted node to be inactive")
|
|
}
|
|
}
|
|
|
|
func TestDeleteNoteDoesNotAffectOtherNotes(t *testing.T) {
|
|
svc, _, vaultRoot := setupRenameService(t)
|
|
|
|
// Create two notes
|
|
node1, _, err := svc.Create("", "Note One", "")
|
|
if err != nil {
|
|
t.Fatalf("Create note1: %v", err)
|
|
}
|
|
node2, _, err := svc.Create("", "Note Two", "")
|
|
if err != nil {
|
|
t.Fatalf("Create note2: %v", err)
|
|
}
|
|
|
|
// Save content to both
|
|
svc.Save(node1.ID, "content one")
|
|
svc.Save(node2.ID, "content two")
|
|
|
|
// Delete note1
|
|
if err := svc.Delete(node1.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
// Verify note2 content is still readable
|
|
content, err := svc.Read(node2.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read note2: %v", err)
|
|
}
|
|
if content != "content two" {
|
|
t.Errorf("note2 content = %q, want %q", content, "content two")
|
|
}
|
|
|
|
_ = vaultRoot
|
|
}
|
|
|
|
func TestPathTraversalBlocked(t *testing.T) {
|
|
svc, _, vaultRoot := setupRenameService(t)
|
|
|
|
// Try to create a note with path traversal in title
|
|
node, _, err := svc.Create("", "../../../etc/passwd", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Verify the file was created with sanitized name, not traversing
|
|
content, err := svc.Read(node.ID)
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
_ = content
|
|
|
|
// Check that no file exists outside vault
|
|
suspicious := filepath.Join(vaultRoot, "..", "..", "..", "etc", "passwd.md")
|
|
if _, err := os.Stat(suspicious); err == nil {
|
|
t.Error("path traversal succeeded — file created outside vault")
|
|
}
|
|
}
|
|
|
|
func TestDeleteNoteMovesFileToSharedTrash(t *testing.T) {
|
|
svc, _, vaultRoot := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "To Delete", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Write content
|
|
content := "# To Delete\n\nThis content should survive deletion."
|
|
if err := svc.Save(node.ID, content); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
// Get the file record to know the trash file name.
|
|
rec, err := svc.Load(node.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
fileRec, err := svc.files.Get(rec.FileID)
|
|
if err != nil {
|
|
t.Fatalf("Get file: %v", err)
|
|
}
|
|
|
|
// Delete (soft-delete + move to shared trash)
|
|
if err := svc.Delete(node.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
// Verify original file no longer exists at original location
|
|
origPath := filepath.Join(vaultRoot, "Notes", "To Delete.md")
|
|
if _, err := os.Stat(origPath); !os.IsNotExist(err) {
|
|
t.Error("original file should not exist at original location after delete")
|
|
}
|
|
|
|
// Verify file exists in shared trash (not in trash/notes/)
|
|
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
|
|
trashFile := filepath.Join(trashDir, fileRec.ID+"_"+fileRec.Filename)
|
|
data, err := os.ReadFile(trashFile)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile trash: %v", err)
|
|
}
|
|
if string(data) != content {
|
|
t.Errorf("trash content = %q, want %q", string(data), content)
|
|
}
|
|
|
|
// Verify file record is marked missing=1
|
|
updatedRec, err := svc.files.Get(rec.FileID)
|
|
if err != nil {
|
|
t.Fatalf("Get file after delete: %v", err)
|
|
}
|
|
if !updatedRec.Missing {
|
|
t.Error("file record should be marked missing=1 after delete")
|
|
}
|
|
}
|
|
|
|
func TestDeleteNoteNoActiveNodeForOrphan(t *testing.T) {
|
|
svc, nodeRepo, vaultRoot := setupRenameService(t)
|
|
|
|
node, _, err := svc.Create("", "Orphan Test", "")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Delete
|
|
if err := svc.Delete(node.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
// Node should not be active
|
|
_, err = nodeRepo.GetActive(node.ID)
|
|
if err == nil {
|
|
t.Error("deleted node should not be returned by GetActive")
|
|
}
|
|
|
|
// File should be in shared trash, not in Notes/
|
|
notesPath := filepath.Join(vaultRoot, "Notes", "Orphan Test.md")
|
|
if _, err := os.Stat(notesPath); !os.IsNotExist(err) {
|
|
t.Error("file should not remain in Notes/ after delete")
|
|
}
|
|
|
|
// Verify file is in shared trash (not trash/notes/)
|
|
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
|
|
entries, _ := os.ReadDir(trashDir)
|
|
found := false
|
|
for _, e := range entries {
|
|
if e.Name() != "" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("file should exist in shared trash/")
|
|
}
|
|
}
|