You have unsaved changes. Do you want to discard them?
diff --git a/frontend/src/static/js/components/webstatus-subscribe-button.ts b/frontend/src/static/js/components/webstatus-subscribe-button.ts
index e2f1eb556..6448e8d40 100644
--- a/frontend/src/static/js/components/webstatus-subscribe-button.ts
+++ b/frontend/src/static/js/components/webstatus-subscribe-button.ts
@@ -60,7 +60,7 @@ export class SubscribeButton extends LitElement {
(this._isSubscriptionDialogOpen = false)}
diff --git a/lib/gcpspanner/delete_user_saved_search_test.go b/lib/gcpspanner/delete_user_saved_search_test.go
index 82f00d330..895de5327 100644
--- a/lib/gcpspanner/delete_user_saved_search_test.go
+++ b/lib/gcpspanner/delete_user_saved_search_test.go
@@ -95,4 +95,30 @@ func TestDeleteUserSavedSearch(t *testing.T) {
t.Errorf("expected ErrQueryReturnedNoResults. received %s", err)
}
})
+
+ t.Run("SYSTEM_MANAGED search cannot be deleted", func(t *testing.T) {
+ // 1. Create a system managed search directly in the DB.
+ systemSearchID := "system-search"
+ _, err := spannerClient.Apply(ctx, []*spanner.Mutation{
+ spanner.Insert("SavedSearches",
+ []string{"ID", "Name", "Query", "Scope", "AuthorID", "CreatedAt", "UpdatedAt"},
+ []interface{}{
+ systemSearchID, "System Search", "id:foo", "SYSTEM_MANAGED",
+ "system", spanner.CommitTimestamp, spanner.CommitTimestamp}),
+ })
+ if err != nil {
+ t.Fatalf("failed to create system search: %v", err)
+ }
+
+ // 2. Attempt to delete it via the user API.
+ err = spannerClient.DeleteUserSavedSearch(ctx, DeleteUserSavedSearchRequest{
+ RequestingUserID: "userID1",
+ SavedSearchID: systemSearchID,
+ })
+
+ // 3. Verify it fails with 'not found' because the mapper filters it out.
+ if !errors.Is(err, ErrQueryReturnedNoResults) {
+ t.Errorf("expected ErrQueryReturnedNoResults, got %v", err)
+ }
+ })
}
diff --git a/lib/gcpspanner/get_user_saved_search.go b/lib/gcpspanner/get_user_saved_search.go
index 9cb401656..304410c0b 100644
--- a/lib/gcpspanner/get_user_saved_search.go
+++ b/lib/gcpspanner/get_user_saved_search.go
@@ -16,6 +16,7 @@ package gcpspanner
import (
"context"
+ "errors"
"fmt"
"cloud.google.com/go/spanner"
@@ -103,29 +104,69 @@ func (m authenticatedUserSavedSearchMapper) SelectOne(
return stmt
}
+// readSavedSearchMapper provides a way to read any SavedSearch by its ID, regardless of scope.
+type readSavedSearchMapper struct{}
+
+func (m readSavedSearchMapper) SelectOne(id string) spanner.Statement {
+ stmt := spanner.NewStatement(fmt.Sprintf(`
+ SELECT
+ ID, Name, Description, Query, Scope, AuthorID, CreatedAt, UpdatedAt
+ FROM %s
+ WHERE ID = @id
+ LIMIT 1`,
+ savedSearchesTable))
+ stmt.Params["id"] = id
+
+ return stmt
+}
+
+// GetUserSavedSearch returns a single user saved search by its id.
+// If the user is authenticated, it will also return their role and bookmark status.
func (c *Client) GetUserSavedSearch(
ctx context.Context,
savedSearchID string,
authenticatedUserID *string) (*UserSavedSearch, error) {
+
+ // Use a single read-only transaction for all operations.
+ txn := c.ReadOnlyTransaction()
+ defer txn.Close()
+
+ // 1. Fetch the base SavedSearch using the new generic mapper.
+ savedSearch, err := newEntityReader[readSavedSearchMapper, SavedSearch, string](c).
+ readRowByKeyWithTransaction(ctx, savedSearchID, txn)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. If the user is unauthenticated, they can only see public or system searches.
if authenticatedUserID == nil {
- // For an unauthenticated user, we only read the SavedSearches row then fill in the rest of
- // UserSavedSearch struct with nil values.
- row, err := newEntityReader[unauthenticatedUserSavedSearchMapper, SavedSearch, string](c).
- readRowByKey(ctx, savedSearchID)
- if err != nil {
- return nil, err
+ if savedSearch.Scope != UserPublicScope && savedSearch.Scope != SystemManagedScope {
+ return nil, ErrQueryReturnedNoResults
+ }
+
+ return &UserSavedSearch{SavedSearch: *savedSearch, Role: nil, IsBookmarked: nil}, nil
+ }
+
+ // 3. For an authenticated user, fetch their specific role and bookmark status
+ // using the original restricted mapper within the SAME transaction.
+ userSpecifics, err := newEntityReader[
+ authenticatedUserSavedSearchMapper,
+ UserSavedSearch,
+ authenticatedUserSavedSearchMapperKey,
+ ](c).readRowByKeyWithTransaction(ctx, authenticatedUserSavedSearchMapperKey{
+ UserID: *authenticatedUserID,
+ ID: savedSearchID,
+ }, txn)
+
+ // If there are no user-specific details (e.g., for a SYSTEM_MANAGED search),
+ // that's okay. We just return the base search info.
+ if err != nil {
+ if errors.Is(err, ErrQueryReturnedNoResults) {
+ return &UserSavedSearch{SavedSearch: *savedSearch, Role: nil, IsBookmarked: nil}, nil
}
- return &UserSavedSearch{
- SavedSearch: *row,
- IsBookmarked: nil,
- Role: nil,
- }, nil
+ return nil, err
}
- return newEntityReader[authenticatedUserSavedSearchMapper, UserSavedSearch, authenticatedUserSavedSearchMapperKey](c).
- readRowByKey(ctx, authenticatedUserSavedSearchMapperKey{
- UserID: *authenticatedUserID,
- ID: savedSearchID,
- })
+ return userSpecifics, nil
}
diff --git a/lib/gcpspanner/user_search_bookmarks_test.go b/lib/gcpspanner/user_search_bookmarks_test.go
index 4635775a4..02fcf1145 100644
--- a/lib/gcpspanner/user_search_bookmarks_test.go
+++ b/lib/gcpspanner/user_search_bookmarks_test.go
@@ -186,4 +186,30 @@ func TestUserSearchBookmark(t *testing.T) {
t.Errorf("expected ErrOwnerCannotDeleteBookmark error. received %s", err)
}
})
+
+ t.Run("the test user cannot bookmark a SYSTEM_MANAGED search", func(t *testing.T) {
+ // 1. Create a system managed search directly in the DB.
+ systemSearchID := "system-managed-search-id"
+ _, err := spannerClient.Apply(ctx, []*spanner.Mutation{
+ spanner.Insert("SavedSearches",
+ []string{"ID", "Name", "Query", "Scope", "AuthorID", "CreatedAt", "UpdatedAt"},
+ []interface{}{
+ systemSearchID, "System Search", "id:feat", "SYSTEM_MANAGED",
+ "system", spanner.CommitTimestamp, spanner.CommitTimestamp}),
+ })
+ if err != nil {
+ t.Fatalf("failed to create system search: %v", err)
+ }
+
+ // 2. Attempt to bookmark it.
+ err = spannerClient.AddUserSearchBookmark(ctx, UserSavedSearchBookmark{
+ UserID: testUser,
+ SavedSearchID: systemSearchID,
+ })
+
+ // 3. Verify it fails with 'not found' because the mapper filters it out.
+ if !errors.Is(err, ErrQueryReturnedNoResults) {
+ t.Errorf("expected ErrQueryReturnedNoResults, got %v", err)
+ }
+ })
}
diff --git a/util/cmd/load_fake_data/main.go b/util/cmd/load_fake_data/main.go
index 2541a6515..87f78da58 100644
--- a/util/cmd/load_fake_data/main.go
+++ b/util/cmd/load_fake_data/main.go
@@ -1008,6 +1008,12 @@ func generateData(ctx context.Context, spannerClient *gcpspanner.Client, datasto
}
slog.InfoContext(ctx, "feature evolution generation complete")
+ err = spannerClient.SyncSystemManagedSavedQuery(ctx)
+ if err != nil {
+ return fmt.Errorf("system managed saved query sync failed %w", err)
+ }
+ slog.InfoContext(ctx, "system managed saved query sync complete")
+
return nil
}