feat: harden workspace file APIs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c1b8982e3b
commit
04b309d3fc
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
@@ -112,3 +113,24 @@ func (h *fileHandler) rename(c *gin.Context) {
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) stat(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
info, err := h.svc.Stat(id, path)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.FileInfoResponse{
|
||||
Name: info.Name,
|
||||
Path: info.Path,
|
||||
IsDir: info.IsDir,
|
||||
Size: info.Size,
|
||||
ModTime: info.ModTime.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ func NewRouter(workspaces *service.WorkspaceService, files *service.FileService,
|
||||
r.GET("/healthz", healthHandler)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.GET("/workspaces", wsHandler.list)
|
||||
api.POST("/workspaces", wsHandler.create)
|
||||
api.GET("/workspaces/:id", wsHandler.get)
|
||||
api.DELETE("/workspaces/:id", wsHandler.delete)
|
||||
|
||||
api.GET("/workspaces/:id/files", fileHandler.list)
|
||||
api.GET("/workspaces/:id/files/read", fileHandler.read)
|
||||
api.GET("/workspaces/:id/files/stat", fileHandler.stat)
|
||||
api.PUT("/workspaces/:id/files/write", fileHandler.write)
|
||||
api.POST("/workspaces/:id/files/mkdir", fileHandler.mkdir)
|
||||
api.DELETE("/workspaces/:id/files", fileHandler.remove)
|
||||
|
||||
+207
-27
@@ -13,16 +13,67 @@ import (
|
||||
)
|
||||
|
||||
func setupTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return setupTestRouterWithMaxWriteBytes(t, 1<<20)
|
||||
}
|
||||
|
||||
func setupTestRouterWithMaxWriteBytes(t *testing.T, maxWriteBytes int64) http.Handler {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
wsMgr := workspace.NewLocalManager(root)
|
||||
procMgr := process.NewManager("")
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil)
|
||||
fileSvc := service.NewFileService(wsMgr)
|
||||
fileSvc := service.NewFileService(wsMgr, maxWriteBytes)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc, nil)
|
||||
}
|
||||
|
||||
func createWorkspaceForTest(t *testing.T, router http.Handler, id string) {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(map[string]string{"id": id})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace %q: status %d, body %s", id, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileForTest(t *testing.T, router http.Handler, id, path, content string) {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(map[string]string{"path": path, "content": content})
|
||||
req := httptest.NewRequest("PUT", "/api/workspaces/"+id+"/files/write", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("write %q: status %d, body %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertErrorResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status = %d, want %d; body %s", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Error.Code != code {
|
||||
t.Fatalf("code = %q, want %q", body.Error.Code, code)
|
||||
}
|
||||
if body.Error.Message == "" {
|
||||
t.Fatal("error message is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
req := httptest.NewRequest("GET", "/healthz", nil)
|
||||
@@ -64,32 +115,47 @@ func TestCreateWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadFile(t *testing.T) {
|
||||
func TestListWorkspaces(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
for _, id := range []string{"beta", "alpha"} {
|
||||
payload, _ := json.Marshal(map[string]string{"id": id})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create %s: %d %s", id, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace first.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req := httptest.NewRequest("GET", "/api/workspaces", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Write file.
|
||||
writePayload, _ := json.Marshal(map[string]string{"path": "main.go", "content": "package main\n"})
|
||||
req = httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(writePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("write file: status=%d want=%d body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
var resp struct {
|
||||
Workspaces []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"workspaces"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Workspaces) != 2 || resp.Workspaces[0].ID != "alpha" || resp.Workspaces[1].ID != "beta" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Read file.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=main.go", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
func TestWriteReadFile(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
writeFileForTest(t, router, "user1", "main.go", "package main\n")
|
||||
|
||||
// Read file
|
||||
req := httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=main.go", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read file: status=%d want=%d body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
@@ -104,23 +170,137 @@ func TestWriteReadFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStatus(t *testing.T) {
|
||||
func TestFileStat(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
writeFileForTest(t, router, "user1", "main.go", "package main\n")
|
||||
|
||||
// Create workspace.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req := httptest.NewRequest("GET", "/api/workspaces/user1/files/stat?path=main.go", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Name != "main.go" || resp.Path != "main.go" || resp.IsDir || resp.Size != int64(len("package main\n")) {
|
||||
t.Fatalf("resp=%#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceRootIsRejected(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
req := httptest.NewRequest("DELETE", "/api/workspaces/user1/files?path=.", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileRejectsOversizedContent(t *testing.T) {
|
||||
router := setupTestRouterWithMaxWriteBytes(t, 4)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
// Content length 5 exceeds maxWriteBytes of 4
|
||||
payload, _ := json.Marshal(map[string]string{"path": "big.txt", "content": "12345"})
|
||||
req := httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d", rec.Code)
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest, "bad_request")
|
||||
}
|
||||
|
||||
func TestFileLifecycle(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
|
||||
// Mkdir
|
||||
mkdirPayload, _ := json.Marshal(map[string]string{"path": "dir"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces/user1/files/mkdir", bytes.NewReader(mkdirPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("mkdir status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Get process status.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/process/status", nil)
|
||||
// Write
|
||||
writeFileForTest(t, router, "user1", "dir/old.txt", "hello")
|
||||
|
||||
// List
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files?path=dir", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Rename
|
||||
renamePayload, _ := json.Marshal(map[string]string{"oldPath": "dir/old.txt", "newPath": "dir/new.txt"})
|
||||
req = httptest.NewRequest("POST", "/api/workspaces/user1/files/rename", bytes.NewReader(renamePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("rename status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Read new location
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=dir/new.txt", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Remove
|
||||
req = httptest.NewRequest("DELETE", "/api/workspaces/user1/files?path=dir/new.txt", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponsesAreConsistent(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// Not found workspace
|
||||
req := httptest.NewRequest("GET", "/api/workspaces/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assertErrorResponse(t, rec, http.StatusNotFound, "not_found")
|
||||
|
||||
// Invalid JSON
|
||||
req = httptest.NewRequest("POST", "/api/workspaces", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest, "bad_request")
|
||||
|
||||
// Path escape attempt
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=../secret", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest, "bad_request")
|
||||
}
|
||||
|
||||
func TestProcessStatus(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
createWorkspaceForTest(t, router, "user1")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/workspaces/user1/process/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
|
||||
@@ -31,6 +31,21 @@ func (h *workspaceHandler) create(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) list(c *gin.Context) {
|
||||
workspaces, err := h.svc.List()
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
resp := model.WorkspaceListResponse{
|
||||
Workspaces: make([]model.WorkspaceResponse, 0, len(workspaces)),
|
||||
}
|
||||
for _, ws := range workspaces {
|
||||
resp.Workspaces = append(resp.Workspaces, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ws, err := h.svc.Get(id)
|
||||
|
||||
@@ -117,10 +117,13 @@ func (l *LocalFS) Mkdir(path string) error {
|
||||
|
||||
// Remove removes path (file or directory recursively).
|
||||
func (l *LocalFS) Remove(path string) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
abs, rel, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return util.New(util.CodeBadRequest, "cannot remove workspace root")
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to remove path", err)
|
||||
}
|
||||
|
||||
@@ -135,3 +135,12 @@ func TestLocalFSListSorted(t *testing.T) {
|
||||
t.Errorf("List not sorted: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSRejectsRemoveRoot(t *testing.T) {
|
||||
fsys := NewLocal(t.TempDir())
|
||||
for _, path := range []string{"", "."} {
|
||||
if err := fsys.Remove(path); err == nil {
|
||||
t.Fatalf("Remove(%q) expected error", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,13 @@ type ReadFileResponse struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// FileInfoResponse is the API response for file stat.
|
||||
// Path is always workspace-relative; host absolute paths are never exposed.
|
||||
type FileInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"modTime"`
|
||||
}
|
||||
|
||||
@@ -9,3 +9,9 @@ type CreateWorkspaceRequest struct {
|
||||
type WorkspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// WorkspaceListResponse is the API response for listing workspaces.
|
||||
// Root is never exposed.
|
||||
type WorkspaceListResponse struct {
|
||||
Workspaces []WorkspaceResponse `json:"workspaces"`
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ package service
|
||||
|
||||
import (
|
||||
"codespace/internal/fs"
|
||||
"codespace/internal/util"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
// FileService handles file operations within workspaces.
|
||||
type FileService struct {
|
||||
workspaces workspace.Manager
|
||||
workspaces workspace.Manager
|
||||
maxWriteBytes int64
|
||||
}
|
||||
|
||||
// NewFileService creates a FileService.
|
||||
func NewFileService(workspaces workspace.Manager) *FileService {
|
||||
return &FileService{workspaces: workspaces}
|
||||
// NewFileService creates a FileService with a per-write byte limit.
|
||||
// If maxWriteBytes is zero or negative, a 1 MiB default is applied.
|
||||
func NewFileService(workspaces workspace.Manager, maxWriteBytes int64) *FileService {
|
||||
if maxWriteBytes <= 0 {
|
||||
maxWriteBytes = 1 << 20
|
||||
}
|
||||
return &FileService{workspaces: workspaces, maxWriteBytes: maxWriteBytes}
|
||||
}
|
||||
|
||||
// List lists files at the given workspace-relative path.
|
||||
@@ -34,7 +40,11 @@ func (s *FileService) Read(workspaceID, path string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// Write writes data to a file at the given workspace-relative path.
|
||||
// Writes exceeding maxWriteBytes are rejected before touching the filesystem.
|
||||
func (s *FileService) Write(workspaceID, path string, data []byte) error {
|
||||
if int64(len(data)) > s.maxWriteBytes {
|
||||
return util.New(util.CodeBadRequest, "file exceeds max write size")
|
||||
}
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -38,6 +38,11 @@ func (s *WorkspaceService) Get(id string) (*workspace.Workspace, error) {
|
||||
return s.workspaces.Get(id)
|
||||
}
|
||||
|
||||
// List returns all workspaces sorted by ID.
|
||||
func (s *WorkspaceService) List() ([]workspace.Workspace, error) {
|
||||
return s.workspaces.List()
|
||||
}
|
||||
|
||||
// Delete stops any running process for the workspace, then removes it.
|
||||
func (s *WorkspaceService) Delete(id string) error {
|
||||
status := s.processes.Status(id)
|
||||
|
||||
@@ -3,6 +3,7 @@ package workspace
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ type Manager interface {
|
||||
Create(id string) (*Workspace, error)
|
||||
Get(id string) (*Workspace, error)
|
||||
Delete(id string) error
|
||||
List() ([]Workspace, error)
|
||||
}
|
||||
|
||||
// LocalManager implements Manager using the local filesystem.
|
||||
@@ -71,3 +73,29 @@ func (m *LocalManager) Delete(id string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all workspaces under the manager root, sorted by ID.
|
||||
// Only directories whose names pass ValidID are included.
|
||||
func (m *LocalManager) List() ([]Workspace, error) {
|
||||
if err := os.MkdirAll(m.root, 0o755); err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to ensure workspace root", err)
|
||||
}
|
||||
entries, err := os.ReadDir(m.root)
|
||||
if err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to read workspace root", err)
|
||||
}
|
||||
var result []Workspace
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !ValidID(e.Name()) {
|
||||
continue
|
||||
}
|
||||
result = append(result, Workspace{ID: e.Name(), Root: RootFor(m.root, e.Name())})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalManagerListReturnsSortedWorkspaces(t *testing.T) {
|
||||
mgr := NewLocalManager(t.TempDir())
|
||||
if _, err := mgr.Create("beta"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mgr.Create("alpha"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := mgr.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "alpha" || got[1].ID != "beta" {
|
||||
t.Fatalf("ids = %q, %q; want alpha, beta", got[0].ID, got[1].ID)
|
||||
}
|
||||
if got[0].Root == "" || got[1].Root == "" {
|
||||
t.Fatal("manager list should keep internal roots for service use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalManagerListSkipsUnsafeDirectoryNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "valid"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Contains space, which is invalid per ValidID regex
|
||||
if err := os.Mkdir(filepath.Join(root, "bad name"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := NewLocalManager(root)
|
||||
got, err := mgr.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "valid" {
|
||||
t.Fatalf("got %#v, want only valid", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user