From 672d0ce8d9f662b7ef8c7ad1f82b0758abe1d797 Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 22:10:23 +0800 Subject: [PATCH] Add workspace user update endpoint --- handlers/workspaces.go | 151 ++++++++++++++++++++++++++++++++ handlers/workspaces_test.go | 119 +++++++++++++++++++++++++ mocks/Database.go | 58 +++++++++++- routes/workspace_routes_test.go | 8 ++ routes/workspaces.go | 1 + 5 files changed, 336 insertions(+), 1 deletion(-) diff --git a/handlers/workspaces.go b/handlers/workspaces.go index 698f46e2c..96d3c2bef 100644 --- a/handlers/workspaces.go +++ b/handlers/workspaces.go @@ -306,6 +306,157 @@ func (oh *workspaceHandler) CreateWorkspaceUser(w http.ResponseWriter, r *http.R json.NewEncoder(w).Encode(user) } +type WorkspaceUserUpdateRequest struct { + OwnerPubKey string `json:"owner_pubkey"` + NewOwnerPubKey string `json:"new_owner_pubkey,omitempty"` + OrgUuid string `json:"org_uuid"` + WorkspaceUuid string `json:"workspace_uuid,omitempty"` +} + +// UpdateWorkspaceUser godoc +// +// @Summary Update Workspace User +// @Description Update a user in a workspace +// @Tags Workspace - Users +// @Accept json +// @Produce json +// @Security PubKeyContextAuth +// @Param uuid path string true "Workspace UUID" +// @Param user path string true "Current User PubKey" +// @Param workspaceUser body WorkspaceUserUpdateRequest true "Workspace User Update Data" +// @Success 200 {object} db.WorkspaceUsers +// @Router /workspaces/users/{uuid}/{user} [put] +func (oh *workspaceHandler) UpdateWorkspaceUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + workspaceUUID := chi.URLParam(r, "uuid") + currentOwnerPubKey := chi.URLParam(r, "user") + now := time.Now() + + workspaceUserUpdate := WorkspaceUserUpdateRequest{} + body, err := io.ReadAll(r.Body) + r.Body.Close() + + if err != nil { + logger.Log.Error("[body] %v", err) + w.WriteHeader(http.StatusNotAcceptable) + return + } + + err = json.Unmarshal(body, &workspaceUserUpdate) + if err != nil { + logger.Log.Error("[workspaces] %v", err) + w.WriteHeader(http.StatusNotAcceptable) + return + } + + if workspaceUserUpdate.WorkspaceUuid == "" && workspaceUserUpdate.OrgUuid != "" { + workspaceUserUpdate.WorkspaceUuid = workspaceUserUpdate.OrgUuid + } + + if workspaceUUID == "" { + workspaceUUID = workspaceUserUpdate.WorkspaceUuid + } else if workspaceUserUpdate.WorkspaceUuid != "" && workspaceUserUpdate.WorkspaceUuid != workspaceUUID { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("workspace uuid does not match route") + return + } + + newOwnerPubKey := workspaceUserUpdate.NewOwnerPubKey + if newOwnerPubKey == "" { + newOwnerPubKey = workspaceUserUpdate.OwnerPubKey + } + + if workspaceUUID == "" || currentOwnerPubKey == "" || newOwnerPubKey == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("no uuid, current user pubkey, or new user pubkey") + return + } + + if pubKeyFromAuth == "" { + logger.Log.Info("[workspaces] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if currentOwnerPubKey == newOwnerPubKey { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("new user pubkey must be different from current user pubkey") + return + } + + workspace := oh.db.GetWorkspaceByUuid(workspaceUUID) + + if currentOwnerPubKey == workspace.OwnerPubKey || newOwnerPubKey == workspace.OwnerPubKey { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("Cannot update workspace admin as a user") + return + } + + if pubKeyFromAuth == currentOwnerPubKey || pubKeyFromAuth == newOwnerPubKey { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("Cannot update userself as a user") + return + } + + hasRole := oh.userHasAccess(pubKeyFromAuth, workspaceUUID, db.UpdateUser) + if !hasRole { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("Don't have access to update user") + return + } + + existingWorkspaceUser := oh.db.GetWorkspaceUser(currentOwnerPubKey, workspaceUUID) + if existingWorkspaceUser.ID == 0 { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("User does not exists in the workspace") + return + } + + newUser := oh.db.GetPersonByPubkey(newOwnerPubKey) + if newUser.OwnerPubKey != newOwnerPubKey { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("User doesn't exists in people") + return + } + + existingNewWorkspaceUser := oh.db.GetWorkspaceUser(newOwnerPubKey, workspaceUUID) + if existingNewWorkspaceUser.ID != 0 { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode("User already exists") + return + } + + roles := oh.db.GetUserRoles(workspaceUUID, currentOwnerPubKey) + oh.db.DeleteWorkspaceUser(db.WorkspaceUsersData{ + WorkspaceUuid: workspaceUUID, + Person: db.Person{ + OwnerPubKey: currentOwnerPubKey, + }, + }, workspaceUUID) + + updatedWorkspaceUser := db.WorkspaceUsers{ + OwnerPubKey: newOwnerPubKey, + WorkspaceUuid: workspaceUUID, + Created: existingWorkspaceUser.Created, + Updated: &now, + } + + user := oh.db.CreateWorkspaceUser(updatedWorkspaceUser) + + if len(roles) > 0 { + for i := range roles { + roles[i].OwnerPubKey = newOwnerPubKey + roles[i].WorkspaceUuid = workspaceUUID + roles[i].OrgUuid = "" + } + oh.db.CreateUserRoles(roles, workspaceUUID, newOwnerPubKey) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(user) +} + // GetWorkspaceUsers godoc // // @Summary Get Workspace Users diff --git a/handlers/workspaces_test.go b/handlers/workspaces_test.go index f5f845138..e4df808b8 100644 --- a/handlers/workspaces_test.go +++ b/handlers/workspaces_test.go @@ -1435,6 +1435,125 @@ func TestCreateWorkspaceUser(t *testing.T) { }) } +func TestUpdateWorkspaceUser(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + oHandler := NewWorkspaceHandler(db.TestDB) + + admin := db.Person{ + Uuid: "update-admin-uuid", + OwnerAlias: "update-admin", + UniqueName: "update_admin", + OwnerPubKey: "update-admin-pubkey", + PriceToMeet: 0, + Description: "update admin", + } + oldUser := db.Person{ + Uuid: "update-old-user-uuid", + OwnerAlias: "update-old-user", + UniqueName: "update_old_user", + OwnerPubKey: "update-old-user-pubkey", + PriceToMeet: 0, + Description: "update old user", + } + newUser := db.Person{ + Uuid: "update-new-user-uuid", + OwnerAlias: "update-new-user", + UniqueName: "update_new_user", + OwnerPubKey: "update-new-user-pubkey", + PriceToMeet: 0, + Description: "update new user", + } + + db.TestDB.CreateOrEditPerson(admin) + db.TestDB.CreateOrEditPerson(oldUser) + db.TestDB.CreateOrEditPerson(newUser) + + workspace := db.Workspace{ + Uuid: "update-workspace-uuid", + Name: "update_workspace", + OwnerPubKey: admin.OwnerPubKey, + Github: "github", + Website: "website", + Description: "description", + } + db.TestDB.CreateOrEditWorkspace(workspace) + + db.TestDB.DeleteWorkspaceUser(db.WorkspaceUsersData{ + WorkspaceUuid: workspace.Uuid, + Person: oldUser, + }, workspace.Uuid) + db.TestDB.DeleteWorkspaceUser(db.WorkspaceUsersData{ + WorkspaceUuid: workspace.Uuid, + Person: newUser, + }, workspace.Uuid) + + oldWorkspaceUser := db.WorkspaceUsers{ + OwnerPubKey: oldUser.OwnerPubKey, + WorkspaceUuid: workspace.Uuid, + } + db.TestDB.CreateWorkspaceUser(oldWorkspaceUser) + + role := db.WorkspaceUserRoles{ + WorkspaceUuid: workspace.Uuid, + OwnerPubKey: oldUser.OwnerPubKey, + Role: db.UpdateUser, + } + db.TestDB.CreateUserRoles([]db.WorkspaceUserRoles{role}, workspace.Uuid, oldUser.OwnerPubKey) + + oHandler.userHasAccess = func(pubKeyFromAuth string, uuid string, role string) bool { + return pubKeyFromAuth == admin.OwnerPubKey && uuid == workspace.Uuid && role == db.UpdateUser + } + + t.Run("Should test that when an unauthorized user hits the endpoint it returns a 401 error", func(t *testing.T) { + requestBody, _ := json.Marshal(WorkspaceUserUpdateRequest{ + OwnerPubKey: newUser.OwnerPubKey, + }) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", workspace.Uuid) + rctx.URLParams.Add("user", oldUser.OwnerPubKey) + req, err := http.NewRequestWithContext(context.WithValue(context.Background(), chi.RouteCtxKey, rctx), http.MethodPut, "/users/"+workspace.Uuid+"/"+oldUser.OwnerPubKey, bytes.NewReader(requestBody)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + http.HandlerFunc(oHandler.UpdateWorkspaceUser).ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("Should test that when the right conditions are met a workspace user can be updated", func(t *testing.T) { + requestBody, _ := json.Marshal(WorkspaceUserUpdateRequest{ + OwnerPubKey: newUser.OwnerPubKey, + }) + ctx := context.WithValue(context.Background(), auth.ContextKey, admin.OwnerPubKey) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", workspace.Uuid) + rctx.URLParams.Add("user", oldUser.OwnerPubKey) + req, err := http.NewRequestWithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx), http.MethodPut, "/users/"+workspace.Uuid+"/"+oldUser.OwnerPubKey, bytes.NewReader(requestBody)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + http.HandlerFunc(oHandler.UpdateWorkspaceUser).ServeHTTP(rr, req) + + updatedWorkspaceUser := db.TestDB.GetWorkspaceUser(newUser.OwnerPubKey, workspace.Uuid) + deletedWorkspaceUser := db.TestDB.GetWorkspaceUser(oldUser.OwnerPubKey, workspace.Uuid) + updatedRoles := db.TestDB.GetUserRoles(workspace.Uuid, newUser.OwnerPubKey) + deletedRoles := db.TestDB.GetUserRoles(workspace.Uuid, oldUser.OwnerPubKey) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, newUser.OwnerPubKey, updatedWorkspaceUser.OwnerPubKey) + assert.Equal(t, uint(0), deletedWorkspaceUser.ID) + assert.Equal(t, 1, len(updatedRoles)) + assert.Equal(t, db.UpdateUser, updatedRoles[0].Role) + assert.Equal(t, 0, len(deletedRoles)) + }) +} + func TestGetWorkspaceUsers(t *testing.T) { } diff --git a/mocks/Database.go b/mocks/Database.go index 5c98b7f66..15f0a18b8 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -5696,6 +5696,62 @@ func (_c *Database_GetBountyByCreated_Call) RunAndReturn(run func(uint) (db.NewB return _c } +// GetBountyByUnlockCode provides a mock function with given fields: code +func (_m *Database) GetBountyByUnlockCode(code string) (db.NewBounty, error) { + ret := _m.Called(code) + + if len(ret) == 0 { + panic("no return value specified for GetBountyByUnlockCode") + } + + var r0 db.NewBounty + var r1 error + if rf, ok := ret.Get(0).(func(string) (db.NewBounty, error)); ok { + return rf(code) + } + if rf, ok := ret.Get(0).(func(string) db.NewBounty); ok { + r0 = rf(code) + } else { + r0 = ret.Get(0).(db.NewBounty) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(code) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Database_GetBountyByUnlockCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBountyByUnlockCode' +type Database_GetBountyByUnlockCode_Call struct { + *mock.Call +} + +// GetBountyByUnlockCode is a helper method to define mock.On call +// - code string +func (_e *Database_Expecter) GetBountyByUnlockCode(code interface{}) *Database_GetBountyByUnlockCode_Call { + return &Database_GetBountyByUnlockCode_Call{Call: _e.mock.On("GetBountyByUnlockCode", code)} +} + +func (_c *Database_GetBountyByUnlockCode_Call) Run(run func(code string)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) Return(_a0 db.NewBounty, _a1 error) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) RunAndReturn(run func(string) (db.NewBounty, error)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(run) + return _c +} + // GetBountyById provides a mock function with given fields: id func (_m *Database) GetBountyById(id string) ([]db.NewBounty, error) { ret := _m.Called(id) @@ -18423,4 +18479,4 @@ func (_c *Database_DeleteBountyStakeProcess_Call) Return(_a0 error) *Database_De func (_c *Database_DeleteBountyStakeProcess_Call) RunAndReturn(run func(uuid.UUID) error) *Database_DeleteBountyStakeProcess_Call { _c.Call.Return(run) return _c -} \ No newline at end of file +} diff --git a/routes/workspace_routes_test.go b/routes/workspace_routes_test.go index 15f4dcb30..8cd3f5820 100644 --- a/routes/workspace_routes_test.go +++ b/routes/workspace_routes_test.go @@ -75,6 +75,7 @@ func TestWorkspaceRoutes(t *testing.T) { workspaceRouter.Get("/users/{uuid}", MockHandler(t, http.StatusOK, nil)) workspaceRouter.Get("/users/{uuid}/count", MockHandler(t, http.StatusOK, nil)) + workspaceRouter.Put("/users/{uuid}/{user}", MockHandler(t, http.StatusOK, nil)) workspaceRouter.Post("/users/role/{uuid}/{user}", MockHandler(t, http.StatusOK, nil)) workspaceRouter.Get("/bounties/{uuid}", MockHandler(t, http.StatusOK, nil)) @@ -220,6 +221,13 @@ func TestWorkspaceRoutes(t *testing.T) { path: "/workspaces/users/123e4567-e89b-12d3-a456-426614174000/count", expectedStatus: http.StatusOK, }, + { + name: "Update Workspace User", + method: "PUT", + path: "/workspaces/users/123e4567-e89b-12d3-a456-426614174000/user123", + body: map[string]interface{}{"owner_pubkey": "user456"}, + expectedStatus: http.StatusOK, + }, { name: "Get Workspace Budget", method: "GET", diff --git a/routes/workspaces.go b/routes/workspaces.go index e5f3f5a07..194b08cd8 100644 --- a/routes/workspaces.go +++ b/routes/workspaces.go @@ -26,6 +26,7 @@ func WorkspaceRoutes() chi.Router { r.Post("/", workspaceHandlers.CreateOrEditWorkspace) r.Post("/users/{uuid}", workspaceHandlers.CreateWorkspaceUser) + r.Put("/users/{uuid}/{user}", workspaceHandlers.UpdateWorkspaceUser) r.Delete("/users/{uuid}", handlers.DeleteWorkspaceUser) r.Post("/users/role/{uuid}/{user}", workspaceHandlers.AddUserRoles)