Changeset 0.21.1 (#86)
This commit is contained in:
388
server/workspace/fs_test.go
Normal file
388
server/workspace/fs_test.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user