From 3a833993ba756c663468ef4b7142fb0b2c3da736 Mon Sep 17 00:00:00 2001 From: core-be <55+core-be@noreply.git.moleculesai.app> Date: Tue, 2 Jun 2026 00:32:14 +0000 Subject: [PATCH] test(handlers): add org_scope + workspace_abilities coverage (#1312) Extracted clean from bundled #1985 (which mixed these tests with a tracker rename + cancel-in-progress flips that are being handled separately). Two test files only; reuse existing withMockDB/makeReq/wsUUID* harness from tokens_sqlmock_test.go; no production code changed. --- .../internal/handlers/org_scope_test.go | 191 +++++++++++++++++ .../handlers/workspace_abilities_test.go | 200 ++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 workspace-server/internal/handlers/org_scope_test.go create mode 100644 workspace-server/internal/handlers/workspace_abilities_test.go diff --git a/workspace-server/internal/handlers/org_scope_test.go b/workspace-server/internal/handlers/org_scope_test.go new file mode 100644 index 000000000..032c10ee0 --- /dev/null +++ b/workspace-server/internal/handlers/org_scope_test.go @@ -0,0 +1,191 @@ +package handlers + +// Sqlmock-backed coverage for org_scope.go (orgRootID + sameOrg). +// Security-critical path — cross-tenant isolation (#1953). + +import ( + "context" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" +) + +// ---------- orgRootID ---------- + +func TestOrgRootID_HappyPath_NonRoot(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + // CTE walks: ws-child → ws-parent → org-root (parent_id IS NULL) + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3)) + + root, err := orgRootID(context.Background(), db.DB, wsUUID1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root != wsUUID3 { + t.Errorf("root=%q, want %q", root, wsUUID3) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestOrgRootID_WorkspaceIsRoot(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + // One-row chain: the workspace itself is the org root. + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID1)) + + root, err := orgRootID(context.Background(), db.DB, wsUUID1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root != wsUUID1 { + t.Errorf("root=%q, want %q", root, wsUUID1) + } +} + +func TestOrgRootID_NoRows(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"})) + + _, err := orgRootID(context.Background(), db.DB, wsUUID1) + if !errors.Is(err, errNoOrgRoot) { + t.Fatalf("expected errNoOrgRoot, got %v", err) + } +} + +func TestOrgRootID_DBError(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnError(errors.New("conn lost")) + + _, err := orgRootID(context.Background(), db.DB, wsUUID1) + if err == nil || errors.Is(err, errNoOrgRoot) { + t.Fatalf("expected DB error, got %v", err) + } +} + +func TestOrgRootID_EmptyRoot(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + // Row present but root is empty string → treated as not-found. + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow("")) + + _, err := orgRootID(context.Background(), db.DB, wsUUID1) + if !errors.Is(err, errNoOrgRoot) { + t.Fatalf("expected errNoOrgRoot for empty root, got %v", err) + } +} + +// ---------- sameOrg ---------- + +func TestSameOrg_SameWorkspace(t *testing.T) { + // Fast path: identical IDs are same-org without touching DB. + mock, cleanup := withMockDB(t) + defer cleanup() + + ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("same workspace must be same-org") + } + // No DB expectations → proves short-circuit. + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("DB was touched despite short-circuit: %v", err) + } +} + +func TestSameOrg_SameOrg(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3)) + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID2). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3)) + + ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected same-org") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestSameOrg_DifferentOrg(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3)) + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID2). + WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow("org-b")) + + ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("expected different-org") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestSameOrg_OrgRootFails(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnError(errors.New("conn lost")) + + _, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2) + if err == nil { + t.Fatal("expected error when orgRootID fails") + } +} + +func TestSameOrg_OrgRootNotFound(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`WITH RECURSIVE org_chain`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"root_id"})) + + _, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2) + if !errors.Is(err, errNoOrgRoot) { + t.Fatalf("expected errNoOrgRoot, got %v", err) + } +} diff --git a/workspace-server/internal/handlers/workspace_abilities_test.go b/workspace-server/internal/handlers/workspace_abilities_test.go new file mode 100644 index 000000000..d2fc5a08c --- /dev/null +++ b/workspace-server/internal/handlers/workspace_abilities_test.go @@ -0,0 +1,200 @@ +package handlers + +// Sqlmock-backed coverage for workspace_abilities.go (PatchAbilities). +// Closes #1312 — handler was at 0% coverage. + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +func patchAbilitiesReq(t *testing.T, wsID string, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsID}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + PatchAbilities(c) + return w +} + +// ---------- Validation errors ---------- + +func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) { + w := patchAbilitiesReq(t, "not-a-uuid", `{"broadcast_enabled":true}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_InvalidJSON(t *testing.T) { + w := patchAbilitiesReq(t, wsUUID1, `not json`) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_EmptyBody(t *testing.T) { + w := patchAbilitiesReq(t, wsUUID1, `{}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---------- Not found ---------- + +func TestPatchAbilities_WorkspaceNotFound(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestPatchAbilities_ExistsQueryError(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnError(errors.New("conn refused")) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 on exists query error, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---------- Happy paths ---------- + +func TestPatchAbilities_BroadcastOnly(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestPatchAbilities_TalkToUserOnly(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestPatchAbilities_BothFields(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// ---------- DB errors on update ---------- + +func TestPatchAbilities_BroadcastUpdateError(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, true). + WillReturnError(errors.New("disk full")) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_TalkToUserUpdateError(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, false). + WillReturnError(errors.New("disk full")) + + w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_BothFields_BroadcastFails(t *testing.T) { + mock, cleanup := withMockDB(t) + defer cleanup() + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(wsUUID1). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(wsUUID1, true). + WillReturnError(errors.New("disk full")) + + w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} -- 2.52.0