// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package command import ( "encoding/json" "fmt" "strings" "testing" "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" "github.com/mitchellh/cli" "github.com/stretchr/testify/require" ) func TestVarListCommand_Implements(t *testing.T) { ci.Parallel(t) var _ cli.Command = &VarListCommand{} } // TestVarListCommand_Offline contains all of the tests that do not require a // testServer to complete func TestVarListCommand_Offline(t *testing.T) { ci.Parallel(t) ui := cli.NewMockUi() cmd := &VarListCommand{Meta: Meta{Ui: ui}} testCases := []testVarListTestCase{ { name: "help", args: []string{"-help"}, exitCode: 1, expectUsage: true, }, { name: "bad args", args: []string{"some", "bad", "args"}, exitCode: 1, expectUsageError: true, expectStdErrPrefix: "This command takes flags and either no arguments or one: ", }, { name: "bad address", args: []string{"-address", "nope"}, exitCode: 1, expectStdErrPrefix: "Error retrieving vars", }, { name: "unparsable address", args: []string{"-address", "http://10.0.0.1:bad"}, exitCode: 1, expectStdErrPrefix: "Error initializing client: invalid address", }, { name: "missing template", args: []string{`-out=go-template`, "foo"}, exitCode: 1, expectStdErrPrefix: errMissingTemplate, }, { name: "unexpected_template", args: []string{`-out=json`, `-template="bad"`, "foo"}, exitCode: 1, expectStdErrPrefix: errUnexpectedTemplate, }, { name: "bad out", args: []string{`-out=bad`, "foo"}, exitCode: 1, expectStdErrPrefix: errInvalidListOutFormat, }, } for _, tC := range testCases { t.Run(tC.name, func(t *testing.T) { tC := tC ec := cmd.Run(tC.args) stdOut := ui.OutputWriter.String() errOut := ui.ErrorWriter.String() defer resetUiWriters(ui) require.Equal(t, tC.exitCode, ec, "Expected exit code %v; got: %v\nstdout: %s\nstderr: %s", tC.exitCode, ec, stdOut, errOut, ) if tC.expectUsage { help := cmd.Help() require.Equal(t, help, strings.TrimSpace(stdOut)) // Test that stdout ends with a linefeed since we trim them for // convenience in the equality tests. require.True(t, strings.HasSuffix(stdOut, "\n"), "stdout does not end with a linefeed") } if tC.expectUsageError { require.Contains(t, errOut, commandErrorText(cmd)) } if tC.expectStdOut != "" { require.Equal(t, tC.expectStdOut, strings.TrimSpace(stdOut)) // Test that stdout ends with a linefeed since we trim them for // convenience in the equality tests. require.True(t, strings.HasSuffix(stdOut, "\n"), "stdout does not end with a linefeed") } if tC.expectStdErrPrefix != "" { require.True(t, strings.HasPrefix(errOut, tC.expectStdErrPrefix), "Expected stderr to start with %q; got %s", tC.expectStdErrPrefix, errOut) // Test that stderr ends with a linefeed since we trim them for // convenience in the equality tests. require.True(t, strings.HasSuffix(errOut, "\n"), "stderr does not end with a linefeed") } }) } } // TestVarListCommand_Online contains all of the tests that use a testServer. // They reuse the same testServer so that they can run in parallel and minimize // test startup time costs. func TestVarListCommand_Online(t *testing.T) { ci.Parallel(t) // Create a server srv, client, url := testServer(t, true, nil) defer srv.Shutdown() ui := cli.NewMockUi() cmd := &VarListCommand{Meta: Meta{Ui: ui}} nsList := []string{api.DefaultNamespace, "ns1"} pathList := []string{"a/b/c", "a/b/c/d", "z/y", "z/y/x"} variables := setupTestVariables(client, nsList, pathList) testTmpl := `{{ range $i, $e := . }}{{if ne $i 0}}{{print "•"}}{{end}}{{printf "%v\t%v" .Namespace .Path}}{{end}}` pathsEqual := func(t *testing.T, expect any) testVarListJSONTestExpectFn { out := func(t *testing.T, check any) { expect := expect exp, ok := expect.(NSPather) require.True(t, ok, "expect is not an NSPather, got %T", expect) in, ok := check.(NSPather) require.True(t, ok, "check is not an NSPather, got %T", check) require.ElementsMatch(t, exp.NSPaths(), in.NSPaths()) } return out } hasLength := func(t *testing.T, length int) testVarListJSONTestExpectFn { out := func(t *testing.T, check any) { length := length in, ok := check.(NSPather) require.True(t, ok, "check is not an NSPather, got %T", check) inLen := in.NSPaths().Len() require.Equal(t, length, inLen, "expected length of %v, got %v. \nvalues: %v", length, inLen, in.NSPaths()) } return out } testCases := []testVarListTestCase{ { name: "plaintext/not found", args: []string{"-out=table", "does/not/exist"}, expectStdOut: errNoMatchingVariables, }, { name: "plaintext/single variable", args: []string{"-out=table", "a/b/c/d"}, expectStdOut: formatList([]string{ "Namespace|Path|Last Updated", fmt.Sprintf( "default|a/b/c/d|%s", formatUnixNanoTime(variables.HavingPrefix("a/b/c/d")[0].ModifyTime), ), }, ), }, { name: "plaintext/terse", args: []string{"-out=terse"}, expectStdOut: strings.Join(variables.HavingNamespace(api.DefaultNamespace).Strings(), "\n"), }, { name: "plaintext/terse/prefix", args: []string{"-out=terse", "a/b/c"}, expectStdOut: strings.Join(variables.HavingNSPrefix(api.DefaultNamespace, "a/b/c").Strings(), "\n"), }, { name: "plaintext/terse/filter", args: []string{"-out=terse", "-filter", "VariableMetadata.Path == \"a/b/c\""}, expectStdOut: "a/b/c", expectStdErrPrefix: msgWarnFilterPerformance, }, { name: "plaintext/terse/paginated", args: []string{"-out=terse", "-per-page=1"}, expectStdOut: "a/b/c", expectStdErrPrefix: "Next page token", }, { name: "plaintext/terse/prefix/wildcard ns", args: []string{"-out=terse", "-namespace", "*", "a/b/c/d"}, expectStdOut: strings.Join(variables.HavingPrefix("a/b/c/d").Strings(), "\n"), }, { name: "plaintext/terse/paginated/prefix/wildcard ns", args: []string{"-out=terse", "-per-page=1", "-namespace", "*", "a/b/c/d"}, expectStdOut: variables.HavingPrefix("a/b/c/d").Strings()[0], expectStdErrPrefix: "Next page token", }, { name: "json/not found", args: []string{"-out=json", "does/not/exist"}, jsonTest: &testVarListJSONTest{ jsonDest: &SVMSlice{}, expectFns: []testVarListJSONTestExpectFn{ hasLength(t, 0), }, }, }, { name: "json/prefix", args: []string{"-out=json", "a"}, jsonTest: &testVarListJSONTest{ jsonDest: &SVMSlice{}, expectFns: []testVarListJSONTestExpectFn{ pathsEqual(t, variables.HavingNSPrefix(api.DefaultNamespace, "a")), }, }, }, { name: "json/paginated", args: []string{"-out=json", "-per-page", "1"}, jsonTest: &testVarListJSONTest{ jsonDest: &PaginatedSVMSlice{}, expectFns: []testVarListJSONTestExpectFn{ hasLength(t, 1), }, }, }, { name: "template/not found", args: []string{"-out=go-template", "-template", testTmpl, "does/not/exist"}, expectStdOut: "", }, { name: "template/prefix", args: []string{"-out=go-template", "-template", testTmpl, "a/b/c/d"}, expectStdOut: "default\ta/b/c/d", }, { name: "template/filter", args: []string{"-out=go-template", "-template", testTmpl, "-filter", "VariableMetadata.Path == \"a/b/c\""}, expectStdOut: "default\ta/b/c", expectStdErrPrefix: msgWarnFilterPerformance, }, { name: "template/paginated", args: []string{"-out=go-template", "-template", testTmpl, "-per-page=1"}, expectStdOut: "default\ta/b/c", expectStdErrPrefix: "Next page token", }, { name: "template/prefix/wildcard namespace", args: []string{"-namespace", "*", "-out=go-template", "-template", testTmpl, "a/b/c/d"}, expectStdOut: "default\ta/b/c/d•ns1\ta/b/c/d", }, } for _, tC := range testCases { t.Run(tC.name, func(t *testing.T) { tC := tC // address always needs to be provided and since the test cases // might pass a positional parameter, we need to jam it in the // front. tcArgs := append([]string{"-address=" + url}, tC.args...) code := cmd.Run(tcArgs) stdOut := ui.OutputWriter.String() errOut := ui.ErrorWriter.String() defer resetUiWriters(ui) require.Equal(t, tC.exitCode, code, "Expected exit code %v; got: %v\nstdout: %s\nstderr: %s", tC.exitCode, code, stdOut, errOut) if tC.expectStdOut != "" { require.Equal(t, tC.expectStdOut, strings.TrimSpace(stdOut)) // Test that stdout ends with a linefeed since we trim them for // convenience in the equality tests. require.True(t, strings.HasSuffix(stdOut, "\n"), "stdout does not end with a linefeed") } if tC.expectStdErrPrefix != "" { require.True(t, strings.HasPrefix(errOut, tC.expectStdErrPrefix), "Expected stderr to start with %q; got %s", tC.expectStdErrPrefix, errOut) // Test that stderr ends with a linefeed since this test only // considers prefixes. require.True(t, strings.HasSuffix(stdOut, "\n"), "stderr does not end with a linefeed") } if tC.jsonTest != nil { jtC := tC.jsonTest err := json.Unmarshal([]byte(stdOut), &jtC.jsonDest) require.NoError(t, err, "stdout: %s", stdOut) for _, fn := range jtC.expectFns { fn(t, jtC.jsonDest) } } }) } } func resetUiWriters(ui *cli.MockUi) { ui.ErrorWriter.Reset() ui.OutputWriter.Reset() } type testVarListTestCase struct { name string args []string exitCode int expectUsage bool expectUsageError bool expectStdOut string expectStdErrPrefix string jsonTest *testVarListJSONTest } type testVarListJSONTest struct { jsonDest interface{} expectFns []testVarListJSONTestExpectFn } type testVarListJSONTestExpectFn func(*testing.T, interface{}) type testSVNamespacePath struct { Namespace string Path string } func setupTestVariables(c *api.Client, nsList, pathList []string) SVMSlice { out := make(SVMSlice, 0, len(nsList)*len(pathList)) for _, ns := range nsList { c.Namespaces().Register(&api.Namespace{Name: ns}, nil) for _, p := range pathList { setupTestVariable(c, ns, p, &out) } } return out } func setupTestVariable(c *api.Client, ns, p string, out *SVMSlice) error { testVar := &api.Variable{ Namespace: ns, Path: p, Items: map[string]string{"k": "v"}} v, _, err := c.Variables().Create(testVar, &api.WriteOptions{Namespace: ns}) *out = append(*out, *v.Metadata()) return err } type NSPather interface { Len() int NSPaths() testSVNamespacePaths } type testSVNamespacePaths []testSVNamespacePath func (ps testSVNamespacePaths) Len() int { return len(ps) } func (ps testSVNamespacePaths) NSPaths() testSVNamespacePaths { return ps } type SVMSlice []api.VariableMetadata func (s SVMSlice) Len() int { return len(s) } func (s SVMSlice) NSPaths() testSVNamespacePaths { out := make(testSVNamespacePaths, len(s)) for i, v := range s { out[i] = testSVNamespacePath{v.Namespace, v.Path} } return out } func (ps SVMSlice) Strings() []string { ns := make(map[string]struct{}) outNS := make([]string, len(ps)) out := make([]string, len(ps)) for i, p := range ps { out[i] = p.Path outNS[i] = p.Namespace + "|" + p.Path ns[p.Namespace] = struct{}{} } if len(ns) > 1 { return strings.Split(formatList(outNS), "\n") } return out } func (ps *SVMSlice) HavingNamespace(ns string) SVMSlice { return *ps.having("namespace", ns) } func (ps *SVMSlice) HavingPrefix(prefix string) SVMSlice { return *ps.having("prefix", prefix) } func (ps *SVMSlice) HavingNSPrefix(ns, p string) SVMSlice { return *ps.having("namespace", ns).having("prefix", p) } func (ps SVMSlice) having(field, val string) *SVMSlice { out := make(SVMSlice, 0, len(ps)) for _, p := range ps { if field == "namespace" && p.Namespace == val { out = append(out, p) } if field == "prefix" && strings.HasPrefix(p.Path, val) { out = append(out, p) } } return &out } type PaginatedSVMSlice struct { Data SVMSlice QueryMeta api.QueryMeta } func (s *PaginatedSVMSlice) Len() int { return len(s.Data) } func (s *PaginatedSVMSlice) NSPaths() testSVNamespacePaths { out := make(testSVNamespacePaths, len(s.Data)) for i, v := range s.Data { out[i] = testSVNamespacePath{v.Namespace, v.Path} } return out } type PaginatedSVQuietSlice struct { Data []string QueryMeta api.QueryMeta } func (ps PaginatedSVQuietSlice) Len() int { return len(ps.Data) } func (s *PaginatedSVQuietSlice) NSPaths() testSVNamespacePaths { out := make(testSVNamespacePaths, len(s.Data)) for i, v := range s.Data { out[i] = testSVNamespacePath{"", v} } return out }