From e5016d30942c6dc73a0abda2bc033ceecd1ca81a Mon Sep 17 00:00:00 2001 From: James Scott Date: Fri, 25 Oct 2024 22:08:52 +0000 Subject: [PATCH] Add database method for updating user saved search It checks whether or not a user is an owner of the saved search through a reusable permissions checker. If it passes, it will allow the update to go through. --- lib/gcpspanner/saved_search_user_roles.go | 45 ++++++++ lib/gcpspanner/update_user_saved_search.go | 89 +++++++++++++++ .../update_user_saved_search_test.go | 105 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 lib/gcpspanner/update_user_saved_search.go create mode 100644 lib/gcpspanner/update_user_saved_search_test.go diff --git a/lib/gcpspanner/saved_search_user_roles.go b/lib/gcpspanner/saved_search_user_roles.go index fb827193..4c7dead6 100644 --- a/lib/gcpspanner/saved_search_user_roles.go +++ b/lib/gcpspanner/saved_search_user_roles.go @@ -14,6 +14,15 @@ package gcpspanner +import ( + "context" + "errors" + "log/slog" + + "cloud.google.com/go/spanner" + "google.golang.org/api/iterator" +) + // SavedSearchRole is the enum for the saved searches role. type SavedSearchRole string @@ -30,3 +39,39 @@ type SavedSearchUserRole struct { UserID string `spanner:"UserID"` UserRole SavedSearchRole `spanner:"UserRole"` } + +func (c *Client) checkForSavedSearchRole( + ctx context.Context, txn *spanner.ReadWriteTransaction, roleToCheck SavedSearchRole, + userID string, savedSearchID string) error { + var role string + stmt := spanner.Statement{ + SQL: `SELECT UserRole + FROM SavedSearchUserRoles + WHERE SavedSearchID = @savedSearchID AND UserID = @userID`, + Params: map[string]interface{}{ + "savedSearchID": savedSearchID, + "userID": userID, + }, + } + row, err := txn.Query(ctx, stmt).Next() + if err != nil { + // No row found. User does not have a role. + if errors.Is(err, iterator.Done) { + return errors.Join(ErrMissingRequiredRole, err) + } + slog.ErrorContext(ctx, "failed to query user role", "error", err) + + return errors.Join(ErrInternalQueryFailure, err) + } + if err := row.Columns(&role); err != nil { + slog.ErrorContext(ctx, "failed to extract role from row", "error", err) + + return errors.Join(ErrInternalQueryFailure, err) + } + + if role != string(roleToCheck) { + return ErrMissingRequiredRole + } + + return nil +} diff --git a/lib/gcpspanner/update_user_saved_search.go b/lib/gcpspanner/update_user_saved_search.go new file mode 100644 index 00000000..163b911b --- /dev/null +++ b/lib/gcpspanner/update_user_saved_search.go @@ -0,0 +1,89 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpspanner + +import ( + "cmp" + "context" + "errors" + "log/slog" + + "cloud.google.com/go/spanner" +) + +// ErrMissingRequiredRole indicates that the user is missing the required role +// for the transaction. +var ErrMissingRequiredRole = errors.New("user is missing required role") + +// UpdateSavedSearchRequest is a request to update the saved search. +type UpdateSavedSearchRequest struct { + ID string + AuthorID string + Query *string + Name *string +} + +type updateUserSavedSearchMapper struct { + unauthenticatedUserSavedSearchMapper +} + +func (m updateUserSavedSearchMapper) GetKey(in UpdateSavedSearchRequest) string { return in.ID } + +func (m updateUserSavedSearchMapper) Table() string { return savedSearchesTable } + +func (m updateUserSavedSearchMapper) Merge(req UpdateSavedSearchRequest, existing SavedSearch) SavedSearch { + var incomingName, incomingQuery string + if req.Name != nil { + incomingName = *req.Name + } + if req.Query != nil { + incomingQuery = *req.Query + } + + return SavedSearch{ + ID: existing.ID, + Name: cmp.Or(incomingName, existing.Name), + Query: cmp.Or(incomingQuery, existing.Query), + Scope: existing.Scope, + AuthorID: req.AuthorID, + CreatedAt: existing.CreatedAt, + UpdatedAt: spanner.CommitTimestamp, + } +} + +func (c *Client) UpdateUserSavedSearch(ctx context.Context, req UpdateSavedSearchRequest) error { + _, err := c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + // 1. Check if the user has permission to update (OWNER role) + err := c.checkForSavedSearchRole(ctx, txn, SavedSearchOwner, req.AuthorID, req.ID) + if err != nil { + return err + } + + // 2. Read and update the existing saved search + err = newEntityWriter[updateUserSavedSearchMapper](c).updateWithTransaction(ctx, txn, req) + if err != nil { + slog.ErrorContext(ctx, "failed to update the saved search", "error", err) + + return err + } + + return nil + }) + if err != nil { + return err + } + + return nil +} diff --git a/lib/gcpspanner/update_user_saved_search_test.go b/lib/gcpspanner/update_user_saved_search_test.go new file mode 100644 index 00000000..8fe8f499 --- /dev/null +++ b/lib/gcpspanner/update_user_saved_search_test.go @@ -0,0 +1,105 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpspanner + +import ( + "context" + "errors" + "testing" + + "cloud.google.com/go/spanner" +) + +func TestUpdateUserSavedSearch(t *testing.T) { + restartDatabaseContainer(t) + ctx := context.Background() + + savedSearchID, err := spannerClient.CreateNewUserSavedSearch(ctx, CreateUserSavedSearchRequest{ + Name: "my little search", + Query: "group:css", + OwnerUserID: "userID1", + }) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if savedSearchID == nil { + t.Error("expected non-nil id.") + } + + t.Run("non-owner cannot edit", func(t *testing.T) { + expectedSavedSearch := &UserSavedSearch{ + IsBookmarked: valuePtr(true), + Role: valuePtr(string(SavedSearchOwner)), + SavedSearch: SavedSearch{ + ID: *savedSearchID, + Name: "my little search", + Query: "group:css", + Scope: "USER_PUBLIC", + AuthorID: "userID1", + // Don't actually compare the last two values. + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + }, + } + err := spannerClient.UpdateUserSavedSearch(ctx, UpdateSavedSearchRequest{ + ID: *savedSearchID, + AuthorID: "non-owner", + Query: valuePtr("junkquery"), + Name: valuePtr("junkName"), + }) + if !errors.Is(err, ErrMissingRequiredRole) { + t.Errorf("expected error trying to update %s", err) + } + actual, err := spannerClient.GetUserSavedSearch(ctx, *savedSearchID, valuePtr("userID1")) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if !userSavedSearchEquality(expectedSavedSearch, actual) { + t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearch, actual) + } + }) + t.Run("owner can edit", func(t *testing.T) { + expectedSavedSearch := &UserSavedSearch{ + IsBookmarked: valuePtr(true), + Role: valuePtr(string(SavedSearchOwner)), + SavedSearch: SavedSearch{ + ID: *savedSearchID, + Name: "my new search", + Query: "group:grid", + Scope: "USER_PUBLIC", + AuthorID: "userID1", + // Don't actually compare the last two values. + CreatedAt: spanner.CommitTimestamp, + UpdatedAt: spanner.CommitTimestamp, + }, + } + err := spannerClient.UpdateUserSavedSearch(ctx, UpdateSavedSearchRequest{ + ID: *savedSearchID, + AuthorID: "userID1", + Query: valuePtr("group:grid"), + Name: valuePtr("my new search"), + }) + if !errors.Is(err, nil) { + t.Errorf("expected nil error trying to update %s", err) + } + actual, err := spannerClient.GetUserSavedSearch(ctx, *savedSearchID, valuePtr("userID1")) + if err != nil { + t.Errorf("expected nil error. received %s", err) + } + if !userSavedSearchEquality(expectedSavedSearch, actual) { + t.Errorf("different saved searches\nexpected: %+v\nreceived: %v", expectedSavedSearch, actual) + } + }) +}