diff --git a/api/scopes/project_test.go b/api/scopes/project_test.go new file mode 100644 index 0000000000..4c57443215 --- /dev/null +++ b/api/scopes/project_test.go @@ -0,0 +1,41 @@ +package scopes + +import ( + "testing" + + "github.com/hashicorp/watchtower/api" + "github.com/hashicorp/watchtower/internal/servers/controller" + "github.com/stretchr/testify/assert" +) + +func TestProjects_Crud(t *testing.T) { + tc := controller.NewTestController(t, nil) + defer tc.Shutdown() + + client := tc.Client() + org := &Organization{ + Client: client, + } + + name := "foo" + + checkProject := func(step string, p *Project, apiErr *api.Error, err error) { + assert := assert.New(t) + assert.NoError(err, step) + assert.Nil(apiErr, step) + assert.NotNil(p, "returned project", step) + assert.NotNil(p, "returned project name", step) + assert.Equal(name, *p.Name, step) + } + + p, apiErr, err := org.CreateProject(tc.Context(), &Project{Name: api.String(name)}) + checkProject("create", p, apiErr, err) + + p, apiErr, err = org.ReadProject(tc.Context(), &Project{Id: p.Id}) + checkProject("read", p, apiErr, err) + + // TODO: Update and Delete + + // TODO: Error conditions once the proper errors are being returned. + // Probably as parallel subtests against the same DB. +} diff --git a/go.mod b/go.mod index 1aabff863a..d0ccef116d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/golang/protobuf v1.4.1 github.com/grpc-ecosystem/grpc-gateway v1.14.5 github.com/hashicorp/errwrap v1.0.0 - github.com/hashicorp/go-alpnmux v0.0.0-20200323180452-dee08f00df54 + github.com/hashicorp/go-alpnmux v0.0.0-20200513011953-0293f5d23c31 github.com/hashicorp/go-cleanhttp v0.5.1 github.com/hashicorp/go-hclog v0.13.0 github.com/hashicorp/go-kms-wrapping v0.5.8 @@ -27,7 +27,7 @@ require ( github.com/hashicorp/go-sockaddr v1.0.2 github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/hcl v1.0.0 - github.com/hashicorp/vault v1.2.1-0.20200511170424-4928e34922e8 + github.com/hashicorp/vault v1.2.1-0.20200513002223-fc89625cd196 github.com/hashicorp/vault/sdk v0.1.14-0.20200511170424-4928e34922e8 github.com/jackc/pgx/v4 v4.6.0 github.com/jinzhu/gorm v1.9.12 diff --git a/go.sum b/go.sum index 705e677ba1..b38d01d9f4 100644 --- a/go.sum +++ b/go.sum @@ -570,6 +570,8 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-alpnmux v0.0.0-20200323180452-dee08f00df54 h1:WhMHPQosFuXFjt2wpRzX2eqR1WpnS+35MFzmG3trg3Y= github.com/hashicorp/go-alpnmux v0.0.0-20200323180452-dee08f00df54/go.mod h1:KvpteZzIafT4tRAuQ9vVRBgZyqeVCS2B2177fNAyEZc= +github.com/hashicorp/go-alpnmux v0.0.0-20200513011953-0293f5d23c31 h1:pxqI71/0R1WIASjQEJ9W9skCKYiREEkRoXFvHCZH1pg= +github.com/hashicorp/go-alpnmux v0.0.0-20200513011953-0293f5d23c31/go.mod h1:KvpteZzIafT4tRAuQ9vVRBgZyqeVCS2B2177fNAyEZc= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -656,16 +658,18 @@ github.com/hashicorp/raft-snapshot v1.0.2-0.20190827162939-8117efcc5aab/go.mod h github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.8.3 h1:MWYcmct5EtKz0efYooPcL0yNkem+7kWxqXDi/UIh+8k= github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= -github.com/hashicorp/vault v1.2.1-0.20200511170424-4928e34922e8 h1:++KBH9oO+9rr5qXvNuPV1tlwcUVmwxuXjxUi2tp5he4= -github.com/hashicorp/vault v1.2.1-0.20200511170424-4928e34922e8/go.mod h1:1/d/RuAcpBW/XXJ7RbRdezTGXc3ZLK/AAj6gPg4wIf8= +github.com/hashicorp/vault v1.2.1-0.20200513002223-fc89625cd196 h1:QplfqcymYufhcbkqY8aMrp5ukBveoZ3+rQK0CotAFhw= +github.com/hashicorp/vault v1.2.1-0.20200513002223-fc89625cd196/go.mod h1:1/d/RuAcpBW/XXJ7RbRdezTGXc3ZLK/AAj6gPg4wIf8= github.com/hashicorp/vault-plugin-auth-alicloud v0.5.5 h1:JYf3VYpKs7mOdtcwZWi73S82oXrC/JR7uoPVUd8c4Hk= github.com/hashicorp/vault-plugin-auth-alicloud v0.5.5/go.mod h1:sQ+VNwPQlemgXHXikYH6onfH9gPwDZ1GUVRLz0ZvHx8= +github.com/hashicorp/vault-plugin-auth-azure v0.5.5 h1:kN79ai+aMVU9hUmwscHjmweW2fGa8V/t+ScIchPZGrk= github.com/hashicorp/vault-plugin-auth-azure v0.5.5/go.mod h1:RCVBsf8AJndh4c6iGZtvVZFui9SG0Bj9fnF0SodNIkw= github.com/hashicorp/vault-plugin-auth-centrify v0.5.5 h1:YXxXt6o6I1rOkYW+hADK0vd+uVMj4C6Qs3jBrQlKQcY= github.com/hashicorp/vault-plugin-auth-centrify v0.5.5/go.mod h1:GfRoy7NHsuR/ogmZtbExdJXUwbfwcxPrS9xzkyy2J/c= github.com/hashicorp/vault-plugin-auth-cf v0.5.4 h1:2wl+qK7cLpr4u/lkv5DgvkNoKKhHC69H1QmoXOnArLw= github.com/hashicorp/vault-plugin-auth-cf v0.5.4/go.mod h1:idkFYHc6ske2BE7fe00SpH+SBIlqDKz8vk/IPLJuX2o= github.com/hashicorp/vault-plugin-auth-gcp v0.5.1/go.mod h1:eLj92eX8MPI4vY1jaazVLF2sVbSAJ3LRHLRhF/pUmlI= +github.com/hashicorp/vault-plugin-auth-gcp v0.6.1 h1:WXTuja3WC2BdZekYCnzuZGoVvZTAGH8kSDUHzOK2PQY= github.com/hashicorp/vault-plugin-auth-gcp v0.6.1/go.mod h1:8eBRzg+JIhAaDBfDndDAQKIhDrQ3WW8OPklxAYftNFs= github.com/hashicorp/vault-plugin-auth-jwt v0.6.2 h1:fp6Rk89iPjDS8dyEK7lEauYE/UhkgkHbmwRZKuQA01U= github.com/hashicorp/vault-plugin-auth-jwt v0.6.2/go.mod h1:SFadxIfoLGzugEjwUUmUaCGbsYEz2/jJymZDDQjEqYg= diff --git a/internal/cmd/base/listener.go b/internal/cmd/base/listener.go index a13f99c2c5..b972779a8b 100644 --- a/internal/cmd/base/listener.go +++ b/internal/cmd/base/listener.go @@ -3,7 +3,6 @@ package base import ( "errors" "fmt" - "io" "net" "net/http" "os" @@ -37,7 +36,7 @@ type WorkerAuthCertInfo struct { } // Factory is the factory function to create a listener. -type ListenerFactory func(*configutil.Listener, io.Writer, hclog.Logger, cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) +type ListenerFactory func(*configutil.Listener, hclog.Logger, cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) // BuiltinListeners is the list of built-in listener types. var BuiltinListeners = map[string]ListenerFactory{ @@ -46,16 +45,16 @@ var BuiltinListeners = map[string]ListenerFactory{ // New creates a new listener of the given type with the given // configuration. The type is looked up in the BuiltinListeners map. -func NewListener(l *configutil.Listener, w io.Writer, logger hclog.Logger, ui cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) { +func NewListener(l *configutil.Listener, logger hclog.Logger, ui cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) { f, ok := BuiltinListeners[l.Type] if !ok { return nil, nil, nil, fmt.Errorf("unknown listener type: %q", l.Type) } - return f(l, w, logger, ui) + return f(l, logger, ui) } -func tcpListenerFactory(l *configutil.Listener, _ io.Writer, logger hclog.Logger, ui cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) { +func tcpListenerFactory(l *configutil.Listener, logger hclog.Logger, ui cli.Ui) (*alpnmux.ALPNMux, map[string]string, reloadutil.ReloadFunc, error) { if l.Address == "" { if len(l.Purpose) == 1 && l.Purpose[0] == "cluster" { l.Address = "127.0.0.1:9201" @@ -72,6 +71,15 @@ func tcpListenerFactory(l *configutil.Listener, _ io.Writer, logger hclog.Logger bindProto = "tcp4" } + if l.RandomPort { + colon := strings.Index(l.Address, ":") + if colon != -1 { + // colon+1 because it needs to end in a colon to be automatically + // assigned by Go + l.Address = l.Address[0 : colon+1] + } + } + ln, err := net.Listen(bindProto, l.Address) if err != nil { return nil, nil, nil, err diff --git a/internal/cmd/base/servers.go b/internal/cmd/base/servers.go index 54ebf45144..22e43882e7 100644 --- a/internal/cmd/base/servers.go +++ b/internal/cmd/base/servers.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/go-hclog" wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/go-multierror" "github.com/hashicorp/vault/internalshared/configutil" "github.com/hashicorp/vault/internalshared/gatedwriter" "github.com/hashicorp/vault/internalshared/reloadutil" @@ -239,7 +240,7 @@ func (b *Server) SetupListeners(ui cli.Ui, config *configutil.SharedConfig) erro tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, } - lnMux, props, reloadFunc, err := NewListener(lnConfig, b.GatedWriter, b.Logger, ui) + lnMux, props, reloadFunc, err := NewListener(lnConfig, b.Logger, ui) if err != nil { return fmt.Errorf("Error initializing listener of type %s: %w", lnConfig.Type, err) } @@ -356,16 +357,18 @@ func (b *Server) SetupKMSes(ui cli.Ui, config *configutil.SharedConfig, purposes return nil } -func (b *Server) RunShutdownFuncs(ui cli.Ui) { +func (b *Server) RunShutdownFuncs() error { + var mErr *multierror.Error for _, f := range b.ShutdownFuncs { if err := f(); err != nil { - ui.Error(fmt.Sprintf("Error running a shutdown task: %s", err.Error())) + mErr = multierror.Append(mErr, err) } } + return mErr.ErrorOrNil() } func (b *Server) CreateDevDatabase(dialect string) error { - c, url, err := db.InitDbInDocker(dialect) + c, url, container, err := db.InitDbInDocker(dialect) if err != nil { c() return fmt.Errorf("unable to start dev database with dialect %s: %w", dialect, err) @@ -376,6 +379,10 @@ func (b *Server) CreateDevDatabase(dialect string) error { b.InfoKeys = append(b.InfoKeys, "dev database url") b.Info["dev database url"] = b.DevDatabaseUrl + if container != "" { + b.InfoKeys = append(b.InfoKeys, "dev database container") + b.Info["dev database container"] = strings.TrimPrefix(container, "/") + } dbase, err := gorm.Open(dialect, url) if err != nil { diff --git a/internal/cmd/commands/controller/controller.go b/internal/cmd/commands/controller/controller.go index d324cdc641..c6d980dee1 100644 --- a/internal/cmd/commands/controller/controller.go +++ b/internal/cmd/commands/controller/controller.go @@ -259,7 +259,11 @@ func (c *Command) Run(args []string) int { c.ShutdownFuncs = append(c.ShutdownFuncs, c.DestroyDevDatabase) } - defer c.RunShutdownFuncs(c.UI) + defer func() { + if err := c.RunShutdownFuncs(); err != nil { + c.UI.Error(fmt.Errorf("Error running shutdown tasks: %w", err).Error()) + } + }() c.PrintInfo(c.UI, "controller") c.ReleaseLogGate() diff --git a/internal/cmd/commands/dev/dev.go b/internal/cmd/commands/dev/dev.go index 9bc088df0b..54f4fe7a46 100644 --- a/internal/cmd/commands/dev/dev.go +++ b/internal/cmd/commands/dev/dev.go @@ -220,7 +220,11 @@ func (c *Command) Run(args []string) int { return 1 } - defer c.RunShutdownFuncs(c.UI) + defer func() { + if err := c.RunShutdownFuncs(); err != nil { + c.UI.Error(fmt.Errorf("Error running shutdown tasks: %w", err).Error()) + } + }() if err := c.CreateDevDatabase("postgres"); err != nil { c.UI.Error(fmt.Errorf("Error creating dev database container: %w", err).Error()) diff --git a/internal/cmd/commands/worker/worker.go b/internal/cmd/commands/worker/worker.go index 0cb9de0ac1..1a31555dc4 100644 --- a/internal/cmd/commands/worker/worker.go +++ b/internal/cmd/commands/worker/worker.go @@ -176,7 +176,11 @@ func (c *Command) Run(args []string) int { return 1 } - defer c.RunShutdownFuncs(c.UI) + defer func() { + if err := c.RunShutdownFuncs(); err != nil { + c.UI.Error(fmt.Errorf("Error running shutdown tasks: %w", err).Error()) + } + }() c.PrintInfo(c.UI, "worker") c.ReleaseLogGate() diff --git a/internal/db/db.go b/internal/db/db.go index 9739c222dc..f4e73ad1c3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -57,31 +57,31 @@ func Migrate(connectionUrl string, migrationsDirectory string) error { } // InitDbInDocker initializes the data store within docker or an existing PG_URL -func InitDbInDocker(dialect string) (cleanup func() error, retURL string, err error) { +func InitDbInDocker(dialect string) (cleanup func() error, retURL, container string, err error) { switch dialect { case "postgres": if os.Getenv("PG_URL") != "" { if err := InitStore(dialect, func() error { return nil }, os.Getenv("PG_URL")); err != nil { - return func() error { return nil }, os.Getenv("PG_URL"), fmt.Errorf("error initializing store: %w", err) + return func() error { return nil }, os.Getenv("PG_URL"), "", fmt.Errorf("error initializing store: %w", err) } - return func() error { return nil }, os.Getenv("PG_URL"), nil + return func() error { return nil }, os.Getenv("PG_URL"), "", nil } } - c, url, err := StartDbInDocker(dialect) + c, url, container, err := StartDbInDocker(dialect) if err != nil { - return func() error { return nil }, "", fmt.Errorf("could not start docker: %w", err) + return func() error { return nil }, "", "", fmt.Errorf("could not start docker: %w", err) } if err := InitStore(dialect, c, url); err != nil { - return func() error { return nil }, "", fmt.Errorf("error initializing store: %w", err) + return func() error { return nil }, "", "", fmt.Errorf("error initializing store: %w", err) } - return c, url, nil + return c, url, container, nil } // StartDbInDocker -func StartDbInDocker(dialect string) (cleanup func() error, retURL string, err error) { +func StartDbInDocker(dialect string) (cleanup func() error, retURL, container string, err error) { pool, err := dockertest.NewPool("") if err != nil { - return func() error { return nil }, "", fmt.Errorf("could not connect to docker: %w", err) + return func() error { return nil }, "", "", fmt.Errorf("could not connect to docker: %w", err) } var resource *dockertest.Resource @@ -94,7 +94,7 @@ func StartDbInDocker(dialect string) (cleanup func() error, retURL string, err e panic(fmt.Sprintf("unknown dialect %q", dialect)) } if err != nil { - return func() error { return nil }, "", fmt.Errorf("could not start resource: %w", err) + return func() error { return nil }, "", "", fmt.Errorf("could not start resource: %w", err) } cleanup = func() error { @@ -115,10 +115,10 @@ func StartDbInDocker(dialect string) (cleanup func() error, retURL string, err e defer db.Close() return nil }); err != nil { - return func() error { return nil }, "", fmt.Errorf("could not connect to docker: %w", err) + return func() error { return nil }, "", "", fmt.Errorf("could not connect to docker: %w", err) } - return cleanup, url, nil + return cleanup, url, resource.Container.Name, nil } // InitStore will execute the migrations needed to initialize the store for tests diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 6e8819964c..5831056206 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -5,7 +5,7 @@ import ( ) func TestOpen(t *testing.T) { - cleanup, url, err := StartDbInDocker("postgres") + cleanup, url, _, err := StartDbInDocker("postgres") if err != nil { t.Fatal(err) } @@ -56,7 +56,7 @@ func TestOpen(t *testing.T) { } func TestMigrate(t *testing.T) { - cleanup, url, err := StartDbInDocker("postgres") + cleanup, url, _, err := StartDbInDocker("postgres") if err != nil { t.Fatal(err) } diff --git a/internal/db/testing.go b/internal/db/testing.go index 7f74638a9f..eb7a211075 100644 --- a/internal/db/testing.go +++ b/internal/db/testing.go @@ -16,7 +16,7 @@ func TestSetup(t *testing.T, dialect string) (func() error, *gorm.DB, string) { cleanup := func() error { return nil } var url string var err error - cleanup, url, err = InitDbInDocker(dialect) + cleanup, url, _, err = InitDbInDocker(dialect) if err != nil { t.Fatal(err) } @@ -43,4 +43,3 @@ func TestWrapper(t *testing.T) wrapping.Wrapper { } return root } - diff --git a/internal/servers/controller/testing.go b/internal/servers/controller/testing.go new file mode 100644 index 0000000000..231fda56e6 --- /dev/null +++ b/internal/servers/controller/testing.go @@ -0,0 +1,190 @@ +package controller + +import ( + "context" + "fmt" + "net" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/watchtower/api" + "github.com/hashicorp/watchtower/internal/cmd/base" + "github.com/hashicorp/watchtower/internal/cmd/config" +) + +// TestController wraps a base.Server and Controller to provide a +// fully-programmatic controller for tests. Error checking (for instance, for +// valid config) is not stringent at the moment. +type TestController struct { + b *base.Server + c *Controller + t *testing.T + client *api.Client + ctx context.Context + cancel context.CancelFunc +} + +// Controller returns the underlying controller +func (tc *TestController) Controller() *Controller { + return tc.c +} + +func (tc *TestController) Client() *api.Client { + return tc.client +} + +func (tc *TestController) Context() context.Context { + return tc.ctx +} + +func (tc *TestController) Cancel() { + tc.cancel() +} + +func (tc *TestController) buildClient() { + var apiLn *base.ServerListener + for _, listener := range tc.b.Listeners { + if listener.Config.Purpose[0] == "api" { + apiLn = listener + break + } + } + if apiLn == nil { + tc.t.Fatal("could not find api listener") + } + + tcpAddr, ok := apiLn.Mux.Addr().(*net.TCPAddr) + if !ok { + tc.t.Fatal("could not parse address as a TCP addr") + } + addr := fmt.Sprintf("http://%s:%d", tcpAddr.IP.String(), tcpAddr.Port) + client, err := api.NewClient(nil) + if err != nil { + tc.t.Fatal(fmt.Errorf("error creating client: %w", err)) + } + if err := client.SetAddr(addr); err != nil { + tc.t.Fatal(fmt.Errorf("error setting client address: %w", err)) + } + + tc.client = client +} + +// Shutdown runs any cleanup functions; be sure to run this after your test is +// done +func (tc *TestController) Shutdown() { + if tc.b != nil { + close(tc.b.ShutdownCh) + } + + tc.cancel() + + if tc.c != nil { + if err := tc.c.Shutdown(); err != nil { + tc.t.Error(err) + } + } + if tc.b != nil { + if err := tc.b.RunShutdownFuncs(); err != nil { + tc.t.Error(err) + } + if tc.b.DestroyDevDatabase() != nil { + if err := tc.b.DestroyDevDatabase(); err != nil { + tc.t.Error(err) + } + } + } +} + +type TestControllerOpts struct { + // Config; if not provided a dev one will be created + Config *config.Config + + // DefaultOrgId is the default org ID to use, if set. Can also be provided + // in the normal config. + DefaultOrgId string + + // DisableDatabaseCreation can be set true to disable creating a dev + // database + DisableDatabaseCreation bool +} + +func NewTestController(t *testing.T, opts *TestControllerOpts) *TestController { + ctx, cancel := context.WithCancel(context.Background()) + + tc := &TestController{ + t: t, + ctx: ctx, + cancel: cancel, + } + + if opts == nil { + opts = new(TestControllerOpts) + } + + // Base server + tc.b = base.NewServer(nil) + tc.b.Command = &base.Command{ + ShutdownCh: make(chan struct{}), + } + + // Get dev config, or use a provided one + var err error + if opts.Config == nil { + opts.Config, err = config.DevController() + if err != nil { + t.Fatal(err) + } + } + + // Set default org ID, preferring one passed in from opts over config + if opts.Config.DefaultOrgId != "" { + tc.b.DefaultOrgId = opts.Config.DefaultOrgId + } + if opts.DefaultOrgId != "" { + tc.b.DefaultOrgId = opts.DefaultOrgId + } + + // Start a logger + tc.b.Logger = hclog.New(&hclog.LoggerOptions{ + Level: hclog.Trace, + }) + + // Set up KMSes + if err := tc.b.SetupKMSes(nil, opts.Config.SharedConfig, []string{"controller", "worker-auth"}); err != nil { + t.Fatal(err) + } + + // Ensure the listeners use random port allocation + for _, listener := range opts.Config.Listeners { + listener.RandomPort = true + } + if err := tc.b.SetupListeners(nil, opts.Config.SharedConfig); err != nil { + t.Fatal(err) + } + + if !opts.DisableDatabaseCreation { + if err := tc.b.CreateDevDatabase("postgres"); err != nil { + t.Fatal(err) + } + } + + conf := &Config{ + RawConfig: opts.Config, + Server: tc.b, + } + + tc.c, err = New(conf) + if err != nil { + tc.Shutdown() + t.Fatal(err) + } + + tc.buildClient() + + if err := tc.c.Start(); err != nil { + tc.Shutdown() + t.Fatal(err) + } + + return tc +} diff --git a/internal/servers/controller/testing_test.go b/internal/servers/controller/testing_test.go new file mode 100644 index 0000000000..a85c31903c --- /dev/null +++ b/internal/servers/controller/testing_test.go @@ -0,0 +1,13 @@ +package controller + +import ( + "testing" +) + +func Test_TestController(t *testing.T) { + t.Run("startup and shutdown", func(t *testing.T) { + t.Parallel() + tc := NewTestController(t, nil) + defer tc.Shutdown() + }) +}