package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "codespace/internal/process" "codespace/internal/service" "codespace/internal/shell" "codespace/internal/workspace" "github.com/gin-gonic/gin" ) 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("", nil) shellMgr := shell.NewManager("bash", []string{"-i"}) wsSvc := service.NewWorkspaceService(wsMgr, procMgr, shellMgr, nil) fileSvc := service.NewFileService(wsMgr, maxWriteBytes) procSvc := service.NewProcessService(wsMgr, procMgr, nil) shellSvc := service.NewShellService(wsMgr, shellMgr, nil) acpSvc := service.NewAcpService(procMgr, wsMgr, nil) return NewRouter(wsSvc, fileSvc, procSvc, shellSvc, acpSvc, nil, gin.TestMode) } 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) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if body["status"] != "ok" { t.Errorf("status = %q, want ok", body["status"]) } } func TestCreateWorkspace(t *testing.T) { router := setupTestRouter(t) payload, _ := json.Marshal(map[string]string{"id": "user1"}) 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("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp["id"] != "user1" { t.Errorf("id = %q, want user1", resp["id"]) } } 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()) } } req := httptest.NewRequest("GET", "/api/workspaces", 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 { 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) } } 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()) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp["content"] != "package main\n" { t.Errorf("content = %q, want 'package main\\n'", resp["content"]) } } func TestFileStat(t *testing.T) { router := setupTestRouter(t) createWorkspaceForTest(t, router, "user1") writeFileForTest(t, router, "user1", "main.go", "package main\n") 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) 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()) } // 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()) } var resp struct { WorkspaceID string `json:"workspaceId"` Running bool `json:"running"` } if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp.WorkspaceID != "user1" { t.Errorf("workspaceId = %q, want user1", resp.WorkspaceID) } if resp.Running { t.Error("expected Running=false") } }