diff --git a/internal/auth/password/account.go b/internal/auth/password/account.go index ee51984ed..e40bf105c 100644 --- a/internal/auth/password/account.go +++ b/internal/auth/password/account.go @@ -19,6 +19,12 @@ type Account struct { CredentialID string `gorm:"-"` } +func allocAccount() Account { + return Account{ + Account: &store.Account{}, + } +} + // NewAccount creates a new in memory Account with userName assigned to // authMethodId. Name and description are the only valid options. All other // options are ignored. diff --git a/internal/auth/password/options.go b/internal/auth/password/options.go index 923fba18d..412bd8786 100644 --- a/internal/auth/password/options.go +++ b/internal/auth/password/options.go @@ -16,6 +16,7 @@ type Option func(*options) type options struct { withName string withDescription string + withLimit int } func getDefaultOptions() options { @@ -35,3 +36,12 @@ func WithName(name string) Option { o.withName = name } } + +// WithLimit provides an option to provide a limit. Intentionally allowing +// negative integers. If WithLimit < 0, then unlimited results are returned. +// If WithLimit == 0, then default limits are used for results. +func WithLimit(l int) Option { + return func(o *options) { + o.withLimit = l + } +} diff --git a/internal/auth/password/options_test.go b/internal/auth/password/options_test.go index 5e9b6fa7c..ce0f191fa 100644 --- a/internal/auth/password/options_test.go +++ b/internal/auth/password/options_test.go @@ -20,4 +20,10 @@ func Test_GetOpts(t *testing.T) { testOpts.withDescription = "test desc" assert.Equal(t, opts, testOpts) }) + t.Run("WithLimit", func(t *testing.T) { + opts := getOpts(WithLimit(5)) + testOpts := getDefaultOptions() + testOpts.withLimit = 5 + assert.Equal(t, opts, testOpts) + }) } diff --git a/internal/auth/password/repository.go b/internal/auth/password/repository.go index 95dc4aca0..a3e978930 100644 --- a/internal/auth/password/repository.go +++ b/internal/auth/password/repository.go @@ -13,12 +13,15 @@ type Repository struct { reader db.Reader writer db.Writer wrapper wrapping.Wrapper + // defaultLimit provides a default for limiting the number of results returned from the repo + defaultLimit int } // NewRepository creates a new Repository. The returned repository should // only be used for one transaction and it is not safe for concurrent go -// routines to access it. -func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper) (*Repository, error) { +// routines to access it. WithLimit option is used as a repo wide default +// limit applied to all ListX methods. +func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper, opt ...Option) (*Repository, error) { switch { case r == nil: return nil, fmt.Errorf("db.Reader: %w", db.ErrNilParameter) @@ -28,9 +31,16 @@ func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper) (*Reposit return nil, fmt.Errorf("wrapping.Wrapper: %w", db.ErrNilParameter) } + opts := getOpts(opt...) + if opts.withLimit == 0 { + // zero signals the watchtower defaults should be used. + opts.withLimit = db.DefaultLimit + } + return &Repository{ - reader: r, - writer: w, - wrapper: wrapper, + reader: r, + writer: w, + wrapper: wrapper, + defaultLimit: opts.withLimit, }, nil } diff --git a/internal/auth/password/repository_account.go b/internal/auth/password/repository_account.go index 03c546731..396e024a0 100644 --- a/internal/auth/password/repository_account.go +++ b/internal/auth/password/repository_account.go @@ -2,6 +2,7 @@ package password import ( "context" + "errors" "fmt" "regexp" @@ -62,6 +63,42 @@ func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Optio return newAccount, nil } +// LookupAccount will look up an account in the repository. If the account is not +// found, it will return nil, nil. All options are ignored. +func (r *Repository) LookupAccount(ctx context.Context, withPublicId string, opt ...Option) (*Account, error) { + if withPublicId == "" { + return nil, fmt.Errorf("lookup: password account: missing public id %w", db.ErrInvalidParameter) + } + a := allocAccount() + a.PublicId = withPublicId + if err := r.reader.LookupByPublicId(ctx, &a); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("lookup: password account: failed %w for %s", err, withPublicId) + } + return &a, nil +} + +// ListAccounts in an auth method and supports WithLimit option. +func (r *Repository) ListAccounts(ctx context.Context, withAuthMethodId string, opt ...Option) ([]*Account, error) { + if withAuthMethodId == "" { + return nil, fmt.Errorf("list: password account: missing auth method id %w", db.ErrInvalidParameter) + } + opts := getOpts(opt...) + limit := r.defaultLimit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + var accts []*Account + err := r.reader.SearchWhere(ctx, &accts, "auth_method_id = ?", []interface{}{withAuthMethodId}, db.WithLimit(limit)) + if err != nil { + return nil, fmt.Errorf("list: password account: %w", err) + } + return accts, nil +} + var reInvalidUserName = regexp.MustCompile("[^a-z0-9.]") func validUserName(u string) bool { diff --git a/internal/auth/password/repository_account_test.go b/internal/auth/password/repository_account_test.go index 57e5194fb..e0495ed53 100644 --- a/internal/auth/password/repository_account_test.go +++ b/internal/auth/password/repository_account_test.go @@ -3,10 +3,12 @@ package password import ( "context" "errors" + "fmt" "testing" "github.com/hashicorp/watchtower/internal/auth/password/store" "github.com/hashicorp/watchtower/internal/db" + "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,6 +33,35 @@ func TestCheckUserName(t *testing.T) { } } +func testAccounts(t *testing.T, conn *gorm.DB, scopeId, authMethodId string, count int) []*Account { + t.Helper() + assert, require := assert.New(t), require.New(t) + w := db.New(conn) + var auts []*Account + for i := 0; i < count; i++ { + cat, err := NewAccount(authMethodId, fmt.Sprintf("name%d", i)) + assert.NoError(err) + require.NotNil(cat) + id, err := newAuthMethodId() + assert.NoError(err) + require.NotEmpty(id) + cat.PublicId = id + + ctx := context.Background() + _, err2 := w.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, iw db.Writer) error { + return iw.Create(ctx, cat) + }, + ) + + require.NoError(err2) + // TODO(toddknight): Figure out why the iw.Create call doesn't populate the scope id from the DB. + cat.ScopeId = scopeId + auts = append(auts, cat) + } + return auts +} + func TestRepository_CreateAccount(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) @@ -255,3 +286,173 @@ func TestRepository_CreateAccount(t *testing.T) { assert.Equal(got2.CreateTime, got2.UpdateTime) }) } + +func TestRepository_LookupAccount(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + authMethod := testAuthMethods(t, conn, 1)[0] + account := testAccounts(t, conn, authMethod.GetScopeId(), authMethod.GetPublicId(), 1)[0] + + newAcctId, err := newAccountId() + require.NoError(t, err) + var tests = []struct { + name string + in string + want *Account + wantIsErr error + }{ + { + name: "With no public id", + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "With non existing account id", + in: newAcctId, + }, + { + name: "With existing account id", + in: account.GetPublicId(), + want: account, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.LookupAccount(context.Background(), tt.in) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + assert.EqualValues(tt.want, got) + }) + } +} + +func TestRepository_ListAccounts(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + authMethods := testAuthMethods(t, conn, 3) + accounts1 := testAccounts(t, conn, authMethods[0].GetScopeId(), authMethods[0].GetPublicId(), 3) + accounts2 := testAccounts(t, conn, authMethods[1].GetScopeId(), authMethods[1].GetPublicId(), 4) + _ = accounts2 + + var tests = []struct { + name string + in string + opts []Option + want []*Account + wantIsErr error + }{ + { + name: "With no auth method id", + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "With no accounts id", + in: authMethods[2].GetPublicId(), + want: []*Account{}, + }, + { + name: "With first auth method id", + in: authMethods[0].GetPublicId(), + want: accounts1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.ListAccounts(context.Background(), tt.in, tt.opts...) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + assert.EqualValues(tt.want, got) + }) + } +} + +func TestRepository_ListAccounts_Limits(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + am := testAuthMethods(t, conn, 1)[0] + + accountCount := 10 + _ = testAccounts(t, conn, am.GetScopeId(), am.GetPublicId(), accountCount) + + var tests = []struct { + name string + repoOpts []Option + listOpts []Option + wantLen int + }{ + { + name: "With no limits", + wantLen: accountCount, + }, + { + name: "With repo limit", + repoOpts: []Option{WithLimit(3)}, + wantLen: 3, + }, + { + name: "With negative repo limit", + repoOpts: []Option{WithLimit(-1)}, + wantLen: accountCount, + }, + { + name: "With List limit", + listOpts: []Option{WithLimit(3)}, + wantLen: 3, + }, + { + name: "With negative List limit", + listOpts: []Option{WithLimit(-1)}, + wantLen: accountCount, + }, + { + name: "With repo smaller than list limit", + repoOpts: []Option{WithLimit(2)}, + listOpts: []Option{WithLimit(6)}, + wantLen: 6, + }, + { + name: "With repo larger than list limit", + repoOpts: []Option{WithLimit(6)}, + listOpts: []Option{WithLimit(2)}, + wantLen: 2, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper, tt.repoOpts...) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.ListAccounts(context.Background(), am.GetPublicId(), tt.listOpts...) + require.NoError(err) + assert.Len(got, tt.wantLen) + }) + } +} diff --git a/internal/auth/password/repository_test.go b/internal/auth/password/repository_test.go index 103f63efb..4aa4e2e98 100644 --- a/internal/auth/password/repository_test.go +++ b/internal/auth/password/repository_test.go @@ -20,6 +20,7 @@ func TestRepository_New(t *testing.T) { r db.Reader w db.Writer wrapper wrapping.Wrapper + opts []Option } var tests = []struct { @@ -36,9 +37,25 @@ func TestRepository_New(t *testing.T) { wrapper: wrapper, }, want: &Repository{ - reader: rw, - writer: rw, + reader: rw, + writer: rw, + wrapper: wrapper, + defaultLimit: db.DefaultLimit, + }, + }, + { + name: "valid with limit", + args: args{ + r: rw, + w: rw, wrapper: wrapper, + opts: []Option{WithLimit(5)}, + }, + want: &Repository{ + reader: rw, + writer: rw, + wrapper: wrapper, + defaultLimit: 5, }, }, { @@ -86,7 +103,7 @@ func TestRepository_New(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) - got, err := NewRepository(tt.args.r, tt.args.w, tt.args.wrapper) + got, err := NewRepository(tt.args.r, tt.args.w, tt.args.wrapper, tt.args.opts...) if tt.wantIsErr != nil { assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) assert.Nil(got)