Merge pull request #6383 from hashicorp/llb-desktop-client-sort

feat(clientcahe): add client cache sorts
This commit is contained in:
Emilia authored and GitHub committed 2026-02-11 08:29:17 -08:00
commit f01cd7019b
12 files changed
+1702 -29

No files matched your search

+59 -4
View File
@@ -10,6 +10,7 @@ import (
"math"
"net/url"
"os"
"slices"
"strings"
"time"
@@ -19,12 +20,12 @@ import (
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
cachecmd "github.com/hashicorp/boundary/internal/clientcache/cmd/cache"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/hashicorp/boundary/internal/clientcache/internal/client"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"golang.org/x/exp/slices"
)
var (
@@ -39,6 +40,7 @@ var (
}
errCacheNotRunning = stderrors.New("The cache process is not running.")
sortDirections = []string{"asc", "desc"}
)
type SearchCommand struct {
@@ -47,6 +49,8 @@ type SearchCommand struct {
flagResource string
flagForceRefresh bool
flagMaxResultSetSize int64
flagSortBy string
flagSortDirection string
}
func (c *SearchCommand) Synopsis() string {
@@ -128,6 +132,18 @@ func (c *SearchCommand) Flags() *base.FlagSets {
Usage: `Forces a refresh to be attempted prior to performing the search`,
Hidden: true,
})
f.StringVar(&base.StringVar{
Name: "sort-by",
Target: &c.flagSortBy,
Usage: `Specifies which column to sort resources by. Targets may be sorted by 'name'. Sessions may be sorted by 'created_time'. Use sort-direction to control which direction to sort results by.`,
Completion: complete.PredictSet(getSortableResources()...),
})
f.StringVar(&base.StringVar{
Name: "sort-direction",
Target: &c.flagSortDirection,
Usage: `Specifies which direction, 'asc' or 'desc', to sort results by. Requires sort-by and defaults to 'asc'.`,
Completion: complete.PredictSet(sortDirections...),
})
return set
}
@@ -163,10 +179,33 @@ func (c *SearchCommand) Run(args []string) int {
c.PrintCliError(stderrors.New("Max result set size must be greater than or equal to -1"))
return base.CommandUserError
case c.flagMaxResultSetSize > math.MaxInt:
c.PrintCliError(stderrors.New(fmt.Sprintf("Max result set size must be less than or equal to the %v", math.MaxInt)))
c.PrintCliError(fmt.Errorf("Max result set size must be less than or equal to the %v", math.MaxInt))
return base.CommandUserError
}
if c.flagSortDirection != "" {
if c.flagSortBy == "" {
c.PrintCliError(stderrors.New("sort-direction requires sort-by"))
return base.CommandUserError
}
if c.flagSortDirection != "asc" && c.flagSortDirection != "desc" {
c.PrintCliError(stderrors.New("sort-direction must be one of 'asc' or 'desc'"))
return base.CommandUserError
}
}
if c.flagSortBy != "" {
sortableBy, ok := daemon.SortableColumnsForResource[cache.ToSearchableResource(c.flagResource)]
if !ok {
c.PrintCliError(fmt.Errorf("resource %q is not sortable", c.flagResource))
return base.CommandUserError
}
if !slices.Contains(sortableBy, cache.SortBy(c.flagSortBy)) {
c.PrintCliError(fmt.Errorf("resource %q is not sortable by %q. %q is sortable by %v", c.flagResource, c.flagSortBy, c.flagResource, sortableBy))
return base.CommandUserError
}
}
resp, result, apiErr, err := c.Search(ctx)
if err != nil {
c.PrintCliError(err)
@@ -238,10 +277,10 @@ func (c *SearchCommand) Search(ctx context.Context) (*api.Response, *daemon.Sear
if err != nil {
return nil, nil, nil, err
}
return search(ctx, dotPath, tf, opts...)
return search(ctx, dotPath, tf, c.flagSortBy, c.flagSortDirection, opts...)
}
func search(ctx context.Context, daemonPath string, fb filterBy, opt ...client.Option) (*api.Response, *daemon.SearchResult, *api.Error, error) {
func search(ctx context.Context, daemonPath string, fb filterBy, sortBy string, sortDirection string, opt ...client.Option) (*api.Response, *daemon.SearchResult, *api.Error, error) {
addr := daemon.SocketAddress(daemonPath)
_, err := os.Stat(addr.Path)
if addr.Scheme == "unix" && err != nil {
@@ -263,6 +302,14 @@ func search(ctx context.Context, daemonPath string, fb filterBy, opt ...client.O
if fb.maxResultSetSize != 0 {
q.Add("max_result_set_size", fmt.Sprintf("%d", fb.maxResultSetSize))
}
if sortBy != "" {
q.Add(daemon.SortByKey, sortBy)
if sortDirection != "" {
q.Add(daemon.SortDirectionKey, sortDirection)
}
}
resp, err := c.Get(ctx, "/v1/search", q, opt...)
if err != nil {
return nil, nil, nil, fmt.Errorf("Error when sending request to the cache: %w.", err)
@@ -491,3 +538,11 @@ type filterBy struct {
forceRefresh bool
maxResultSetSize int
}
func getSortableResources() []string {
ret := []string{}
for sr := range daemon.SortableColumnsForResource {
ret = append(ret, string(sr))
}
return ret
}
+95 -21
View File
@@ -128,7 +128,7 @@ func TestSearch(t *testing.T) {
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), tc.fb)
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), tc.fb, "", "")
require.NoError(t, err)
assert.NotNil(t, apiErr)
assert.Contains(t, apiErr.Message, tc.apiErrContains)
@@ -141,7 +141,7 @@ func TestSearch(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -157,7 +157,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagQuery: "name='name'",
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -173,7 +173,7 @@ func TestSearch(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: unsupportedAt.Id,
resource: "targets",
})
}, "", "")
assert.NoError(t, err)
require.NotNil(t, apiErr)
assert.NotNil(t, resp)
@@ -182,22 +182,27 @@ func TestSearch(t *testing.T) {
assert.Contains(t, apiErr.Message, "doesn't support search")
})
srv.AddResources(t, at, []*aliases.Alias{
expectedAliases := []*aliases.Alias{
{Id: "alt_1234567890", Value: "value1", DestinationId: "ttcp_1234567890"},
{Id: "alt_0987654321", Name: "value2", DestinationId: "ttcp_0987654321"},
}, []*targets.Target{
}
// These need to be sorted by name to preserve the sorting tests
expectedTargets := []*targets.Target{
{Id: "ttcp_1234567890", Name: "name1", Description: "description1"},
{Id: "ttcp_0987654321", Name: "name2", Description: "description2"},
}, []*sessions.Session{
{Id: "sess_1234567890", TargetId: "ttcp_1234567890", Status: "pending"},
{Id: "sess_0987654321", TargetId: "ttcp_0987654321", Status: "pending"},
}, boundaryTokenReaderFn)
}
expectedSessions := []*sessions.Session{
{Id: "sess_0987654321", TargetId: "ttcp_0987654321", Status: "pending", CreatedTime: time.Now()},
{Id: "sess_1234567890", TargetId: "ttcp_1234567890", Status: "pending", CreatedTime: time.Now().Add(-100 * time.Second)},
}
srv.AddResources(t, at, expectedAliases, expectedTargets, expectedSessions, boundaryTokenReaderFn)
t.Run("target response from list", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
@@ -208,7 +213,7 @@ func TestSearch(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -221,7 +226,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagQuery: "id % 'ttcp'",
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -234,7 +239,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagQuery: "id % 'ttcp_1234567890'",
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -249,7 +254,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagFilter: `"/item/id" matches "ttcp"`,
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -262,7 +267,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagFilter: `"/item/id" matches "ttcp_1234567890"`,
resource: "targets",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -276,7 +281,7 @@ func TestSearch(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "sessions",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -290,7 +295,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagQuery: "id % 'sess'",
resource: "sessions",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -303,7 +308,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagQuery: "id % 'sess_1234567890'",
resource: "sessions",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -316,7 +321,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagFilter: `"/item/id" matches "sess"`,
resource: "sessions",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -329,7 +334,7 @@ func TestSearch(t *testing.T) {
authTokenId: at.Id,
flagFilter: `"/item/id" matches "sess_1234567890"`,
resource: "sessions",
})
}, "", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
@@ -337,4 +342,73 @@ func TestSearch(t *testing.T) {
assert.NotNil(t, r)
assert.Len(t, r.Sessions, 1)
})
t.Run("sorted targets from list", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
}, "name", "desc")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
assert.NotNil(t, r)
assert.Equal(t, expectedTargets[1].Name, r.Targets[0].Name)
assert.Equal(t, expectedTargets[0].Name, r.Targets[1].Name)
})
t.Run("sorted targets ascending by default", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "targets",
}, "name", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
assert.NotNil(t, r)
assert.Equal(t, expectedTargets[0].Name, r.Targets[0].Name)
assert.Equal(t, expectedTargets[1].Name, r.Targets[1].Name)
})
t.Run("sorted sessions from list", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "sessions",
}, "created_time", "desc")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
assert.NotNil(t, r)
assert.Less(t, r.Sessions[1].CreatedTime, r.Sessions[0].CreatedTime)
})
t.Run("sorted sessions descending by default", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
resource: "sessions",
}, "created_time", "")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
assert.NotNil(t, r)
assert.Less(t, r.Sessions[1].CreatedTime, r.Sessions[0].CreatedTime)
})
t.Run("sorted and filtered", func(t *testing.T) {
resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{
authTokenId: at.Id,
flagFilter: `"/item/id" matches "ttcp"`,
resource: "targets",
}, "name", "desc")
require.NoError(t, err)
assert.NoError(t, err)
assert.Nil(t, apiErr)
assert.NotNil(t, resp)
assert.NotNil(t, r)
assert.Equal(t, expectedTargets[1].Name, r.Targets[0].Name)
assert.Equal(t, expectedTargets[0].Name, r.Targets[1].Name)
})
}
+36
View File
@@ -5,10 +5,21 @@ package cache
import (
stderrors "errors"
"fmt"
"regexp"
"slices"
"github.com/hashicorp/go-dbw"
)
// safeSortColumnRegex contains characters that could break SQL ORDER BY clauses
var safeSortColumnRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
var (
errInvalidSortColumn = stderrors.New("not allowed for this resource type")
errUnsafeSortColumn = stderrors.New("contains unsafe characters")
)
type testRefreshWaitChs struct {
firstSempahore chan struct{}
secondSemaphore chan struct{}
@@ -26,6 +37,8 @@ type options struct {
withMaxResultSetSize int
withTestRefreshWaitChs *testRefreshWaitChs
withUseNonPagedListing bool
withSortBy SortBy // validated DB column name
withSortDirection SortDirection // "asc" or "desc"
}
// Option - how options are passed as args
@@ -139,3 +152,26 @@ func WithUseNonPagedListing(b bool) Option {
return nil
}
}
// WithSort configures sorting for query results.
// Empty sortBy is silently ignored. Empty direction defaults to ascending in the repository layer.
// Validates column against sortableColumns and rejects SQL-unsafe characters.
func WithSort(sortBy SortBy, direction SortDirection, sortableColumns []SortBy) Option {
return func(o *options) error {
// ignore empty sortBy
if sortBy == SortByDefault {
return nil
}
switch {
case !slices.Contains(sortableColumns, sortBy):
return fmt.Errorf("invalid sort column %q: %w", sortBy, errInvalidSortColumn)
case !safeSortColumnRegex.MatchString(string(sortBy)):
return fmt.Errorf("invalid sort column %q: %w", sortBy, errUnsafeSortColumn)
}
o.withSortBy = sortBy
o.withSortDirection = direction
return nil
}
}
+101
View File
@@ -126,4 +126,105 @@ func Test_GetOpts(t *testing.T) {
testOpts.withUseNonPagedListing = true
assert.Equal(t, opts, testOpts)
})
t.Run("WithSort-default-sortby-ignored", func(t *testing.T) {
opts, err := getOpts(WithSort(SortByDefault, Ascending, []SortBy{SortByName}))
require.NoError(t, err)
testOpts := getDefaultOptions()
assert.Equal(t, opts, testOpts)
})
t.Run("WithSort-empty-sortby-ignored", func(t *testing.T) {
opts, err := getOpts(WithSort("", Ascending, []SortBy{SortByName}))
require.NoError(t, err)
testOpts := getDefaultOptions()
assert.Equal(t, opts, testOpts)
})
t.Run("WithSort-valid-name-ascending", func(t *testing.T) {
opts, err := getOpts(WithSort(SortByName, Ascending, []SortBy{SortByName, SortByCreatedTime}))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withSortBy = SortByName
testOpts.withSortDirection = Ascending
assert.Equal(t, opts, testOpts)
})
t.Run("WithSort-valid-created_time-descending", func(t *testing.T) {
opts, err := getOpts(WithSort(SortByCreatedTime, Descending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withSortBy = SortByCreatedTime
testOpts.withSortDirection = Descending
assert.Equal(t, opts, testOpts)
})
t.Run("WithSort-column-not-in-sortable-list", func(t *testing.T) {
_, err := getOpts(WithSort(SortByName, Ascending, []SortBy{SortByCreatedTime}))
require.Error(t, err)
assert.ErrorContains(t, err, errInvalidSortColumn.Error())
})
t.Run("WithSort-empty-sortable-columns", func(t *testing.T) {
_, err := getOpts(WithSort(SortByName, Ascending, []SortBy{}))
require.Error(t, err)
assert.ErrorContains(t, err, errInvalidSortColumn.Error())
})
t.Run("WithSort-nil-sortable-columns", func(t *testing.T) {
_, err := getOpts(WithSort(SortByName, Ascending, nil))
require.Error(t, err)
assert.ErrorContains(t, err, errInvalidSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-semicolon", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name; DROP TABLE"), Ascending, []SortBy{SortBy("name; DROP TABLE")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-quote", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name'--"), Ascending, []SortBy{SortBy("name'--")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-double-quote", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name\"--"), Ascending, []SortBy{SortBy("name\"--")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-backslash", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name\\x00"), Ascending, []SortBy{SortBy("name\\x00")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-comma", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name,other"), Ascending, []SortBy{SortBy("name,other")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-parenthesis", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name("), Ascending, []SortBy{SortBy("name(")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-space", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name "), Ascending, []SortBy{SortBy("name ")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-tab", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name\t"), Ascending, []SortBy{SortBy("name\t")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-newline", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name\n"), Ascending, []SortBy{SortBy("name\n")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-unsafe-chars-dash", func(t *testing.T) {
_, err := getOpts(WithSort(SortBy("name-col"), Ascending, []SortBy{SortBy("name-col")}))
require.Error(t, err)
assert.ErrorContains(t, err, errUnsafeSortColumn.Error())
})
t.Run("WithSort-default-direction", func(t *testing.T) {
opts, err := getOpts(WithSort(SortByName, SortDirectionDefault, []SortBy{SortByName}))
require.NoError(t, err)
testOpts := getDefaultOptions()
testOpts.withSortBy = SortByName
testOpts.withSortDirection = SortDirectionDefault
assert.Equal(t, opts, testOpts)
})
}
+32 -1
View File
@@ -19,6 +19,13 @@ import (
"github.com/hashicorp/mql"
)
// sessionSortFieldMap maps SortBy values to their corresponding JSON field paths.
// Sessions store data in a JSON blob, requiring json_extract for sorting.
var sessionSortFieldMap = map[SortBy]string{
SortByCreatedTime: "created_time",
// Add future sortable fields here
}
// SessionRetrievalFunc is a function that retrieves sessions
// from the provided boundary addr using the provided token.
type SessionRetrievalFunc func(ctx context.Context, addr, authTok string, refreshTok RefreshTokenValue, inPage *sessions.SessionListResult, opt ...Option) (ret *sessions.SessionListResult, refreshToken RefreshTokenValue, err error)
@@ -373,8 +380,32 @@ func (r *Repository) searchSessions(ctx context.Context, condition string, searc
searchArgs = append(searchArgs, opts.withUserId)
}
dbOpts := []db.Option{db.WithLimit(opts.withMaxResultSetSize + 1)}
if opts.withSortBy != SortByDefault {
var direction string
switch opts.withSortDirection {
// default to descending
case Descending, SortDirectionDefault:
direction = "desc"
case Ascending:
direction = "asc"
default:
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported sort direction: %v", opts.withSortDirection))
}
jsonField, ok := sessionSortFieldMap[opts.withSortBy]
if !ok {
return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("unsupported sort field for sessions: %v", opts.withSortBy))
}
// Sessions store data in a JSON blob (i.e. created_time), so we need to use json_extract for sorting
orderClause := fmt.Sprintf("json_extract(item, '$.%s') %s", jsonField, direction)
dbOpts = append(dbOpts, db.WithOrder(orderClause))
}
var cachedSessions []*Session
if err := r.rw.SearchWhere(ctx, &cachedSessions, condition, searchArgs, db.WithLimit(opts.withMaxResultSetSize+1)); err != nil {
if err := r.rw.SearchWhere(ctx, &cachedSessions, condition, searchArgs, dbOpts...); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
@@ -6,6 +6,7 @@ package cache
import (
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"testing"
@@ -643,3 +644,991 @@ func TestDefaultSessionRetrievalFunc(t *testing.T) {
assert.Empty(t, got2.RemovedIds)
assert.Empty(t, got2.Items)
}
func TestRepository_SearchSessionsSorting(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create sessions with different created_time times
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "status1",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: time.Now().Add(-2 * time.Hour),
},
{
Id: "ttcp_2",
Status: "status2",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: time.Now().Add(-1 * time.Hour),
},
{
Id: "ttcp_3",
Status: "status3",
Endpoint: "address3",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: time.Now(),
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
searchService, err := NewSearchService(ctx, r)
require.NoError(t, err)
t.Run("sort by created_time ascending", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
SortBy: SortByCreatedTime,
SortDirection: Ascending,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
require.Len(t, result.Sessions, 3)
assert.Equal(t, "ttcp_1", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
assert.Equal(t, "ttcp_3", result.Sessions[2].Id)
})
t.Run("sort by created_time descending", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
SortBy: SortByCreatedTime,
SortDirection: Descending,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
require.Len(t, result.Sessions, 3)
assert.Equal(t, "ttcp_3", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
assert.Equal(t, "ttcp_1", result.Sessions[2].Id)
})
t.Run("sort by created_time with default direction defaults to desc", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
SortBy: SortByCreatedTime,
SortDirection: SortDirectionDefault,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
require.Len(t, result.Sessions, 3)
assert.Equal(t, "ttcp_3", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
assert.Equal(t, "ttcp_1", result.Sessions[2].Id)
})
t.Run("no sort specified returns results", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
assert.Len(t, result.Sessions, 3)
})
t.Run("invalid sort column for sessions returns error", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
SortBy: SortByName, // name is not valid for sessions
SortDirection: Ascending,
}
_, err := searchService.Search(ctx, params)
require.Error(t, err)
assert.ErrorContains(t, err, errInvalidSortColumn.Error())
})
t.Run("sort with query", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
Query: `(status % "status")`,
SortBy: SortByCreatedTime,
SortDirection: Descending,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
require.Len(t, result.Sessions, 3)
assert.Equal(t, "ttcp_3", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
assert.Equal(t, "ttcp_1", result.Sessions[2].Id)
})
t.Run("sort with limit", func(t *testing.T) {
params := SearchParams{
Resource: Sessions,
AuthTokenId: kt.AuthTokenId,
SortBy: SortByCreatedTime,
SortDirection: Descending,
MaxResultSetSize: 2,
}
result, err := searchService.Search(ctx, params)
require.NoError(t, err)
assert.Len(t, result.Sessions, 2)
assert.True(t, result.Incomplete)
assert.Equal(t, "ttcp_3", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
})
}
func TestRepository_searchSessions_dbOptions(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create sessions with different created_time values for sorting tests
// These timestamps are intentionally different to verify sorting works correctly
now := time.Now()
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "status1",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-2 * time.Hour), // Oldest
},
{
Id: "ttcp_2",
Status: "status2",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-1 * time.Hour), // Middle
},
{
Id: "ttcp_3",
Status: "status3",
Endpoint: "address3",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now, // Newest
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
tests := []struct {
name string
sortBy SortBy
sortDirection SortDirection
maxResultSetSize int
expectedOrder []string // expected session IDs in order - verifies json_extract sorting works
expectIncomplete bool
}{
{
name: "default - no sorting",
sortBy: SortByDefault,
sortDirection: SortDirectionDefault,
maxResultSetSize: 10,
expectIncomplete: false,
},
{
name: "sort by created_time ascending - verifies json_extract asc works",
sortBy: SortByCreatedTime,
sortDirection: Ascending,
maxResultSetSize: 10,
expectedOrder: []string{"ttcp_1", "ttcp_2", "ttcp_3"}, // oldest to newest
expectIncomplete: false,
},
{
name: "sort by created_time descending - verifies json_extract desc works",
sortBy: SortByCreatedTime,
sortDirection: Descending,
maxResultSetSize: 10,
expectedOrder: []string{"ttcp_3", "ttcp_2", "ttcp_1"}, // newest to oldest
expectIncomplete: false,
},
{
name: "sort ascending with limit - verifies LIMIT clause",
sortBy: SortByCreatedTime,
sortDirection: Ascending,
maxResultSetSize: 2,
expectedOrder: []string{"ttcp_1", "ttcp_2"},
expectIncomplete: true,
},
{
name: "sort descending with limit - verifies LIMIT clause",
sortBy: SortByCreatedTime,
sortDirection: Descending,
maxResultSetSize: 2,
expectedOrder: []string{"ttcp_3", "ttcp_2"},
expectIncomplete: true,
},
{
name: "no limit (-1)",
sortBy: SortByCreatedTime,
sortDirection: Ascending,
maxResultSetSize: -1,
expectedOrder: []string{"ttcp_1", "ttcp_2", "ttcp_3"},
expectIncomplete: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Build options
opts := []Option{
withAuthTokenId(at.Id),
WithMaxResultSetSize(tt.maxResultSetSize),
}
if tt.sortBy != SortByDefault {
opts = append(opts, WithSort(tt.sortBy, tt.sortDirection, []SortBy{SortByCreatedTime}))
}
// Execute the search
result, err := r.searchSessions(ctx, "true", nil, opts...)
require.NoError(t, err)
require.NotNil(t, result)
// Verify the result
if tt.expectIncomplete {
assert.True(t, result.Incomplete, "expected incomplete result")
assert.Len(t, result.Sessions, tt.maxResultSetSize, "expected limited results")
} else {
assert.False(t, result.Incomplete, "expected complete result")
}
// Verify the order if sorting was applied
// This proves json_extract(item, '$.created_time') is working correctly
if tt.sortBy != SortByDefault && len(tt.expectedOrder) > 0 {
var actualOrder []string
for _, sess := range result.Sessions {
actualOrder = append(actualOrder, sess.Id)
}
assert.Equal(t, tt.expectedOrder[:len(actualOrder)], actualOrder, "sessions not in expected order")
// Additionally verify the timestamps are actually in the correct order
if len(result.Sessions) > 1 {
for i := 0; i < len(result.Sessions)-1; i++ {
curr := result.Sessions[i].CreatedTime
next := result.Sessions[i+1].CreatedTime
if tt.sortDirection == Ascending {
assert.True(t, !curr.After(next), "timestamps should be in ascending order")
} else {
assert.True(t, !curr.Before(next), "timestamps should be in descending order")
}
}
}
}
})
}
}
func TestRepository_searchSessions_dbOptionsWithQuery(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create sessions with different created_time values
now := time.Now()
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "pending",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-2 * time.Hour),
},
{
Id: "ttcp_2",
Status: "active",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-1 * time.Hour),
},
{
Id: "ttcp_3",
Status: "active",
Endpoint: "address3",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now,
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
tests := []struct {
name string
query string
sortBy SortBy
sortDirection SortDirection
maxResultSetSize int
expectedCount int
expectedOrder []string
expectIncomplete bool
}{
{
name: "query with sort ascending",
query: `status % "active"`,
sortBy: SortByCreatedTime,
sortDirection: Ascending,
maxResultSetSize: 10,
expectedCount: 2,
expectedOrder: []string{"ttcp_2", "ttcp_3"},
expectIncomplete: false,
},
{
name: "query with sort descending",
query: `status % "active"`,
sortBy: SortByCreatedTime,
sortDirection: Descending,
maxResultSetSize: 10,
expectedCount: 2,
expectedOrder: []string{"ttcp_3", "ttcp_2"},
expectIncomplete: false,
},
{
name: "query with sort and limit",
query: `status % "active"`,
sortBy: SortByCreatedTime,
sortDirection: Descending,
maxResultSetSize: 1,
expectedCount: 1,
expectedOrder: []string{"ttcp_3"},
expectIncomplete: true,
},
{
name: "query without sort",
query: `status % "active"`,
sortBy: SortByDefault,
sortDirection: SortDirectionDefault,
maxResultSetSize: 10,
expectedCount: 2,
expectIncomplete: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Build options
opts := []Option{
WithMaxResultSetSize(tt.maxResultSetSize),
}
if tt.sortBy != SortByDefault {
opts = append(opts, WithSort(tt.sortBy, tt.sortDirection, []SortBy{SortByCreatedTime}))
}
// Execute the query
result, err := r.QuerySessions(ctx, at.Id, tt.query, opts...)
require.NoError(t, err)
require.NotNil(t, result)
// Verify results
assert.Len(t, result.Sessions, tt.expectedCount)
assert.Equal(t, tt.expectIncomplete, result.Incomplete)
// Verify order if expected
if len(tt.expectedOrder) > 0 {
var actualOrder []string
for _, sess := range result.Sessions {
actualOrder = append(actualOrder, sess.Id)
}
assert.Equal(t, tt.expectedOrder, actualOrder)
}
})
}
}
func TestRepository_searchSessions_limitBehavior(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create 10 sessions
var ss []*sessions.Session
for i := 0; i < 10; i++ {
ss = append(ss, &sessions.Session{
Id: fmt.Sprintf("ttcp_%d", i),
Status: "active",
Endpoint: fmt.Sprintf("address%d", i),
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
})
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
tests := []struct {
name string
maxResultSetSize int
expectedCount int
expectIncomplete bool
}{
{
name: "limit of 5",
maxResultSetSize: 5,
expectedCount: 5,
expectIncomplete: true,
},
{
name: "limit of 10 (exact match)",
maxResultSetSize: 10,
expectedCount: 10,
expectIncomplete: false,
},
{
name: "limit of 15 (more than available)",
maxResultSetSize: 15,
expectedCount: 10,
expectIncomplete: false,
},
{
name: "no limit (-1)",
maxResultSetSize: -1,
expectedCount: 10,
expectIncomplete: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Execute the list
result, err := r.ListSessions(ctx, at.Id, WithMaxResultSetSize(tt.maxResultSetSize))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, tt.expectedCount)
assert.Equal(t, tt.expectIncomplete, result.Incomplete)
})
}
}
func TestRepository_searchSessions_dbOptionsVerifyOrderClause(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create a single session
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "active",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
t.Run("verify order clause format for ascending", func(t *testing.T) {
result, err := r.ListSessions(ctx, at.Id,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Ascending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
})
t.Run("verify order clause format for descending", func(t *testing.T) {
result, err := r.ListSessions(ctx, at.Id,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Descending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
})
t.Run("verify no order clause when sortBy is default", func(t *testing.T) {
result, err := r.ListSessions(ctx, at.Id, WithMaxResultSetSize(10))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
})
}
func TestRepository_searchSessions_sortingEdgeCases(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create test sessions
now := time.Now()
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "active",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-1 * time.Hour),
},
{
Id: "ttcp_2",
Status: "pending",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now,
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
t.Run("unsupported sort field - SortByName not in sessionSortFieldMap", func(t *testing.T) {
// SortByName is not in sessionSortFieldMap, should return error
result, err := r.searchSessions(ctx, "true", nil,
withAuthTokenId(at.Id),
WithMaxResultSetSize(10),
WithSort(SortByName, Ascending, []SortBy{SortByName}))
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "unsupported sort field for sessions")
})
t.Run("invalid sort direction - unknown value", func(t *testing.T) {
// Using an invalid sort direction that's not in the switch statement
result, err := r.searchSessions(ctx, "true", nil,
withAuthTokenId(at.Id),
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, SortDirection("invalid"), []SortBy{SortByCreatedTime}))
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "unsupported sort direction")
})
t.Run("valid sort with SortDirectionDefault - should default to descending", func(t *testing.T) {
// SortDirectionDefault should be treated as ascending
result, err := r.searchSessions(ctx, "true", nil,
withAuthTokenId(at.Id),
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, SortDirectionDefault, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 2)
// Should be in descending order (newest first)
assert.Equal(t, "ttcp_2", result.Sessions[0].Id)
assert.Equal(t, "ttcp_1", result.Sessions[1].Id)
})
t.Run("sort with limit 1 - verify incomplete flag", func(t *testing.T) {
result, err := r.searchSessions(ctx, "true", nil,
withAuthTokenId(at.Id),
WithMaxResultSetSize(1),
WithSort(SortByCreatedTime, Descending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
assert.True(t, result.Incomplete)
assert.Equal(t, "ttcp_2", result.Sessions[0].Id) // newest first
})
t.Run("sort with no results - empty result set", func(t *testing.T) {
// Query that matches nothing
result, err := r.QuerySessions(ctx, at.Id, `status % "nonexistent"`,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Ascending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 0)
assert.False(t, result.Incomplete)
})
}
func TestRepository_searchSessions_jsonExtractSafety(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create test session
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "active",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: time.Now(),
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
t.Run("verify json_extract uses safe field from sessionSortFieldMap", func(t *testing.T) {
// This test verifies that the json_extract uses the mapped field name
// from sessionSortFieldMap, which should be safe
result, err := r.searchSessions(ctx, "true", nil,
withAuthTokenId(at.Id),
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Ascending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
// If we got here without SQL errors, json_extract is working correctly
})
t.Run("verify sorting works with actual JSON data", func(t *testing.T) {
// Verify that the JSON item field contains valid data and sorting works
result, err := r.ListSessions(ctx, at.Id,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Descending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
assert.Equal(t, "ttcp_1", result.Sessions[0].Id)
// Verify the session has all expected fields from JSON unmarshaling
assert.Equal(t, "active", result.Sessions[0].Status)
assert.Equal(t, "address1", result.Sessions[0].Endpoint)
})
}
func TestRepository_ListSessions_sortingValidation(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create test sessions
now := time.Now()
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "active",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-1 * time.Hour),
},
{
Id: "ttcp_2",
Status: "pending",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now,
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
t.Run("ListSessions with unsupported sort field", func(t *testing.T) {
// Trying to sort by a field not in sessionSortFieldMap
result, err := r.ListSessions(ctx, at.Id,
WithMaxResultSetSize(10),
WithSort(SortByName, Ascending, []SortBy{SortByName}))
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "unsupported sort field for sessions")
})
t.Run("ListSessions with valid sort field", func(t *testing.T) {
result, err := r.ListSessions(ctx, at.Id,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Ascending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 2)
assert.Equal(t, "ttcp_1", result.Sessions[0].Id)
assert.Equal(t, "ttcp_2", result.Sessions[1].Id)
})
}
func TestRepository_QuerySessions_sortingValidation(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
addr := "address"
u := &user{
Id: "u1",
Address: addr,
}
at := &authtokens.AuthToken{
Id: "at_1",
Token: "at_1_token",
UserId: u.Id,
}
kt := KeyringToken{
KeyringType: "k1",
TokenName: "t1",
AuthTokenId: at.Id,
}
atMap := map[ringToken]*authtokens.AuthToken{
{"k1", "t1"}: at,
}
r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap)))
require.NoError(t, err)
require.NoError(t, r.AddKeyringToken(ctx, addr, kt))
// Create test sessions
now := time.Now()
ss := []*sessions.Session{
{
Id: "ttcp_1",
Status: "active",
Endpoint: "address1",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now.Add(-1 * time.Hour),
},
{
Id: "ttcp_2",
Status: "active",
Endpoint: "address2",
ScopeId: "p_123",
TargetId: "ttcp_123",
UserId: "u_123",
Type: "tcp",
CreatedTime: now,
},
}
require.NoError(t, r.refreshSessions(ctx, u, map[AuthToken]string{{Id: "id"}: "something"},
WithSessionRetrievalFunc(testSessionStaticResourceRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))))
t.Run("QuerySessions with unsupported sort field", func(t *testing.T) {
result, err := r.QuerySessions(ctx, at.Id, `status % "active"`,
WithMaxResultSetSize(10),
WithSort(SortByName, Ascending, []SortBy{SortByName}))
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "unsupported sort field for sessions")
})
t.Run("QuerySessions with valid sort field and query", func(t *testing.T) {
result, err := r.QuerySessions(ctx, at.Id, `status % "active"`,
WithMaxResultSetSize(10),
WithSort(SortByCreatedTime, Descending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 2)
// Descending order - newest first
assert.Equal(t, "ttcp_2", result.Sessions[0].Id)
assert.Equal(t, "ttcp_1", result.Sessions[1].Id)
})
t.Run("QuerySessions with sort and limit causing incomplete", func(t *testing.T) {
result, err := r.QuerySessions(ctx, at.Id, `status % "active"`,
WithMaxResultSetSize(1),
WithSort(SortByCreatedTime, Ascending, []SortBy{SortByCreatedTime}))
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Sessions, 1)
assert.True(t, result.Incomplete)
assert.Equal(t, "ttcp_1", result.Sessions[0].Id) // oldest first
})
}
+11 -1
View File
@@ -373,8 +373,18 @@ func (r *Repository) searchTargets(ctx context.Context, condition string, search
searchArgs = append(searchArgs, opts.withUserId)
}
dbOpts := []db.Option{db.WithLimit(opts.withMaxResultSetSize + 1)}
if opts.withSortBy != "" {
sd := opts.withSortDirection
if sd != Ascending && sd != Descending {
sd = Ascending
}
orderClause := fmt.Sprintf("%s %s", opts.withSortBy, sd)
dbOpts = append(dbOpts, db.WithOrder(orderClause))
}
var cachedTargets []*Target
if err := r.rw.SearchWhere(ctx, &cachedTargets, condition, searchArgs, db.WithLimit(opts.withMaxResultSetSize+1)); err != nil {
if err := r.rw.SearchWhere(ctx, &cachedTargets, condition, searchArgs, dbOpts...); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
@@ -358,6 +358,27 @@ func TestRepository_ListTargets(t *testing.T) {
assert.Len(t, l.Targets, len(ts))
assert.ElementsMatch(t, l.Targets, ts)
})
t.Run("withSortBy sorts targets", func(t *testing.T) {
l, err := r.ListTargets(ctx, kt1.AuthTokenId, WithSort(SortByName, Descending, []SortBy{SortByName}))
assert.NoError(t, err)
assert.Equal(t, ts[2].Name, l.Targets[0].Name)
assert.Equal(t, ts[1].Name, l.Targets[1].Name)
assert.Equal(t, ts[0].Name, l.Targets[2].Name)
})
t.Run("withSortBy bad SortBy errors", func(t *testing.T) {
_, err := r.ListTargets(ctx, kt1.AuthTokenId, WithSort(SortByCreatedTime, Descending, []SortBy{SortByName}))
assert.Error(t, err)
})
t.Run("withSortBy bad SortDirection defaults to Ascending", func(t *testing.T) {
l, err := r.ListTargets(ctx, kt1.AuthTokenId, WithSort(SortByName, "Something else", []SortBy{SortByName}))
assert.NoError(t, err)
assert.Equal(t, ts[0].Name, l.Targets[0].Name)
assert.Equal(t, ts[1].Name, l.Targets[1].Name)
assert.Equal(t, ts[2].Name, l.Targets[2].Name)
})
}
func TestRepository_ListTargetsLimiting(t *testing.T) {
@@ -525,6 +546,25 @@ func TestRepository_QueryTargets(t *testing.T) {
assert.Len(t, l.Targets, 2)
assert.ElementsMatch(t, l.Targets, ts[0:2])
})
t.Run("withSortBy sorts targets", func(t *testing.T) {
l, err := r.QueryTargets(ctx, kt1.AuthTokenId, query, WithSort(SortByName, Descending, []SortBy{SortByName}))
assert.NoError(t, err)
assert.Equal(t, ts[1].Name, l.Targets[0].Name)
assert.Equal(t, ts[0].Name, l.Targets[1].Name)
})
t.Run("withSortBy bad SortBy errors", func(t *testing.T) {
_, err := r.QueryTargets(ctx, kt1.AuthTokenId, query, WithSort(SortByCreatedTime, Descending, []SortBy{SortByName}))
assert.Error(t, err)
})
t.Run("withSortBy bad SortDirection defaults to Ascending", func(t *testing.T) {
l, err := r.QueryTargets(ctx, kt1.AuthTokenId, query, WithSort(SortByName, "Something else", []SortBy{SortByName}))
assert.NoError(t, err)
assert.Equal(t, ts[0].Name, l.Targets[0].Name)
assert.Equal(t, ts[1].Name, l.Targets[1].Name)
})
}
func TestRepository_QueryTargetsLimiting(t *testing.T) {
+53 -2
View File
@@ -17,6 +17,40 @@ import (
"github.com/hashicorp/go-bexpr"
)
type SortBy string
const (
SortByDefault SortBy = ""
SortByName SortBy = "name"
SortByCreatedTime SortBy = "created_time"
)
// Valid returns true if the SortBy value is a known good value
func (s SortBy) Valid() bool {
switch s {
case SortByDefault, SortByName, SortByCreatedTime:
return true
}
return false
}
type SortDirection string
const (
SortDirectionDefault SortDirection = ""
Ascending SortDirection = "asc"
Descending SortDirection = "desc"
)
// Valid returns true if the SortDirection value is a known good value
func (d SortDirection) Valid() bool {
switch d {
case SortDirectionDefault, Ascending, Descending:
return true
}
return false
}
type SearchableResource string
const (
@@ -61,6 +95,10 @@ type SearchParams struct {
Filter string
// Max result set size is an override to the default max result set size
MaxResultSetSize int
// Which column to sort results by, default is resource specific
SortBy SortBy
// Which direction to sort results by (asc, desc), default is resource specific
SortDirection SortDirection
}
// SearchResult returns the results from searching the cache.
@@ -116,6 +154,7 @@ func NewSearchService(ctx context.Context, repo *Repository) (*SearchService, er
}
in.Targets = finalResults
},
sortableColumns: []SortBy{SortByName},
},
Sessions: &resourceSearchFns[*sessions.Session]{
list: repo.ListSessions,
@@ -129,6 +168,7 @@ func NewSearchService(ctx context.Context, repo *Repository) (*SearchService, er
}
in.Sessions = finalResults
},
sortableColumns: []SortBy{SortByCreatedTime},
},
ImplicitScopes: &resourceSearchFns[*scopes.Scope]{
list: repo.ListImplicitScopes,
@@ -184,6 +224,10 @@ func (s *SearchService) Search(ctx context.Context, params SearchParams) (*Searc
return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid resource")
case params.AuthTokenId == "":
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token id")
case !params.SortBy.Valid():
return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid sort by value")
case !params.SortDirection.Valid():
return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid sort direction value")
}
rSearcher, ok := s.searchableResources[params.Resource]
if !ok {
@@ -211,6 +255,8 @@ type resourceSearchFns[T any] struct {
// filter takes results and a ready-to-use evaluator and filters the items
// in the result
filter func(*SearchResult, *bexpr.Evaluator)
// sortableColumns is a list of columns that can be used for sorting
sortableColumns []SortBy
}
// resourceSearcher is an interface that only resourceSearchFns[T] is expected
@@ -229,13 +275,18 @@ type resourceSearcher interface {
func (l *resourceSearchFns[T]) search(ctx context.Context, p SearchParams) (*SearchResult, error) {
const op = "cache.(resourceSearchFns).search"
opts := []Option{WithMaxResultSetSize(p.MaxResultSetSize)}
if p.SortBy != SortByDefault {
opts = append(opts, WithSort(p.SortBy, p.SortDirection, l.sortableColumns))
}
var found *SearchResult
var err error
switch p.Query {
case "":
found, err = l.list(ctx, p.AuthTokenId, WithMaxResultSetSize(p.MaxResultSetSize))
found, err = l.list(ctx, p.AuthTokenId, opts...)
default:
found, err = l.query(ctx, p.AuthTokenId, p.Query, WithMaxResultSetSize(p.MaxResultSetSize))
found, err = l.query(ctx, p.AuthTokenId, p.Query, opts...)
}
if err != nil {
return nil, errors.Wrap(ctx, err, op)
+163
View File
@@ -442,3 +442,166 @@ func TestSearch(t *testing.T) {
assert.Equal(t, &SearchResult{Targets: []*targets.Target{}}, got)
})
}
func TestSortByValid(t *testing.T) {
cases := []struct {
sortBy SortBy
valid bool
}{
{SortByDefault, true},
{SortBy(""), true},
{SortByName, true},
{SortByCreatedTime, true},
{SortBy("unknown"), false},
{SortBy("id"), false},
{SortBy("invalid_column"), false},
{SortBy("name; DROP TABLE"), false},
{SortBy("name'--"), false},
{SortBy("name\"--"), false},
{SortBy("name\\x00"), false},
{SortBy("name,other"), false},
{SortBy("name ("), false},
{SortBy("name)"), false},
{SortBy("name\t"), false},
{SortBy("name\n"), false},
{SortBy("name\r"), false},
{SortBy("name "), false},
}
for _, tc := range cases {
t.Run(string(tc.sortBy), func(t *testing.T) {
assert.Equal(t, tc.valid, tc.sortBy.Valid())
})
}
}
func TestSortDirectionValid(t *testing.T) {
cases := []struct {
direction SortDirection
valid bool
}{
{SortDirectionDefault, true},
{SortDirection(""), true},
{Ascending, true},
{Descending, true},
{SortDirection("ASC"), false},
{SortDirection("DESC"), false},
{SortDirection("invalid"), false},
}
for _, tc := range cases {
t.Run(string(tc.direction), func(t *testing.T) {
assert.Equal(t, tc.valid, tc.direction.Valid())
})
}
}
func TestSearch_Sorting(t *testing.T) {
ctx := context.Background()
s, err := cachedb.Open(ctx)
require.NoError(t, err)
at := &AuthToken{
Id: "at_sort",
UserId: "u_sort",
}
{
u := &user{Id: at.UserId, Address: "address"}
rw := db.New(s)
require.NoError(t, rw.Create(ctx, u))
require.NoError(t, rw.Create(ctx, at))
targets := []*Target{
{FkUserId: u.Id, Id: "t_1", Name: "alpha", Type: "tcp", Item: `{"id": "t_1", "name": "alpha", "type": "tcp"}`},
{FkUserId: u.Id, Id: "t_2", Name: "charlie", Type: "tcp", Item: `{"id": "t_2", "name": "charlie", "type": "tcp"}`},
{FkUserId: u.Id, Id: "t_3", Name: "bravo", Type: "tcp", Item: `{"id": "t_3", "name": "bravo", "type": "tcp"}`},
}
require.NoError(t, rw.CreateItems(ctx, targets))
sessions := []*Session{
{FkUserId: u.Id, Id: "s_1", Endpoint: "one", Type: "tcp", UserId: "u123", Item: `{"id": "s_1", "endpoint": "one", "type": "tcp", "user_id": "u123"}`},
{FkUserId: u.Id, Id: "s_2", Endpoint: "two", Type: "ssh", UserId: "u321", Item: `{"id": "s_2", "endpoint": "two", "type": "ssh", "user_id": "u321"}`},
}
require.NoError(t, rw.CreateItems(ctx, sessions))
aliases := []*ResolvableAlias{
{FkUserId: u.Id, Id: "alt_1", Value: "one", Type: "target", Item: `{"id": "alt_1", "value": "one", "type": "target"}`},
}
require.NoError(t, rw.CreateItems(ctx, aliases))
}
r, err := NewRepository(ctx, s, &sync.Map{},
mapBasedAuthTokenKeyringLookup(nil),
sliceBasedAuthTokenBoundaryReader(nil))
require.NoError(t, err)
ss, err := NewSearchService(ctx, r)
require.NoError(t, err)
t.Run("no sort specified returns results", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: Targets,
AuthTokenId: at.Id,
})
require.NoError(t, err)
require.Len(t, got.Targets, 3)
})
t.Run("invalid sort by value", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: Targets,
AuthTokenId: at.Id,
SortBy: SortBy("invalid_column"),
})
assert.Error(t, err)
assert.ErrorContains(t, err, "invalid sort by value")
assert.Nil(t, got)
})
t.Run("invalid sort direction value", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: Targets,
AuthTokenId: at.Id,
SortBy: SortByName,
SortDirection: SortDirection("invalid"),
})
assert.Error(t, err)
assert.ErrorContains(t, err, "invalid sort direction value")
assert.Nil(t, got)
})
t.Run("sort column not allowed for resolvable aliases", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: ResolvableAliases,
AuthTokenId: at.Id,
SortBy: SortByName,
SortDirection: Ascending,
})
assert.Error(t, err)
assert.ErrorContains(t, err, "not allowed for this resource type")
assert.Nil(t, got)
})
t.Run("sessions reject name sort", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: Sessions,
AuthTokenId: at.Id,
SortBy: SortByName,
SortDirection: Ascending,
})
assert.Error(t, err)
assert.ErrorContains(t, err, "not allowed for this resource type")
assert.Nil(t, got)
})
t.Run("sessions accept created time sort", func(t *testing.T) {
got, err := ss.Search(ctx, SearchParams{
Resource: Sessions,
AuthTokenId: at.Id,
SortBy: SortByCreatedTime,
SortDirection: Descending,
})
require.NoError(t, err)
require.Len(t, got.Sessions, 2)
})
}
@@ -9,7 +9,9 @@ import (
stderrors "errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/api/aliases"
@@ -57,8 +59,18 @@ const (
forceRefreshKey = "force_refresh"
authTokenIdKey = "auth_token_id"
maxResultSetSizeKey = "max_result_set_size"
// SortByKey The key used as the query parameter for which column to sort resources by
SortByKey = "sort_by"
// SortDirectionKey The key used as the query parameter for which direction, asc or desc, to sort resources by.
SortDirectionKey = "sort_direction"
)
// SortableColumnsForResource Defines which columns a given resource can be sorted by
var SortableColumnsForResource = map[cache.SearchableResource][]cache.SortBy{
cache.Targets: {cache.SortByName},
cache.Sessions: {cache.SortByCreatedTime},
}
func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshService *cache.RefreshService, logger hclog.Logger) (http.HandlerFunc, error) {
const op = "daemon.newSearchHandlerFunc"
switch {
@@ -84,6 +96,8 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe
maxResultSetSizeInt, maxResultSetSizeIntErr := strconv.Atoi(maxResultSetSizeStr)
query := q.Get(queryKey)
filter := q.Get(filterKey)
sb := q.Get(SortByKey)
sd := q.Get(SortDirectionKey)
searchableResource := cache.ToSearchableResource(resource)
switch {
@@ -121,6 +135,20 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe
return
}
sortBy, valid := parseSortBy(sb, searchableResource)
if !valid {
event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("sort_by parameter %q not valid for resource %q", sb, searchableResource)))
writeError(w, fmt.Sprintf("sort_by parameter %q not valid for resource %q", sb, searchableResource), http.StatusBadRequest)
return
}
sortDirection, valid := parseSortDirection(sd)
if !valid {
event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("sort_direction parameter %q not valid", sd)))
writeError(w, fmt.Sprintf("sort_direction parameter %q not valid ", sd), http.StatusBadRequest)
return
}
t, err := repo.LookupToken(reqCtx, authTokenId, cache.WithUpdateLastAccessedTime(true))
if err != nil || t == nil {
if err != nil {
@@ -175,6 +203,8 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe
Query: query,
Filter: filter,
MaxResultSetSize: maxResultSetSizeInt,
SortBy: sortBy,
SortDirection: sortDirection,
})
if err != nil {
event.WriteError(ctx, op, err, event.WithInfoMsg("when performing search", "auth_token_id", authTokenId, "resource", searchableResource, "query", query, "filter", filter))
@@ -237,3 +267,36 @@ func writeUnsupportedError(w http.ResponseWriter) {
}
http.Error(w, string(b), http.StatusBadRequest)
}
// Parses a raw sort direction string into a cache.SortDirection
// Returns the sort direction and whether the provided direction was valid or not
func parseSortDirection(sd string) (cache.SortDirection, bool) {
sd = strings.ToLower(sd)
switch sd {
case "asc", "ascending":
return cache.Ascending, true
case "desc", "descending":
return cache.Descending, true
case "":
return cache.SortDirectionDefault, true
default:
return cache.SortDirectionDefault, false
}
}
// Parses a raw column name to sort by into a cache.SortBy
// Returns the column to sort by and whether the provided column was valid or not
func parseSortBy(sb string, sr cache.SearchableResource) (cache.SortBy, bool) {
sb = strings.ToLower(sb)
by := cache.SortBy(sb)
if by == cache.SortByDefault {
return cache.SortByDefault, true
}
sortableBys, ok := SortableColumnsForResource[sr]
if !ok || !slices.Contains(sortableBys, by) {
return cache.SortByDefault, false
}
return by, true
}
@@ -0,0 +1,60 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package daemon
import (
"testing"
"github.com/hashicorp/boundary/internal/clientcache/internal/cache"
"github.com/stretchr/testify/assert"
)
func TestParseSortBy(t *testing.T) {
testCases := []struct {
inputSb string
inputSr cache.SearchableResource
expectedValid bool
expectedSortBy cache.SortBy
}{
{"name", cache.Targets, true, cache.SortByName},
{"name", cache.Sessions, false, cache.SortByDefault},
{"created_time", cache.Targets, false, cache.SortByDefault},
{"created_time", cache.Sessions, true, cache.SortByCreatedTime},
{"", cache.Targets, true, cache.SortByDefault},
{"", cache.Sessions, true, cache.SortByDefault},
{"ljkdhnsfg", cache.Targets, false, cache.SortByDefault},
{"xcvbxcvb", cache.Sessions, false, cache.SortByDefault},
{"name ", cache.Targets, false, cache.SortByDefault}, // Unicode no break space
{"name\u202e", cache.Targets, false, cache.SortByDefault}, // Unicode RtL override
{"\u202ename", cache.Targets, false, cache.SortByDefault}, // Unicode RtL override
}
for _, tc := range testCases {
actualSortBy, actualValid := parseSortBy(tc.inputSb, tc.inputSr)
assert.Equal(t, tc.expectedSortBy, actualSortBy)
assert.Equal(t, tc.expectedValid, actualValid)
}
}
func TestParseSortDirection(t *testing.T) {
testCases := []struct {
inputSd string
expectedValid bool
expectedSortDirection cache.SortDirection
}{
{"asc", true, cache.Ascending},
{"ascending", true, cache.Ascending},
{"desc", true, cache.Descending},
{"descending", true, cache.Descending},
{"", true, cache.SortDirectionDefault},
{"asdasd", false, cache.SortDirectionDefault},
{"asc ", false, cache.SortDirectionDefault},
{"name\u202e", false, cache.SortDirectionDefault},
{"\u202ename", false, cache.SortDirectionDefault},
}
for _, tc := range testCases {
actualSortDirection, actualValid := parseSortDirection(tc.inputSd)
assert.Equal(t, tc.expectedSortDirection, actualSortDirection)
assert.Equal(t, tc.expectedValid, actualValid)
}
}