From af5d1af5680d4ab9e0690d924d4108d67bf10fdc Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Fri, 30 Jul 2021 08:45:40 -0400 Subject: [PATCH] test: Add dbtest package This is modeled after the internal/docker package to make it easy to use as a replacement. Instead of creating a new docker container for each test, it will create a new database in an existing database container, using a template database as a source. This assumes that the template database exists and already has the schema migrations run against it. The default template is `boundary_template` which should already have all migrations applied. However, some tests require an empty database to test the migration code. In these cases the `template1` template database should be used, ie: dbtest.StartWithTemplate("postgres", dbtest.WithTemplate("template1")) --- testing/dbtest/docs.go | 25 ++++++ testing/dbtest/option.go | 30 +++++++ testing/dbtest/template.go | 150 ++++++++++++++++++++++++++++++++ testing/dbtest/template_test.go | 72 +++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 testing/dbtest/docs.go create mode 100644 testing/dbtest/option.go create mode 100644 testing/dbtest/template.go create mode 100644 testing/dbtest/template_test.go diff --git a/testing/dbtest/docs.go b/testing/dbtest/docs.go new file mode 100644 index 000000000..ada0d0321 --- /dev/null +++ b/testing/dbtest/docs.go @@ -0,0 +1,25 @@ +// Package dbtest provides a way to create a clean database for tests using +// template databases. For it to function properly a postgres database server +// must be running with the expected templates. This is typically done using +// the docker image provided in the docker directory of this package. +// +// To use this package in a test add something like the following in the +// beginning of the test: +// +// c, u, _, err := dbtest.StartUsingTemplate(dbtest.Postgres) +// require.NoError(t, err) +// t.Cleanup(func() { +// require.NoError(t, c()) +// }) +// // use u to get a connection to the new database +// dBase, err := sql.Open("postgres", u) +// +// By default this uses a template that already has all of the boundary +// migrations run. If a test needs a database without any migrations, like in +// the case where the migration code needs to be tested, a different template +// can be specified. This should generally be template1: +// +// c, u, _, err := dbtest.StartUsingTemplate(dbtest.Postgres, dbtest.WithTemplate(dbtest.Template1)) +// +// See https://www.postgresql.org/docs/13/manage-ag-templatedbs.html +package dbtest diff --git a/testing/dbtest/option.go b/testing/dbtest/option.go new file mode 100644 index 000000000..679228420 --- /dev/null +++ b/testing/dbtest/option.go @@ -0,0 +1,30 @@ +package dbtest + +// GetOpts - iterate the inbound Options and return a struct. +func GetOpts(opt ...Option) Options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*Options) + +// Options - how Options are represented. +type Options struct { + withTemplate string +} + +func getDefaultOptions() Options { + return Options{} +} + +// WithTemplate tells the command with database template to use +// when creating a new test database. +func WithTemplate(template string) Option { + return func(o *Options) { + o.withTemplate = template + } +} diff --git a/testing/dbtest/template.go b/testing/dbtest/template.go new file mode 100644 index 000000000..37f2b606f --- /dev/null +++ b/testing/dbtest/template.go @@ -0,0 +1,150 @@ +package dbtest + +import ( + "database/sql" + "fmt" + "math/rand" + "os" + "time" + + // postgres dialect + _ "github.com/lib/pq" +) + +var testDBPort = "5432" + +var ( + // StartUsingTemplate creates a new test database from a postgres template database. + StartUsingTemplate func(dialect string, opt ...Option) (func() error, string, string, error) = startUsingTemplate +) + +func init() { + rand.Seed(time.Now().UnixNano()) + + p := os.Getenv("TEST_DB_PORT") + if p != "" { + testDBPort = p + } +} + +// Dialects +const ( + Postgres = "postgres" +) + +// Template Database Names +const ( + BoundaryTemplate = "boundary_template" + Template1 = "template1" +) + +var supportedDialects = map[string]struct{}{ + Postgres: struct{}{}, +} + +var supportedTemplates = map[string]struct{}{ + BoundaryTemplate: struct{}{}, + Template1: struct{}{}, +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyz" +const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = rand.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(letterBytes) { + b[i] = letterBytes[idx] + i-- + } + cache >>= letterIdxBits + remain-- + } + + return string(b) +} + +func startUsingTemplate(dialect string, opt ...Option) (cleanup func() error, retURL, dbname string, err error) { + if _, ok := supportedDialects[dialect]; !ok { + return func() error { return nil }, "", "", fmt.Errorf("unsupported dialect: %s", dialect) + } + + db, err := sql.Open(dialect, fmt.Sprintf("postgres://boundary:boundary@127.0.0.1:%s/boundary?sslmode=disable", testDBPort)) + if err != nil { + return func() error { return nil }, "", "", fmt.Errorf("could not connect to source database: %w", err) + } + defer db.Close() + + dbname = fmt.Sprintf("boundary_test_%s", randStr(16)) + template := BoundaryTemplate + + opts := GetOpts(opt...) + if opts.withTemplate != "" { + template = opts.withTemplate + } + if _, ok := supportedTemplates[template]; !ok { + return func() error { return nil }, "", "", fmt.Errorf("unsupported database template: %s", template) + } + + _, err = db.Exec(fmt.Sprintf("create database %s template %s", dbname, template)) + if err != nil { + return func() error { return nil }, "", "", fmt.Errorf("could not create test database: %w", err) + } + + url := fmt.Sprintf("postgres://boundary:boundary@127.0.0.1:%s/%s?sslmode=disable", testDBPort, dbname) + + cleanup = func() error { + return dropDatabase(dialect, dbname) + } + + tdb, err := sql.Open(dialect, url) + if err != nil { + return func() error { return nil }, "", "", fmt.Errorf("could not create test database: %w", err) + } + defer tdb.Close() + + if err := tdb.Ping(); err != nil { + return func() error { return nil }, "", "", fmt.Errorf("could not ping test database: %w", err) + } + + return cleanup, url, dbname, nil +} + +const killconns = ` +select + pg_terminate_backend(pg_stat_activity.pid) +from + pg_stat_activity +where pg_stat_activity.datname = $1 + and pid <> pg_backend_pid(); +` + +func dropDatabase(dialect, dbname string) error { + if dbname == "" { + return fmt.Errorf("empty dbname") + } + + db, err := sql.Open(dialect, fmt.Sprintf("postgres://boundary:boundary@127.0.0.1:%s/boundary?sslmode=disable", testDBPort)) + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec(killconns, dbname) + if err != nil { + return err + } + _, err = db.Exec(fmt.Sprintf("drop database %s", dbname)) + if err != nil { + return err + } + return nil +} diff --git a/testing/dbtest/template_test.go b/testing/dbtest/template_test.go new file mode 100644 index 000000000..dcf305199 --- /dev/null +++ b/testing/dbtest/template_test.go @@ -0,0 +1,72 @@ +package dbtest_test + +import ( + "database/sql" + "errors" + "testing" + + "github.com/hashicorp/boundary/testing/dbtest" + "github.com/stretchr/testify/require" +) + +func TestStartUsingTemplate(t *testing.T) { + tests := []struct { + name string + dialect string + options []dbtest.Option + err error + }{ + { + "PostgresNoOptions", + dbtest.Postgres, + []dbtest.Option{}, + nil, + }, + { + "PostgresTemplate1", + dbtest.Postgres, + []dbtest.Option{ + dbtest.WithTemplate(dbtest.Template1), + }, + nil, + }, + { + "PostgresUnsupportedTemplate", + dbtest.Postgres, + []dbtest.Option{ + dbtest.WithTemplate("unsupportedTemplate"), + }, + errors.New("unsupported database template: unsupportedTemplate"), + }, + { + "UnsupportedDialect", + "unsupportedDialect", + []dbtest.Option{}, + errors.New("unsupported dialect: unsupportedDialect"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, u, _, err := dbtest.StartUsingTemplate(tt.dialect, tt.options...) + + if tt.err != nil { + require.EqualError(t, err, tt.err.Error()) + require.NoError(t, c()) + } else { + require.NoError(t, err) + + db, err := sql.Open(tt.dialect, u) + require.NoError(t, err) + require.NoError(t, db.Ping()) + db.Close() + + require.NoError(t, c()) + + // ensure that database is gone after calling c + db, _ = sql.Open(tt.dialect, u) + require.Error(t, db.Ping()) + } + }) + } +}