Skip to content

Commit

Permalink
Add database method for updating user saved search
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jcscottiii committed Oct 25, 2024
1 parent daddb3a commit e5016d3
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 0 deletions.
45 changes: 45 additions & 0 deletions lib/gcpspanner/saved_search_user_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
89 changes: 89 additions & 0 deletions lib/gcpspanner/update_user_saved_search.go
Original file line number Diff line number Diff line change
@@ -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
}
105 changes: 105 additions & 0 deletions lib/gcpspanner/update_user_saved_search_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}

0 comments on commit e5016d3

Please sign in to comment.