feat: harden workspace file APIs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 17:22:26 +08:00
co-authored by Claude Fable 5
parent c1b8982e3b
commit 04b309d3fc
18 changed files with 1262 additions and 76 deletions
+22
View File
@@ -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),
})
}
+2
View File
@@ -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
View File
@@ -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())
+15
View File
@@ -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)