package workspace import ( "context" "os" "path/filepath" "strings" "testing" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) // ── Path Cleaning Tests ───────────────────── func TestCleanPath(t *testing.T) { tests := []struct { input string want string }{ {"src/main.go", "src/main.go"}, {"/src/main.go", "src/main.go"}, {"./src/main.go", "src/main.go"}, {"src/../etc/passwd", "etc/passwd"}, {"../etc/passwd", ""}, {"../..", ""}, {"..", ""}, {".", ""}, {"", ""}, {" src/main.go ", "src/main.go"}, {"src/./main.go", "src/main.go"}, {".gitignore", ".gitignore"}, {".env", ".env"}, {"src/.hidden/file.go", "src/.hidden/file.go"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := cleanPath(tt.input) if got != tt.want { t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestAbsPathTraversal(t *testing.T) { tmpDir := t.TempDir() fs := &FS{basePath: tmpDir} w := &models.Workspace{ BaseModel: models.BaseModel{ID: "test-workspace"}, } // Create the files dir so absPath has a real root to resolve against filesDir := filepath.Join(tmpDir, w.ID, filesSubdir) os.MkdirAll(filesDir, 0750) // Valid paths should work validPaths := []string{ "src/main.go", "README.md", ".gitignore", "deeply/nested/path/file.txt", } for _, p := range validPaths { abs, err := fs.absPath(w, p) if err != nil { t.Errorf("absPath(%q) unexpected error: %v", p, err) continue } if !strings.HasPrefix(abs, filesDir) { t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir) } } // Traversal attempts should fail traversalPaths := []string{ "../../../etc/passwd", "src/../../etc/passwd", } for _, p := range traversalPaths { _, err := fs.absPath(w, p) if err == nil { t.Errorf("absPath(%q) should have returned error for traversal", p) } } } // ── Content Type Detection Tests ──────────── func TestMimeByExtension(t *testing.T) { tests := []struct { ext string want string }{ {".go", "text/x-go"}, {".py", "text/x-python"}, {".rs", "text/x-rust"}, {".ts", "text/typescript"}, {".sh", "text/x-shellscript"}, {".sql", "text/x-sql"}, {".yaml", "text/yaml"}, {".yml", "text/yaml"}, {".toml", "application/toml"}, {".pl", "text/x-perl"}, {".pm", "text/x-perl"}, {".lua", "text/x-lua"}, {".dockerfile", "text/x-dockerfile"}, {".unknown", ""}, } for _, tt := range tests { t.Run(tt.ext, func(t *testing.T) { got := mimeByExtension(tt.ext) if got != tt.want { t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want) } }) } } // ── Unsafe Path Tests ─────────────────────── func TestIsUnsafePath(t *testing.T) { tests := []struct { path string unsafe bool }{ {"src/main.go", false}, {".gitignore", false}, {".env", false}, {"../etc/passwd", true}, {"/etc/passwd", true}, {"src/../../../etc/passwd", true}, {"\\windows\\system32", true}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { got := isUnsafePath(tt.path) if got != tt.unsafe { t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe) } }) } } // ── Common Prefix Detection Tests ─────────── func TestDetectCommonPrefix(t *testing.T) { tests := []struct { name string names []string want string }{ { name: "common prefix", names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"}, want: "project/", }, { name: "no common prefix", names: []string{"src/a.go", "lib/b.go"}, want: "", }, { name: "file at root", names: []string{"project/src/a.go", "Makefile"}, want: "", }, { name: "empty", names: []string{}, want: "", }, { name: "single dir", names: []string{"myproject/file.txt"}, want: "myproject/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := detectCommonPrefix(tt.names) if got != tt.want { t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want) } }) } } // ── Integration: Write + Read Round-Trip ──── type mockWorkspaceStore struct { files map[string]*models.WorkspaceFile } func newMockStore() *mockWorkspaceStore { return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)} } func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil } func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { return nil, nil } func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { return nil } func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil } func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { return nil, nil } func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { return nil, nil } func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { return nil, nil } func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { key := f.WorkspaceID + ":" + f.Path m.files[key] = f return nil } func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { delete(m.files, workspaceID+":"+path) return nil } func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { for k := range m.files { if strings.HasPrefix(k, workspaceID+":"+prefix) { delete(m.files, k) } } return nil } func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { f, ok := m.files[workspaceID+":"+path] if !ok { return nil, os.ErrNotExist } return f, nil } func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { var out []models.WorkspaceFile for _, f := range m.files { if f.WorkspaceID == workspaceID { if prefix == "" || strings.HasPrefix(f.Path, prefix) { out = append(out, *f) } } } return out, nil } func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { for k, f := range m.files { if f.WorkspaceID == workspaceID { delete(m.files, k) } } return nil } // Chunk methods (v0.21.2) — no-ops for FS tests func (m *mockWorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { return nil } func (m *mockWorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { return nil } func (m *mockWorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { return nil, nil } func (m *mockWorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { return nil } func (m *mockWorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { return nil } func TestWriteReadRoundTrip(t *testing.T) { tmpDir := t.TempDir() mock := newMockStore() wfs := NewFS(tmpDir, mock) w := &models.Workspace{ BaseModel: models.BaseModel{ID: "test-ws"}, Status: "active", } if err := wfs.CreateDir(w); err != nil { t.Fatalf("CreateDir: %v", err) } ctx := context.Background() content := "package main\n\nfunc main() {}\n" // Write err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content))) if err != nil { t.Fatalf("WriteFile: %v", err) } // Verify DB index was updated f, ok := mock.files["test-ws:src/main.go"] if !ok { t.Fatal("expected file in mock store index") } if f.ContentType != "text/x-go" { t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go") } if f.SizeBytes != int64(len(content)) { t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content)) } if f.SHA256 == "" { t.Error("expected non-empty sha256") } // Read back rc, size, err := wfs.ReadFile(ctx, w, "src/main.go") if err != nil { t.Fatalf("ReadFile: %v", err) } defer rc.Close() if size != int64(len(content)) { t.Errorf("read size = %d, want %d", size, len(content)) } buf := make([]byte, size) if _, err := rc.Read(buf); err != nil { t.Fatalf("Read: %v", err) } if string(buf) != content { t.Errorf("content = %q, want %q", string(buf), content) } } func TestDeleteFile(t *testing.T) { tmpDir := t.TempDir() mock := newMockStore() wfs := NewFS(tmpDir, mock) w := &models.Workspace{ BaseModel: models.BaseModel{ID: "test-ws"}, } wfs.CreateDir(w) ctx := context.Background() content := "hello" wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content))) // Delete err := wfs.DeleteFile(ctx, w, "test.txt", false) if err != nil { t.Fatalf("DeleteFile: %v", err) } // Verify removed from index if _, ok := mock.files["test-ws:test.txt"]; ok { t.Error("expected file removed from index") } // Verify removed from filesystem abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt") if _, err := os.Stat(abs); !os.IsNotExist(err) { t.Error("expected file removed from filesystem") } } func TestMkdir(t *testing.T) { tmpDir := t.TempDir() mock := newMockStore() wfs := NewFS(tmpDir, mock) w := &models.Workspace{ BaseModel: models.BaseModel{ID: "test-ws"}, } wfs.CreateDir(w) ctx := context.Background() err := wfs.Mkdir(ctx, w, "src/pkg/handlers") if err != nil { t.Fatalf("Mkdir: %v", err) } // Verify on disk abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers") info, err := os.Stat(abs) if err != nil { t.Fatalf("stat: %v", err) } if !info.IsDir() { t.Error("expected directory") } // Verify in index f, ok := mock.files["test-ws:src/pkg/handlers"] if !ok { t.Fatal("expected dir in mock store") } if !f.IsDirectory { t.Error("expected is_directory = true") } }