diff --git a/workspace-server/internal/handlers/bundle.go b/workspace-server/internal/handlers/bundle.go index 0c080398..929e2149 100644 --- a/workspace-server/internal/handlers/bundle.go +++ b/workspace-server/internal/handlers/bundle.go @@ -50,6 +50,14 @@ func (h *BundleHandler) Import(c *gin.Context) { return } + // Reject null JSON (which binds to a zero-value Bundle{}) and empty schema. + // Without this guard a POST of `null` or `{}` would INSERT a workspace row + // with name="" and tier=0 into the DB before bundle.Import() fails. + if b.Schema == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"}) + return + } + ctx := c.Request.Context() result := bundle.Import(ctx, &b, nil, h.broadcaster, h.provisioner, h.platformURL) diff --git a/workspace-server/internal/handlers/bundle_test.go b/workspace-server/internal/handlers/bundle_test.go new file mode 100644 index 00000000..b2aa2fbb --- /dev/null +++ b/workspace-server/internal/handlers/bundle_test.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "bytes" + "database/sql" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// ───────────────────────────────────────────────────────────────────────────── +// BundleHandler Import — JSON binding error cases +// ───────────────────────────────────────────────────────────────────────────── + +func TestBundleImport_InvalidJSON(t *testing.T) { + h := NewBundleHandler(nil, nil, "http://localhost:8080", t.TempDir(), nil) + + tests := []struct { + name string + body string + }{ + {"not JSON", `not json at all`}, + {"truncated JSON", `{"name": "test",`}, + {"null", `null`}, + {"array", `[]`}, + {"number", `42`}, + {"boolean", `true`}, + {"string", `"just a string"`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Import(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("invalid JSON %q: expected status %d, got %d", tc.body, http.StatusBadRequest, w.Code) + } + }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BundleHandler Import — valid JSON routes to bundle.Import and returns 201 +// ───────────────────────────────────────────────────────────────────────────── + +func TestBundleImport_ValidJSON(t *testing.T) { + mock := setupTestDB(t) + broadcaster := newTestBroadcaster() + h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil) + + // bundle.Import does: INSERT workspaces (creates record), UPDATE runtime (after + // parsing config.yaml), plus a RecordAndBroadcast (not a DB call). SubWorkspaces + // recursion is a no-op for this test bundle. No workspace_schedules or + // workspace_secrets INSERT in the current importer. + mock.ExpectExec("INSERT INTO workspaces"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE workspaces SET runtime"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + body := `{"name": "test-workspace", "schema": "1.0", "tier": 3}` + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Import(c) + + if w.Code != http.StatusCreated { + t.Errorf("valid JSON: expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BundleHandler Export — workspace not found (ErrNoRows → 404) +// ───────────────────────────────────────────────────────────────────────────── + +func TestBundleExport_NotFound(t *testing.T) { + mock := setupTestDB(t) + _ = setupTestRedis(t) + broadcaster := newTestBroadcaster() + h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil) + + // bundle.Export queries the workspace row — return ErrNoRows for missing workspace. + mock.ExpectQuery(`SELECT name, COALESCE\(role`). + WithArgs("ws-nonexistent"). + WillReturnError(sql.ErrNoRows) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-nonexistent"}} + c.Request = httptest.NewRequest("GET", "/bundles/export/ws-nonexistent", nil) + + h.Export(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d: %s", http.StatusNotFound, w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BundleHandler Export — query error (DB error → 404, per bundle.Export semantics) +// ───────────────────────────────────────────────────────────────────────────── + +func TestBundleExport_QueryError(t *testing.T) { + mock := setupTestDB(t) + _ = setupTestRedis(t) + broadcaster := newTestBroadcaster() + h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil) + + // Simulate a non-ErrNoRows DB error. + mock.ExpectQuery(`SELECT name, COALESCE\(role`). + WithArgs("ws-error"). + WillReturnError(sql.ErrConnDone) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-error"}} + c.Request = httptest.NewRequest("GET", "/bundles/export/ws-error", nil) + + h.Export(c) + + // bundle.Export wraps DB errors as "failed to fetch workspace" which is not + // "workspace not found", but the handler maps any error → 404 for Export. + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d for DB error, got %d: %s", http.StatusNotFound, w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +}