From b4bd07fe3ca3744d8ae21ac19b76b4e2505d1f63 Mon Sep 17 00:00:00 2001 From: April-May <18632637+AprilMay0@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:44:38 -0800 Subject: [PATCH] feat(apptoken): Implement ListRefreshPage (#6387) Co-authored-by: Michael Milton --- internal/apptoken/repository_test.go | 163 +++- .../apptoken/repository_token_grant_test.go | 95 ++- internal/apptoken/service_list_page.go | 2 +- internal/apptoken/service_list_refresh.go | 2 +- .../apptoken/service_list_refresh_page.go | 83 ++ internal/apptoken/service_list_test.go | 737 +++++++++++++++--- internal/apptoken/testing.go | 151 +--- 7 files changed, 954 insertions(+), 279 deletions(-) create mode 100644 internal/apptoken/service_list_refresh_page.go diff --git a/internal/apptoken/repository_test.go b/internal/apptoken/repository_test.go index 0a6673144f..b5db1f7563 100644 --- a/internal/apptoken/repository_test.go +++ b/internal/apptoken/repository_test.go @@ -890,9 +890,39 @@ func TestRepository_queryAppTokens(t *testing.T) { orgUser := iam.TestUser(t, iamRepo, org1.PublicId) // Create app tokens - gToken := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - orgToken := TestAppToken(t, repo, org1.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - projToken := TestAppToken(t, repo, proj1.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) + gToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: globals.GlobalPrefix, + CreatedByUserId: globalUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + orgToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org1.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + projToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj1.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) testCases := []struct { name string @@ -990,9 +1020,39 @@ func TestRepository_listAppTokens(t *testing.T) { orgUser := iam.TestUser(t, iamRepo, org1.PublicId) // Create app tokens - gToken := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - orgToken := TestAppToken(t, repo, org1.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - projToken := TestAppToken(t, repo, proj1.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) + gToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: globals.GlobalPrefix, + CreatedByUserId: globalUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + orgToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org1.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + projToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj1.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) testCases := []struct { name string @@ -1086,7 +1146,17 @@ func TestRepository_listAppTokens(t *testing.T) { // Create enough tokens to exceed the limit for range make([]int, 5) { - TestAppToken(t, repo, orgExceedLimit.PublicId, orgExceedLimitUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") + TestCreateAppToken(t, repo, &AppToken{ + ScopeId: orgExceedLimit.PublicId, + CreatedByUserId: orgExceedLimitUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) } tokens, _, err := repo.listAppTokens(ctx, []string{orgExceedLimit.PublicId}, []Option{WithLimit(10)}...) @@ -1112,13 +1182,50 @@ func TestRepository_listAppTokensRefresh(t *testing.T) { orgUser := iam.TestUser(t, iamRepo, org1.PublicId) expireInSixSeconds := timestamp.New(timestamp.Now().AsTime().Add(6 * time.Second)) + staleInFourSeconds := uint32(4) // Create app tokens allTokens := []*AppToken{} for range 3 { - gToken := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 4, expireInSixSeconds, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - orgToken := TestAppToken(t, repo, org1.PublicId, orgUser, 4, expireInSixSeconds, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - projToken := TestAppToken(t, repo, proj1.PublicId, orgUser, 4, expireInSixSeconds, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) + gToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: globals.GlobalPrefix, + CreatedByUserId: globalUser.PublicId, + ExpirationTime: expireInSixSeconds, + TimeToStaleSeconds: staleInFourSeconds, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + orgToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org1.PublicId, + CreatedByUserId: orgUser.PublicId, + ExpirationTime: expireInSixSeconds, + TimeToStaleSeconds: staleInFourSeconds, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + projToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj1.PublicId, + CreatedByUserId: orgUser.PublicId, + ExpirationTime: expireInSixSeconds, + TimeToStaleSeconds: staleInFourSeconds, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) allTokens = append(allTokens, gToken, orgToken, projToken) } @@ -1261,9 +1368,39 @@ func TestRepository_estimatedCount(t *testing.T) { org, proj := iam.TestScopes(t, iamRepo, iam.WithName("org1"), iam.WithDescription("Test Org 1")) orgUser := iam.TestUser(t, iamRepo, org.PublicId) - gToken := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - oToken := TestAppToken(t, repo, org.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) - pToken := TestAppToken(t, repo, proj.PublicId, orgUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, globals.GrantScopeIndividual) + gToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: globals.GlobalPrefix, + CreatedByUserId: globalUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + oToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + pToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj.PublicId, + CreatedByUserId: orgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) // Run analyze to update estimate _, err = sqlDb.ExecContext(ctx, "analyze") diff --git a/internal/apptoken/repository_token_grant_test.go b/internal/apptoken/repository_token_grant_test.go index c0be919404..2f09681b85 100644 --- a/internal/apptoken/repository_token_grant_test.go +++ b/internal/apptoken/repository_token_grant_test.go @@ -47,7 +47,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=group;actions=list", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.Group, resource.Scope}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -55,9 +55,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=group;actions=list,ids=*;type=scope;actions=list,read", }, }, @@ -69,7 +69,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=account;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.Account}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -77,9 +77,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=account;actions=list,read", }, }, @@ -91,7 +91,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=credential-library;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.CredentialLibrary}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -99,9 +99,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=credential-library;actions=list,read", }, }, @@ -114,7 +114,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "children", + grantScope: globals.GrantScopeChildren, rTypes: []resource.Type{resource.Account, resource.Scope}, tokenScopeId: org1.PublicId, reqScopeId: org1.PublicId, @@ -123,8 +123,8 @@ func TestGrantsForToken(t *testing.T) { expectedGrants: tempGrantTuples{ { AppTokenScopeId: org1.PublicId, - AppTokenParentScopeId: "global", - GrantScopeId: "children", + AppTokenParentScopeId: globals.GlobalPrefix, + GrantScopeId: globals.GrantScopeChildren, Grant: "ids=*;type=account;actions=list,ids=*;type=scope;actions=list,read", }, }, @@ -136,7 +136,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=auth-method;actions=list,read", }, grantThisScope: true, - grantScope: "children", + grantScope: globals.GrantScopeChildren, rTypes: []resource.Type{resource.AuthMethod}, tokenScopeId: org1.PublicId, reqScopeId: org1.PublicId, @@ -145,8 +145,8 @@ func TestGrantsForToken(t *testing.T) { expectedGrants: tempGrantTuples{ { AppTokenScopeId: org1.PublicId, - AppTokenParentScopeId: "global", - GrantScopeId: "children", + AppTokenParentScopeId: globals.GlobalPrefix, + GrantScopeId: globals.GrantScopeChildren, Grant: "ids=*;type=auth-method;actions=list,read", }, }, @@ -158,7 +158,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=target;actions=list,read", }, grantThisScope: true, - grantScope: "children", + grantScope: globals.GrantScopeChildren, rTypes: []resource.Type{resource.Target}, tokenScopeId: org1.PublicId, reqScopeId: org1.PublicId, @@ -167,8 +167,8 @@ func TestGrantsForToken(t *testing.T) { expectedGrants: tempGrantTuples{ { AppTokenScopeId: org1.PublicId, - AppTokenParentScopeId: "global", - GrantScopeId: "children", + AppTokenParentScopeId: globals.GlobalPrefix, + GrantScopeId: globals.GrantScopeChildren, Grant: "ids=*;type=target;actions=list,read", }, }, @@ -192,7 +192,7 @@ func TestGrantsForToken(t *testing.T) { { AppTokenScopeId: proj1.PublicId, AppTokenParentScopeId: org1.PublicId, - GrantScopeId: "individual", + GrantScopeId: globals.GrantScopeIndividual, Grant: "ids=*;type=host-set;actions=read,ids=*;type=host;actions=list,ids=*;type=target;actions=list,read", }, }, @@ -214,7 +214,7 @@ func TestGrantsForToken(t *testing.T) { { AppTokenScopeId: proj1.PublicId, AppTokenParentScopeId: org1.PublicId, - GrantScopeId: "individual", + GrantScopeId: globals.GrantScopeIndividual, Grant: "ids=*;type=target;actions=list,read", }, }, @@ -227,7 +227,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.Group, resource.Scope}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -235,9 +235,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=group;actions=list,ids=*;type=scope;actions=list,read", }, }, @@ -249,7 +249,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=auth-method;actions=list,read", }, grantThisScope: false, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.AuthMethod}, tokenScopeId: globals.GlobalPrefix, reqScopeId: org1.PublicId, @@ -257,9 +257,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=auth-method;actions=list,read", }, }, @@ -271,7 +271,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=target;actions=list,read", }, grantThisScope: false, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.Target}, tokenScopeId: globals.GlobalPrefix, reqScopeId: proj1.PublicId, @@ -279,9 +279,9 @@ func TestGrantsForToken(t *testing.T) { wantErr: false, expectedGrants: tempGrantTuples{ { - AppTokenScopeId: "global", + AppTokenScopeId: globals.GlobalPrefix, AppTokenParentScopeId: "", - GrantScopeId: "descendants", + GrantScopeId: globals.GrantScopeDescendants, Grant: "ids=*;type=target;actions=list,read", }, }, @@ -294,7 +294,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=auth-method;actions=list", }, grantThisScope: true, - grantScope: "children", + grantScope: globals.GrantScopeChildren, rTypes: []resource.Type{resource.Account, resource.AuthMethod}, tokenScopeId: org1.PublicId, reqScopeId: org1.PublicId, @@ -303,8 +303,8 @@ func TestGrantsForToken(t *testing.T) { expectedGrants: tempGrantTuples{ { AppTokenScopeId: org1.PublicId, - AppTokenParentScopeId: "global", - GrantScopeId: "children", + AppTokenParentScopeId: globals.GlobalPrefix, + GrantScopeId: globals.GrantScopeChildren, Grant: "ids=*;type=account;actions=list,read,ids=*;type=auth-method;actions=list", }, }, @@ -317,7 +317,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=target;actions=list,read", }, grantThisScope: false, - grantScope: "children", + grantScope: globals.GrantScopeChildren, rTypes: []resource.Type{resource.Host, resource.Target}, tokenScopeId: org1.PublicId, reqScopeId: proj1.PublicId, @@ -326,8 +326,8 @@ func TestGrantsForToken(t *testing.T) { expectedGrants: tempGrantTuples{ { AppTokenScopeId: org1.PublicId, - AppTokenParentScopeId: "global", - GrantScopeId: "children", + AppTokenParentScopeId: globals.GlobalPrefix, + GrantScopeId: globals.GrantScopeChildren, Grant: "ids=*;type=host;actions=list,ids=*;type=target;actions=list,read", }, }, @@ -339,7 +339,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: nil, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -354,7 +354,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.Unknown}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -369,7 +369,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.All}, tokenScopeId: globals.GlobalPrefix, reqScopeId: globals.GlobalPrefix, @@ -384,7 +384,7 @@ func TestGrantsForToken(t *testing.T) { "ids=*;type=scope;actions=list,read", }, grantThisScope: true, - grantScope: "descendants", + grantScope: globals.GrantScopeDescendants, rTypes: []resource.Type{resource.All}, tokenScopeId: globals.GlobalPrefix, recursive: true, @@ -402,8 +402,23 @@ func TestGrantsForToken(t *testing.T) { opts = append(opts, WithRecursive(tc.recursive)) } + grantedScopes := []string{tc.grantScope} + if tc.grantThisScope { + grantedScopes = append(grantedScopes, globals.GrantScopeThis) + } + // Create a token with the specified grants - token := TestAppToken(t, repo, tc.tokenScopeId, tc.u, 0, nil, tc.grants, tc.grantThisScope, tc.grantScope) + token := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: tc.tokenScopeId, + CreatedByUserId: tc.u.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: tc.grants, + GrantedScopes: grantedScopes, + }, + }, + }) // Fetch the grants for the token gt, err := repo.GrantsForToken(ctx, token.PublicId, tc.rTypes, tc.reqScopeId, opts...) diff --git a/internal/apptoken/service_list_page.go b/internal/apptoken/service_list_page.go index 61071c8f50..33377bdba0 100644 --- a/internal/apptoken/service_list_page.go +++ b/internal/apptoken/service_list_page.go @@ -41,7 +41,7 @@ func ListPage( return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") case repo == nil: return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") - case withScopeIds == nil: + case len(withScopeIds) == 0: return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") case tok.ResourceType != resource.AppToken: return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have an app token resource type") diff --git a/internal/apptoken/service_list_refresh.go b/internal/apptoken/service_list_refresh.go index 7037d439ca..6e4e07648e 100644 --- a/internal/apptoken/service_list_refresh.go +++ b/internal/apptoken/service_list_refresh.go @@ -45,7 +45,7 @@ func ListRefresh( return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") case repo == nil: return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") - case withScopeIds == nil: + case len(withScopeIds) == 0: return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") case tok.ResourceType != resource.AppToken: return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have an app token resource type") diff --git a/internal/apptoken/service_list_refresh_page.go b/internal/apptoken/service_list_refresh_page.go new file mode 100644 index 0000000000..899fe3d22b --- /dev/null +++ b/internal/apptoken/service_list_refresh_page.go @@ -0,0 +1,83 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package apptoken + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListRefreshPage lists up to page size app tokens, filtering out entries that +// do not pass the filter item function. It will automatically request +// more app tokens from the database, at page size chunks, to fill the page. +// It will start its paging based on the information in the token. +// It returns a new list token used to continue pagination or refresh items. +// App tokens are ordered by update time descending (most recently updated first). +// App tokens may contain items that were already returned during the initial +// pagination phase. It also returns a list of any app tokens deleted since the +// last response. +func ListRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*AppToken], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*AppToken], error) { + const op = "apptoken.ListRefreshPage" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case len(withScopeIds) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + case tok.ResourceType != resource.AppToken: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have an app token resource type") + } + rt, ok := tok.Subtype.(*listtoken.RefreshToken) + if !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a refresh token component") + } + + listItemsFn := func(ctx context.Context, lastPageItem *AppToken, limit int) ([]*AppToken, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(lastItem)) + } + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the original list pagination phase. + return repo.listAppTokensRefresh(ctx, rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration), withScopeIds, opts...) + } + + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to account for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedCount, listDeletedIdsFn, tok) +} diff --git a/internal/apptoken/service_list_test.go b/internal/apptoken/service_list_test.go index c401da1d85..708ed173f8 100644 --- a/internal/apptoken/service_list_test.go +++ b/internal/apptoken/service_list_test.go @@ -5,6 +5,8 @@ package apptoken import ( "context" + "fmt" + "strings" "testing" "time" @@ -18,13 +20,36 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testListPageSize = 10 + testListSmallPageSize = 2 +) + +var ( + // filterNothingFilterFunc does not filter out any tokens + filterNothingFilterFunc = func(_ context.Context, appt *AppToken) (bool, error) { + return true, nil + } + + // filterOutInactiveFunc filters out inactive (revoked, expired, stale) tokens + filterOutInactiveFunc = func(_ context.Context, appt *AppToken) (bool, error) { + return appt.IsActive(), nil + } +) + func TestList(t *testing.T) { - ctx := t.Context() conn, _ := db.TestSetup(t, "postgres") wrap := db.TestWrapper(t) repo := TestRepo(t, conn, wrap) iamRepo := iam.TestRepo(t, conn, wrap) + // Set database read timeout to avoid duplicates in response + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + // Create test data org1, proj1 := iam.TestScopes(t, iamRepo, iam.WithName("org1"), iam.WithDescription("Test Org 1")) globalUser := iam.TestUser(t, iamRepo, globals.GlobalPrefix) @@ -34,11 +59,61 @@ func TestList(t *testing.T) { var globalAppTokens, org1AppTokens, proj1AppTokens, org2AppTokens, proj2AppTokens, allAppTokens []*AppToken for range 5 { - globalAppToken := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") - org1AppToken := TestAppToken(t, repo, org1.PublicId, org1User, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") - proj1AppToken := TestAppToken(t, repo, proj1.PublicId, org1User, 0, nil, []string{"ids=*;type=target;actions=list,read"}, true, "individual") - org2AppToken := TestAppToken(t, repo, org2.PublicId, org2User, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") - proj2AppToken := TestAppToken(t, repo, proj2.PublicId, org2User, 0, nil, []string{"ids=*;type=target;actions=list,read"}, true, "individual") + globalAppToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: globals.GlobalPrefix, + CreatedByUserId: globalUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + org1AppToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org1.PublicId, + CreatedByUserId: org1User.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + proj1AppToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj1.PublicId, + CreatedByUserId: org1User.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=target;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + org2AppToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: org2.PublicId, + CreatedByUserId: org2User.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + proj2AppToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: proj2.PublicId, + CreatedByUserId: org2User.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=target;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) globalAppTokens = append(globalAppTokens, globalAppToken) org1AppTokens = append(org1AppTokens, org1AppToken) @@ -48,22 +123,17 @@ func TestList(t *testing.T) { allAppTokens = append(allAppTokens, globalAppToken, org1AppToken, proj1AppToken, org2AppToken, proj2AppToken) } - // Filter functions - filterNothingFilterFunc := func(_ context.Context, appt *AppToken) (bool, error) { - return true, nil - } - filterOutInactiveFunc := func(_ context.Context, appt *AppToken) (bool, error) { - return appt.IsActive(), nil - } + // Validation Tests t.Run("List validation", func(t *testing.T) { t.Run("missing grants hash", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := List( ctx, []byte(""), - 10, + testListPageSize, filterNothingFilterFunc, repo, []string{globals.GlobalPrefix}, @@ -74,6 +144,7 @@ func TestList(t *testing.T) { t.Run("invalid page size", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := List( ctx, @@ -89,10 +160,12 @@ func TestList(t *testing.T) { t.Run("missing filter func", func(t *testing.T) { t.Parallel() + ctx := t.Context() + _, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, nil, repo, []string{globals.GlobalPrefix}, @@ -103,11 +176,12 @@ func TestList(t *testing.T) { t.Run("missing repo", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, nil, []string{globals.GlobalPrefix}, @@ -118,11 +192,12 @@ func TestList(t *testing.T) { t.Run("missing scope ids", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, repo, nil, @@ -130,16 +205,33 @@ func TestList(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "apptoken.List: missing scope ids: parameter violation") }) + + t.Run("empty scope ids", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := List( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + repo, + []string{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.List: missing scope ids: parameter violation") + }) }) t.Run("ListPage validation", func(t *testing.T) { t.Run("missing grants hash", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte(""), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, repo, @@ -151,6 +243,7 @@ func TestList(t *testing.T) { t.Run("invalid page size", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, @@ -167,11 +260,12 @@ func TestList(t *testing.T) { t.Run("missing filter func", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, nil, &listtoken.Token{}, repo, @@ -183,11 +277,12 @@ func TestList(t *testing.T) { t.Run("missing token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, nil, repo, @@ -199,11 +294,12 @@ func TestList(t *testing.T) { t.Run("missing repo", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, nil, @@ -215,11 +311,12 @@ func TestList(t *testing.T) { t.Run("missing scope ids", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, repo, @@ -229,13 +326,31 @@ func TestList(t *testing.T) { assert.Contains(t, err.Error(), "apptoken.ListPage: missing scope ids: parameter violation") }) + t.Run("empty scope ids", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + []string{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListPage: missing scope ids: parameter violation") + }) + t.Run("invalid resource type in token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{ResourceType: resource.AuthToken}, repo, @@ -247,11 +362,12 @@ func TestList(t *testing.T) { t.Run("invalid subtype in token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListPage( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{ResourceType: resource.AppToken, Subtype: nil}, repo, @@ -263,23 +379,23 @@ func TestList(t *testing.T) { t.Run("valid pagination", func(t *testing.T) { t.Parallel() + ctx := t.Context() assert, require := assert.New(t), require.New(t) // There are 25 total tokens created in the test setup above. - // We'll page through them 10 at a time, for a total of 3 pages (10, 10, 5). - pageSize := 10 + // We'll page through them 10 at a time, for a total of 3 pages. firstPageResp, err := List( ctx, []byte("test_grants_hash"), - pageSize, + testListPageSize, filterNothingFilterFunc, repo, []string{globals.GlobalPrefix, org1.PublicId, proj1.PublicId, org2.PublicId, proj2.PublicId}, ) require.NoError(err) require.NotNil(firstPageResp) - assert.Equal(pageSize, len(firstPageResp.Items)) + assert.Equal(testListPageSize, len(firstPageResp.Items)) assert.NotNil(firstPageResp.ListToken) pt, ok := firstPageResp.ListToken.Subtype.(*listtoken.PaginationToken) assert.True(ok) @@ -288,7 +404,7 @@ func TestList(t *testing.T) { secondPageResp, err := ListPage( ctx, []byte("test_grants_hash"), - pageSize, + testListPageSize, filterNothingFilterFunc, firstPageResp.ListToken, repo, @@ -296,7 +412,7 @@ func TestList(t *testing.T) { ) require.NoError(err) require.NotNil(secondPageResp) - assert.Equal(pageSize, len(secondPageResp.Items)) + assert.Equal(testListPageSize, len(secondPageResp.Items)) pt, ok = secondPageResp.ListToken.Subtype.(*listtoken.PaginationToken) assert.True(ok) assert.NotNil(pt) @@ -304,7 +420,7 @@ func TestList(t *testing.T) { thirdPageResp, err := ListPage( ctx, []byte("test_grants_hash"), - pageSize, + testListPageSize, filterNothingFilterFunc, secondPageResp.ListToken, repo, @@ -323,7 +439,7 @@ func TestList(t *testing.T) { _, err = ListPage( ctx, []byte("test_grants_hash"), - pageSize, + testListPageSize, filterNothingFilterFunc, thirdPageResp.ListToken, repo, @@ -337,11 +453,12 @@ func TestList(t *testing.T) { t.Run("ListRefresh validation", func(t *testing.T) { t.Run("missing grants hash", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte(""), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, repo, @@ -353,6 +470,7 @@ func TestList(t *testing.T) { t.Run("invalid page size", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, @@ -369,11 +487,12 @@ func TestList(t *testing.T) { t.Run("missing filter func", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, nil, &listtoken.Token{}, repo, @@ -385,11 +504,12 @@ func TestList(t *testing.T) { t.Run("missing token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, nil, repo, @@ -401,11 +521,12 @@ func TestList(t *testing.T) { t.Run("missing repo", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, nil, @@ -417,11 +538,12 @@ func TestList(t *testing.T) { t.Run("missing scope ids", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{}, repo, @@ -431,13 +553,31 @@ func TestList(t *testing.T) { assert.Contains(t, err.Error(), "apptoken.ListRefresh: missing scope ids: parameter violation") }) + t.Run("empty scope ids", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefresh( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + []string{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefresh: missing scope ids: parameter violation") + }) + t.Run("invalid resource type in token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{ResourceType: resource.AuthToken}, repo, @@ -449,11 +589,12 @@ func TestList(t *testing.T) { t.Run("invalid subtype in token", func(t *testing.T) { t.Parallel() + ctx := t.Context() _, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, &listtoken.Token{ResourceType: resource.AppToken, Subtype: nil}, repo, @@ -464,49 +605,199 @@ func TestList(t *testing.T) { }) }) + t.Run("ListRefreshPage validation", func(t *testing.T) { + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte(""), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing grants hash: parameter violation") + }) + + t.Run("invalid page size", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + 0, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: page size must be at least 1: parameter violation") + }) + + t.Run("missing filter func", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + nil, + &listtoken.Token{}, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing filter item callback: parameter violation") + }) + + t.Run("missing token", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + nil, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing token: parameter violation") + }) + + t.Run("missing repo", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + nil, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing repo: parameter violation") + }) + + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing scope ids: parameter violation") + }) + + t.Run("empty scope ids", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{}, + repo, + []string{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: missing scope ids: parameter violation") + }) + + t.Run("invalid resource type in token", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{ResourceType: resource.AuthToken}, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: token did not have an app token resource type: parameter violation") + }) + + t.Run("invalid subtype in token", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, err := ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + &listtoken.Token{ResourceType: resource.AppToken, Subtype: nil}, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "apptoken.ListRefreshPage: token did not have a refresh token component: parameter violation") + }) + }) + + // Functional Tests + t.Run("simple listing with pagination", func(t *testing.T) { testCases := []struct { - name string - withScopeIds []string - pageSize int - wantTokens []*AppToken - wantErr bool - wantErrMessage string + name string + withScopeIds []string + pageSize int + wantTokens []*AppToken }{ { name: "list global tokens", withScopeIds: []string{globals.GlobalPrefix}, - pageSize: 10, - wantTokens: globalAppTokens[0:5], - wantErr: false, + pageSize: testListPageSize, + wantTokens: globalAppTokens, }, { name: "list org1 tokens", withScopeIds: []string{org1.PublicId}, - pageSize: 10, - wantTokens: org1AppTokens[0:5], - wantErr: false, + pageSize: testListPageSize, + wantTokens: org1AppTokens, }, { name: "list proj1 tokens", withScopeIds: []string{proj1.PublicId}, - pageSize: 10, - wantTokens: proj1AppTokens[0:5], - wantErr: false, + pageSize: testListPageSize, + wantTokens: proj1AppTokens, }, { name: "list all org tokens", withScopeIds: []string{org1.PublicId, org2.PublicId}, - pageSize: 5, - wantTokens: append(org1AppTokens[0:5], org2AppTokens[0:5]...), - wantErr: false, + pageSize: testListPageSize, + wantTokens: append(org1AppTokens, org2AppTokens...), }, { name: "list all proj tokens", withScopeIds: []string{proj1.PublicId, proj2.PublicId}, pageSize: 5, - wantTokens: append(proj1AppTokens[0:5], proj2AppTokens[0:5]...), - wantErr: false, + wantTokens: append(proj1AppTokens, proj2AppTokens...), }, { name: "list all tokens", @@ -517,22 +808,21 @@ func TestList(t *testing.T) { org2.PublicId, proj2.PublicId, }, - pageSize: 10, + pageSize: testListPageSize, wantTokens: allAppTokens, - wantErr: false, }, { name: "list with no matching scopes", withScopeIds: []string{"nonexistent_scope"}, - pageSize: 10, + pageSize: testListPageSize, wantTokens: []*AppToken{}, - wantErr: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() + ctx := t.Context() assert, require := assert.New(t), require.New(t) resp, err := List( @@ -543,11 +833,6 @@ func TestList(t *testing.T) { repo, tc.withScopeIds, ) - if tc.wantErr { - require.Error(err) - assert.Contains(err.Error(), tc.wantErrMessage) - return - } require.NoError(err) require.NotNil(resp) @@ -573,11 +858,10 @@ func TestList(t *testing.T) { nextToken = resp.ListToken } - // Verify we got the expected number of tokens after optional paging + // Verify expected number of tokens retrieved after paging assert.Equal(len(tc.wantTokens), len(retrievedTokens)) - // Verify that all expected tokens are present - // The order is not guaranteed, so we check presence rather than position + // Verify that all expected tokens are present (order not guaranteed) for _, wantToken := range tc.wantTokens { found := false for _, gotToken := range retrievedTokens { @@ -595,6 +879,7 @@ func TestList(t *testing.T) { t.Run("listing with aggressive filtering", func(t *testing.T) { t.Run("filter out tokens", func(t *testing.T) { t.Parallel() + ctx := t.Context() assert, require := assert.New(t), require.New(t) filterOutOrg2Func := func(_ context.Context, appt *AppToken) (bool, error) { // Filter out tokens associated with org2 @@ -607,7 +892,7 @@ func TestList(t *testing.T) { resp, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterOutOrg2Func, repo, []string{org1.PublicId, org2.PublicId}, @@ -618,61 +903,115 @@ func TestList(t *testing.T) { }) t.Run("filter out inactive tokens", func(t *testing.T) { - // This test is intentionally not run in parallel because it modifies shared state + t.Parallel() + ctx := t.Context() assert, require := assert.New(t), require.New(t) - // Create a new global token - globalTokenToBeInactive := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") + // Create a new org to avoid interference from other tests + inactiveOrg, _ := iam.TestScopes(t, iamRepo, iam.WithName("inactive-org"), iam.WithDescription("Inactive Org")) + inactiveOrgUser := iam.TestUser(t, iamRepo, inactiveOrg.PublicId) + + // Create a new token in the new org + inactiveToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: inactiveOrg.PublicId, + CreatedByUserId: inactiveOrgUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) resp, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterOutInactiveFunc, repo, - []string{globals.GlobalPrefix}, + []string{globals.GlobalPrefix, inactiveOrg.PublicId}, ) require.NoError(err) require.NotNil(resp) - assert.Equal(6, len(resp.Items)) // globalAppTokens (5) + globalTokenToBeInactive (1) + assert.Equal(6, len(resp.Items)) // globalAppTokens (5) + inactiveToken (1) // Revoke - tempTestRevokeGlobalAppToken(t, repo, globalTokenToBeInactive.PublicId) + tempTestRevokeAppToken(t, repo, inactiveToken.PublicId, inactiveOrg.PublicId) // List again and only find the original 5 active tokens resp, err = List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterOutInactiveFunc, repo, - []string{globals.GlobalPrefix}, + []string{globals.GlobalPrefix, inactiveOrg.PublicId}, ) require.NoError(err) require.NotNil(resp) assert.Equal(5, len(resp.Items)) // globalAppTokens (5) - // Delete the revoked token to clean up - tempTestDeleteAppToken(t, repo, globalTokenToBeInactive.PublicId, globals.GlobalPrefix) + // Clean up - delete revoked token + tempTestDeleteAppToken(t, repo, inactiveToken.PublicId, inactiveOrg.PublicId) + }) + + t.Run("filter with error", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + errorFilterFunc := func(_ context.Context, _ *AppToken) (bool, error) { + return false, fmt.Errorf("intentional filter error") + } + + _, err := List( + ctx, + []byte("test_grants_hash"), + testListPageSize, + errorFilterFunc, + repo, + []string{globals.GlobalPrefix}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "intentional filter error") }) }) t.Run("listing with refresh", func(t *testing.T) { t.Parallel() + ctx := t.Context() assert, require := assert.New(t), require.New(t) - // Add a couple tokens to ensure there is data to refresh - token1ToBeRefreshed := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") - token2ToBeRefreshed := TestAppToken(t, repo, globals.GlobalPrefix, globalUser, 0, nil, []string{"ids=*;type=scope;actions=list,read"}, true, "individual") + // Create isolated scope and user to avoid interference from other tests + listRefreshOrg, _ := iam.TestScopes(t, iamRepo, iam.WithName("list-refresh-org"), iam.WithDescription("List Refresh Org")) + listRefreshUser := iam.TestUser(t, iamRepo, listRefreshOrg.PublicId) + + // Create ten initial tokens + var tokensToBeRefreshed []*AppToken + for range 10 { + token := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: listRefreshOrg.PublicId, + CreatedByUserId: listRefreshUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + tokensToBeRefreshed = append(tokensToBeRefreshed, token) + } + require.Equal(testListPageSize, len(tokensToBeRefreshed)) // Initial list to get a start refresh token firstResp, err := List( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, repo, - []string{globals.GlobalPrefix}, + []string{listRefreshOrg.PublicId}, ) require.NoError(err) require.NotNil(firstResp) @@ -681,33 +1020,223 @@ func TestList(t *testing.T) { require.True(ok) require.NotNil(startRefreshToken) - // Manipulate the token to move it forward in time. This will ensure that - // when we do the refresh, we are outside the lookback window and thus - // will not pick up the tokens that were just created above. - firstResp.ListToken.Subtype.(*listtoken.StartRefreshToken).PreviousPhaseUpperBound = time.Now().Add(globals.RefreshReadLookbackDuration + 1*time.Second) - time.Sleep(2 * time.Second) + time.Sleep(500 * time.Millisecond) + + // Refresh with no changes - expect no items returned + emptyRefreshResp, err := ListRefresh( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterNothingFilterFunc, + firstResp.ListToken, + repo, + []string{listRefreshOrg.PublicId}, + ) + require.NoError(err) + require.NotNil(emptyRefreshResp) + assert.Equal(0, len(emptyRefreshResp.Items)) + + time.Sleep(500 * time.Millisecond) - // Update tokens - testUpdateAppToken(t, repo, token1ToBeRefreshed.PublicId, globals.GlobalPrefix, map[string]any{"name": "updated-token1-name", "update_time": timestamp.New(timestamp.Now().AsTime())}) - testUpdateAppToken(t, repo, token2ToBeRefreshed.PublicId, globals.GlobalPrefix, map[string]any{"approximate_last_access_time": timestamp.New(timestamp.Now().Timestamp.AsTime()), "update_time": timestamp.New(timestamp.Now().AsTime())}) + // Update two tokens + testUpdateAppToken(t, repo, tokensToBeRefreshed[3].PublicId, listRefreshOrg.PublicId, map[string]any{"name": "updated-token1-name", "update_time": timestamp.New(timestamp.Now().AsTime())}) + testUpdateAppToken(t, repo, tokensToBeRefreshed[4].PublicId, listRefreshOrg.PublicId, map[string]any{"approximate_last_access_time": timestamp.New(timestamp.Now().Timestamp.AsTime()), "update_time": timestamp.New(timestamp.Now().AsTime())}) - // Now do a refresh list + // Now do a refresh list, expecting to find the two updated tokens refreshResp, err := ListRefresh( ctx, []byte("test_grants_hash"), - 10, + testListPageSize, filterNothingFilterFunc, - firstResp.ListToken, + emptyRefreshResp.ListToken, repo, - []string{globals.GlobalPrefix}, + []string{listRefreshOrg.PublicId}, ) require.NoError(err) require.NotNil(refreshResp) - // Expect to find the two updated tokens assert.Equal(2, len(refreshResp.Items)) + for _, token := range refreshResp.Items { + assert.Contains([]string{tokensToBeRefreshed[3].PublicId, tokensToBeRefreshed[4].PublicId}, token.PublicId) + } + + // Clean up - delete created tokens + for _, token := range tokensToBeRefreshed { + tempTestDeleteAppToken(t, repo, token.PublicId, listRefreshOrg.PublicId) + } + }) + + t.Run("listing with refresh pagination and filtering/deletions", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + assert, require := assert.New(t), require.New(t) + sqlDb, err := conn.SqlDB(ctx) + require.NoError(err) + + // Create isolated scope and user to avoid interference from other tests + refreshOrg, refreshProj := iam.TestScopes(t, iamRepo, iam.WithName("refresh-org"), iam.WithDescription("Refresh Org")) + refreshUser := iam.TestUser(t, iamRepo, refreshOrg.PublicId) + + // Create 12 new tokens to provide enough data to test revocation, update, deletion, and pagination + var tokensToBeRefreshed []*AppToken + for range 6 { + orgToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: refreshOrg.PublicId, + CreatedByUserId: refreshUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=scope;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + projToken := TestCreateAppToken(t, repo, &AppToken{ + ScopeId: refreshProj.PublicId, + CreatedByUserId: refreshUser.PublicId, + Permissions: []AppTokenPermission{ + { + Label: "test", + Grants: []string{"ids=*;type=target;actions=list,read"}, + GrantedScopes: []string{globals.GrantScopeThis}, + }, + }, + }) + tokensToBeRefreshed = append(tokensToBeRefreshed, orgToken, projToken) + } + assert.Equal(12, len(tokensToBeRefreshed)) + + // Initial list to get a start refresh token + firstResp, err := List( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterOutInactiveFunc, + repo, + []string{refreshOrg.PublicId, refreshProj.PublicId}, + ) + require.NoError(err) + require.NotNil(firstResp) - // Clean up - tempTestDeleteAppToken(t, repo, token1ToBeRefreshed.PublicId, globals.GlobalPrefix) - tempTestDeleteAppToken(t, repo, token2ToBeRefreshed.PublicId, globals.GlobalPrefix) + // Page through rest of the tokens to advance previous phase upper bound past creation time of new tokens + initialTokens := firstResp.Items + nextToken := firstResp.ListToken + for nextToken != nil { + if _, ok := nextToken.Subtype.(*listtoken.PaginationToken); !ok { + break + } + firstResp, err = ListPage( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterOutInactiveFunc, + nextToken, + repo, + []string{refreshOrg.PublicId, refreshProj.PublicId}, + ) + require.NoError(err) + require.NotNil(firstResp) + initialTokens = append(initialTokens, firstResp.Items...) + nextToken = firstResp.ListToken + } + assert.Equal(12, len(initialTokens)) + + // Wait to ensure time difference, then do a refresh list with no changes + time.Sleep(500 * time.Millisecond) + emptyRefreshResp, err := ListRefresh( + ctx, + []byte("test_grants_hash"), + testListPageSize, + filterOutInactiveFunc, + firstResp.ListToken, + repo, + []string{refreshOrg.PublicId, refreshProj.PublicId}, + ) + require.NoError(err) + require.NotNil(emptyRefreshResp) + assert.Equal(0, len(emptyRefreshResp.Items)) // No changes yet, so no tokens returned + + // Now update, revoke, and delete some of the tokens created above + // Ensure some variety in which tokens get which operation + // There should be a total of 12 tokens created above + // - 4 revoked, 4 updated, 4 deleted + updatedTokens := make(map[string]bool) + deletedTokens := make(map[string]bool) + for i, token := range tokensToBeRefreshed { + if i%3 == 0 { // i = 0,3,6,9 + // Revoke token + tempTestRevokeAppToken(t, repo, token.PublicId, token.ScopeId) + } else if i%2 == 0 { // i = 2,4,8,10 + // Update token name + testUpdateAppToken(t, repo, token.PublicId, token.ScopeId, map[string]any{"name": fmt.Sprintf("updated-%s", token.PublicId), "update_time": timestamp.New(timestamp.Now().AsTime())}) + updatedTokens[token.PublicId] = true + } else { // i = 1,5,7,11 + // TODO: Use actual delete when implemented + // Delete token + tempTestDeleteAppToken(t, repo, token.PublicId, token.ScopeId) + // Additionally, we directly insert into the app_token_deleted table + // These tokens should be excluded from the refresh results as they are considered deleted + _, err := sqlDb.ExecContext(ctx, "INSERT INTO app_token_deleted (public_id) VALUES ($1)", token.PublicId) + require.NoError(err) + deletedTokens[token.PublicId] = true + } + } + + // Wait to ensure time difference, then do a second refresh list with updates and deletions + time.Sleep(500 * time.Millisecond) + refreshResp, err := ListRefresh( + ctx, + []byte("test_grants_hash"), + testListSmallPageSize, + filterOutInactiveFunc, + emptyRefreshResp.ListToken, + repo, + []string{refreshOrg.PublicId, refreshProj.PublicId}, + ) + require.NoError(err) + require.NotNil(refreshResp) + + // Page through the rest of the refreshed tokens + retrievedRefreshTokens := refreshResp.Items + retrievedDeletedIds := refreshResp.DeletedIds + nextToken = refreshResp.ListToken + for nextToken != nil { + if _, ok := nextToken.Subtype.(*listtoken.RefreshToken); !ok { + break + } + refreshResp, err = ListRefreshPage( + ctx, + []byte("test_grants_hash"), + testListSmallPageSize, + filterOutInactiveFunc, + nextToken, + repo, + []string{refreshOrg.PublicId, refreshProj.PublicId}, + ) + require.NoError(err) + require.NotNil(refreshResp) + retrievedRefreshTokens = append(retrievedRefreshTokens, refreshResp.Items...) + retrievedDeletedIds = append(retrievedDeletedIds, refreshResp.DeletedIds...) + nextToken = refreshResp.ListToken + } + + // Expect to find only the 4 updated tokens + assert.Equal(4, len(retrievedRefreshTokens)) + for _, token := range retrievedRefreshTokens { + assert.True(strings.HasPrefix(token.Name, "updated-")) + assert.True(updatedTokens[token.PublicId]) + } + + // Expect to find the 4 deleted tokens in the deleted ids list + assert.Equal(4, len(retrievedDeletedIds)) + for _, id := range retrievedDeletedIds { + assert.True(deletedTokens[id]) + } + + // Clean up remaining tokens + for i, token := range tokensToBeRefreshed { + if i%3 == 0 || i%2 == 0 { // i = 0,2,3,4,6,8,9,10 + tempTestDeleteAppToken(t, repo, token.PublicId, token.ScopeId) + } + } }) } diff --git a/internal/apptoken/testing.go b/internal/apptoken/testing.go index 1b6e21bd31..60f9b5d989 100644 --- a/internal/apptoken/testing.go +++ b/internal/apptoken/testing.go @@ -10,13 +10,12 @@ import ( "sort" "strings" "testing" + "time" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/db/timestamp" - "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" - "github.com/hashicorp/boundary/internal/perms" "github.com/hashicorp/boundary/internal/types/scope" wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/stretchr/testify/assert" @@ -55,126 +54,25 @@ func TestRepo(t testing.TB, conn *db.DB, rootWrapper wrapping.Wrapper, opt ...Op return repo } -func testPublicId(t testing.TB, prefix string) string { - t.Helper() - publicId, err := db.NewPublicId(t.Context(), prefix) - require.NoError(t, err) - return publicId -} - -// TestAppToken creates an app token for testing with the specified grants. -// TODO: Implement TestAppToken once AppToken functionality is added -func TestAppToken(t *testing.T, repo *Repository, scopeId string, user *iam.User, timeToStaleSeconds uint32, expirationTime *timestamp.Timestamp, grants []string, grantThisScope bool, grantScope string) *AppToken { +func TestCreateAppToken(t *testing.T, repo *Repository, token *AppToken) *AppToken { t.Helper() - testToken := &AppToken{ - PublicId: testPublicId(t, "apt_"), - ScopeId: scopeId, - Description: "test app token", - CreatedByUserId: user.PublicId, - TimeToStaleSeconds: timeToStaleSeconds, - ExpirationTime: expirationTime, + // Assign a name and description if not set + if token.Name == "" { + token.Name = fmt.Sprintf("Test App Token %s", time.Now().Format("0405.000000")) } - tempTestAddGrants(t, repo, testToken, grants, grantThisScope, grantScope) - return testToken -} - -// tempTestAddGrants is a temporary test function to add grants to the database for testing -// TODO: Replace with proper AppToken creation function once AppToken functionality is added -func tempTestAddGrants(t *testing.T, repo *Repository, token *AppToken, grants []string, grantThisScope bool, grantScope string) { - t.Helper() - ctx := t.Context() - require := require.New(t) - - // Determine which table to insert into based on scope prefix - var insertTokenSQL, insertPermissionSQL string - insertGrantSQL := ` - insert into app_token_permission_grant (permission_id, raw_grant, canonical_grant) - values ($1, $2, $3) - ` - - switch { - case strings.HasPrefix(token.ScopeId, globals.GlobalPrefix): - insertTokenSQL = ` - insert into app_token_global (public_id, scope_id, name, description, created_by_user_id, time_to_stale_seconds, expiration_time) - values ($1, $2, $3, $4, $5, $6, $7) - ` - insertPermissionSQL = ` - insert into app_token_permission_global (private_id, app_token_id, description, grant_this_scope, grant_scope) - values ($1, $2, $3, $4, $5) - ` - - case strings.HasPrefix(token.ScopeId, globals.OrgPrefix): - insertTokenSQL = ` - insert into app_token_org (public_id, scope_id, name, description, created_by_user_id, time_to_stale_seconds, expiration_time) - values ($1, $2, $3, $4, $5, $6, $7) - ` - insertPermissionSQL = ` - insert into app_token_permission_org (private_id, app_token_id, description, grant_this_scope, grant_scope) - values ($1, $2, $3, $4, $5) - ` - case strings.HasPrefix(token.ScopeId, globals.ProjectPrefix): - insertTokenSQL = ` - insert into app_token_project (public_id, scope_id, name, description, created_by_user_id, time_to_stale_seconds, expiration_time) - values ($1, $2, $3, $4, $5, $6, $7) - ` - insertPermissionSQL = ` - insert into app_token_permission_project (private_id, app_token_id, description, grant_this_scope) - values ($1, $2, $3, $4) - ` - default: - t.Fatalf("invalid scope id: %s", token.ScopeId) + if token.Description == "" { + token.Description = "Test App Token Description" } - // Insert the app token - name := fmt.Sprintf("Test App Token %s", token.PublicId) - _, err := repo.writer.Exec(ctx, insertTokenSQL, []any{token.PublicId, token.ScopeId, name, "test app token", token.CreatedByUserId, token.TimeToStaleSeconds, token.ExpirationTime}) - require.NoError(err) - - // Create a permission for this token - permissionId := testPublicId(t, "aptp_") - - // Default to 'descendants' if not specified for global/org scopes - if grantScope == "" && (strings.HasPrefix(token.ScopeId, globals.GlobalPrefix) || strings.HasPrefix(token.ScopeId, globals.OrgPrefix)) { - grantScope = globals.GrantScopeDescendants + // If no expiration time is set, set it to 1 hour from now + if token.ExpirationTime == nil { + token.ExpirationTime = timestamp.New(time.Now().Add(1 * time.Hour)) } - var permArgs []any - if strings.HasPrefix(token.ScopeId, globals.ProjectPrefix) { - // Project permissions don't have grant_scope column - permArgs = []any{permissionId, token.PublicId, "test permission", grantThisScope} - } else { - permArgs = []any{permissionId, token.PublicId, "test permission", grantThisScope, grantScope} - } - - _, err = repo.writer.Exec(ctx, insertPermissionSQL, permArgs) - require.NoError(err) - - // Parse and insert each grant - for _, grant := range grants { - // Parse the grant to get canonical form - perm, err := perms.Parse(ctx, perms.GrantTuple{ - RoleScopeId: token.ScopeId, - GrantScopeId: token.ScopeId, - Grant: grant, - }, perms.WithSkipFinalValidation(true)) - require.NoError(err) - - canonicalGrant := perm.CanonicalString() - - // Insert into iam_grant lookup table (required for query JOINs) - // The database trigger will automatically extract and set the resource type - _, err = repo.writer.Exec(ctx, ` - insert into iam_grant (canonical_grant) - values ($1) - on conflict (canonical_grant) do nothing - `, []any{canonicalGrant}) - require.NoError(err) - - // Insert the grant with both raw_grant and canonical_grant - _, err = repo.writer.Exec(ctx, insertGrantSQL, []any{permissionId, grant, canonicalGrant}) - require.NoError(err) - } + createdToken, err := repo.CreateAppToken(t.Context(), token) + require.NoError(t, err) + return createdToken } // these will eventually expand to cover org and proj @@ -415,18 +313,31 @@ func testCheckAppTokenCipher(t *testing.T, repo *Repository, appTokenId string) return nil } -// tempTestRevokeGlobalAppToken is a temporary test function to revoke a global app token +// tempTestRevokeAppToken is a temporary test function to revoke a global app token // TODO: Replace with proper AppToken.Revoke function once added -func tempTestRevokeGlobalAppToken(t *testing.T, repo *Repository, tokenId string) { +func tempTestRevokeAppToken(t *testing.T, repo *Repository, tokenId, scopeId string) { t.Helper() ctx := t.Context() require := require.New(t) - _, err := repo.writer.Exec(ctx, ` - update app_token_global + execSQL := ` + update app_token_%s set revoked = true, update_time = now() where public_id = $1 - `, []any{tokenId}) + ` + var table string + switch { + case strings.HasPrefix(scopeId, globals.GlobalPrefix): + table = "global" + case strings.HasPrefix(scopeId, globals.OrgPrefix): + table = "org" + case strings.HasPrefix(scopeId, globals.ProjectPrefix): + table = "project" + default: + t.Fatalf("invalid scope id: %s", scopeId) + } + + _, err := repo.writer.Exec(ctx, fmt.Sprintf(execSQL, table), []any{tokenId}) require.NoError(err) }