diff --git a/workspace-server/internal/handlers/workspace_abilities.go b/workspace-server/internal/handlers/workspace_abilities.go index 71fa48f97..c2ba7d0ee 100644 --- a/workspace-server/internal/handlers/workspace_abilities.go +++ b/workspace-server/internal/handlers/workspace_abilities.go @@ -51,7 +51,12 @@ func PatchAbilities(c *gin.Context) { var exists bool if err := db.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')`, id, - ).Scan(&exists); err != nil || !exists { + ).Scan(&exists); err != nil { + log.Printf("PatchAbilities: workspace existence check for %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) return } 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..10d3c7a08 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_abilities_test.go @@ -0,0 +1,265 @@ +package handlers + +// workspace_abilities_test.go — regression tests for PATCH /workspaces/:id/abilities. +// +// The handler toggles two workspace-level ability flags: +// broadcast_enabled — workspace may POST /broadcast to send org-wide messages +// talk_to_user_enabled — workspace may deliver canvas chat messages via +// send_message_to_user / POST /notify +// +// Gated behind AdminAuth so workspace agents cannot self-modify their own +// ability flags. These tests cover the uncredentialed unit-path (AdminAuth +// middleware is tested separately). + +import ( + "bytes" + "database/sql" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// validUUID is a stable test workspace ID that passes uuid.Parse validation. +const validUUID = "00000000-0000-0000-0000-000000000001" + +// buildAbilitiesCtx wires a gin.Context for PATCH /workspaces/:id/abilities. +func buildAbilitiesCtx(id string, body string) (*httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: id}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+id+"/abilities", + bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return w, c +} + +// -------- Happy path -------- + +// PatchAbilities writes broadcast_enabled=true and returns 200. +func TestPatchAbilities_BroadcastEnabled_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// PatchAbilities writes broadcast_enabled=false and returns 200. +func TestPatchAbilities_BroadcastEnabledFalse_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// PatchAbilities writes talk_to_user_enabled=true and returns 200. +func TestPatchAbilities_TalkToUserEnabled_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w, c := buildAbilitiesCtx(validUUID, `{"talk_to_user_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// Both ability flags in the same request are each written with their own UPDATE. +func TestPatchAbilities_BothFields_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + // broadcast_enabled written first + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + // talk_to_user_enabled written second + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":true,"talk_to_user_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// -------- Input validation -------- + +// Empty body (neither field) → 400. +func TestPatchAbilities_NoAbilityFields_400(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + + w, c := buildAbilitiesCtx(validUUID, `{}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// Non-JSON body → 400. +func TestPatchAbilities_InvalidJSON_400(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + + w, c := buildAbilitiesCtx(validUUID, `not json at all`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// Invalid (non-UUID) workspace ID → 400. +func TestPatchAbilities_InvalidWorkspaceID_400(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + + w, c := buildAbilitiesCtx("not-a-uuid", `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// -------- Database errors -------- + +// Workspace does not exist → 404. +func TestPatchAbilities_WorkspaceNotFound_404(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// DB error on existence check → 500. +func TestPatchAbilities_DBErrorOnExistsCheck_500(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnError(sql.ErrConnDone) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// DB error on broadcast_enabled UPDATE → 500. +func TestPatchAbilities_DBErrorOnBroadcastUpdate_500(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, true). + WillReturnError(sql.ErrConnDone) + + w, c := buildAbilitiesCtx(validUUID, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// DB error on talk_to_user_enabled UPDATE → 500. +func TestPatchAbilities_DBErrorOnTalkToUserUpdate_500(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(validUUID). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(validUUID, true). + WillReturnError(sql.ErrConnDone) + + w, c := buildAbilitiesCtx(validUUID, `{"talk_to_user_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +}