registry_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. package commands
  2. import (
  3. "bytes"
  4. "errors"
  5. "flag"
  6. "io"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // notWithheld is a stand in for a surface's own policy, which the registry knows nothing
  15. // of; see Command.Identity for the one the info API applies.
  16. func notWithheld(c *Command) bool { return c.Name != "withheld" }
  17. const testReason = "this surface does not run commands that write"
  18. // textLine is a Text renderer for test commands whose result is one string.
  19. func textLine(v any, w io.Writer) error {
  20. _, err := io.WriteString(w, v.(string)+"\n")
  21. return err
  22. }
  23. func testRegistry() *Registry {
  24. r := New()
  25. r.Register(&Command{
  26. Name: "served",
  27. ShortDescription: "a command this surface serves",
  28. Run: func(fs any, a []string) (Result, error) {
  29. return Value("read " + strings.Join(a, ",")), nil
  30. },
  31. Text: textLine,
  32. })
  33. r.Register(&Command{
  34. Name: "withheld",
  35. ShortDescription: "a command this surface withholds",
  36. Run: func(fs any, a []string) (Result, error) {
  37. return Value("wrote"), nil
  38. },
  39. Text: textLine,
  40. })
  41. return r
  42. }
  43. // TestFilterKeepsWhatKeepSays: the copy holds the commands keep accepted, and help.
  44. func TestFilterKeepsWhatKeepSays(t *testing.T) {
  45. ro := testRegistry().Filter(notWithheld, testReason)
  46. names := make([]string, 0)
  47. for _, c := range ro.All() {
  48. names = append(names, string(c.Name))
  49. }
  50. assert.ElementsMatch(t, []string{"help", "served"}, names)
  51. }
  52. // TestRefusesWithheldByName covers a client that knows a command exists and asks for it
  53. // anyway. The filter lives on the serving side, so the answer must not depend on what
  54. // the client was told was available.
  55. func TestRefusesWithheldByName(t *testing.T) {
  56. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  57. w := Output{Out: out, Err: errOut}
  58. err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"withheld"}, w)
  59. require.Error(t, err)
  60. assert.Contains(t, err.Error(), "withheld is not available on this surface")
  61. assert.Empty(t, out.String(), "a withheld command must produce no output")
  62. assert.Contains(t, errOut.String(), "withheld is not available on this surface")
  63. assert.Contains(t, errOut.String(), testReason,
  64. "the registry should render the explanation the filter supplied, not one of its own")
  65. }
  66. // TestWithheldIsNotReportedAsUnknown keeps the two cases distinct: an operator who
  67. // mistypes and one who asked for a real command should not get the same answer.
  68. func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
  69. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  70. err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"no-such-command"}, Output{Out: out, Err: errOut})
  71. var f *Failure
  72. require.ErrorAs(t, err, &f, "an unknown command is a failure to the surface")
  73. assert.Contains(t, out.String(), "did not understand: no-such-command")
  74. }
  75. // TestResolveTellsUnknownFromWithheld: the two cases read differently to an operator,
  76. // and a surface with status codes tells them apart by the error's type, never by asking
  77. // the unfiltered registry, which is the one thing a filtered surface must not consult.
  78. func TestResolveTellsUnknownFromWithheld(t *testing.T) {
  79. r := testRegistry().Filter(notWithheld, testReason)
  80. c, err := r.Resolve("served")
  81. require.NoError(t, err)
  82. assert.Equal(t, Name("served"), c.Name)
  83. _, err = r.Resolve("withheld")
  84. var withheld *WithheldError
  85. require.ErrorAs(t, err, &withheld)
  86. require.ErrorContains(t, err, "withheld is not available on this surface")
  87. require.ErrorContains(t, err, testReason)
  88. _, err = r.Resolve("no-such-command")
  89. require.ErrorContains(t, err, `unknown command "no-such-command"`)
  90. assert.NotErrorAs(t, err, &withheld, "an unknown name is not a withheld one")
  91. }
  92. // paramRegistry holds one command with a bool flag and a duration flag, and reports what
  93. // the command saw when it ran.
  94. func paramRegistry(t *testing.T) (*Registry, *[]paramsSeen) {
  95. t.Helper()
  96. var seen []paramsSeen
  97. r := New()
  98. r.Register(&Command{
  99. Name: "set",
  100. ShortDescription: "sets things",
  101. Flags: func() (*flag.FlagSet, any) {
  102. fl := flag.NewFlagSet("", flag.ContinueOnError)
  103. s := paramsSeen{}
  104. fl.BoolVar(&s.Verbose, "verbose", false, "say more")
  105. fl.DurationVar(&s.Wait, "wait", 0, "how long")
  106. return fl, &s
  107. },
  108. Run: func(fs any, a []string) (Result, error) {
  109. seen = append(seen, *fs.(*paramsSeen))
  110. return Value("ok"), nil
  111. },
  112. })
  113. return r, &seen
  114. }
  115. type paramsSeen struct {
  116. Verbose bool
  117. Wait time.Duration
  118. }
  119. // TestInvokeReportsParametersInNameOrder: parameters arrive as an unordered object, so
  120. // with two bad ones the error must not depend on map iteration. Name order decides.
  121. func TestInvokeReportsParametersInNameOrder(t *testing.T) {
  122. r, _ := paramRegistry(t)
  123. cases := map[string]struct {
  124. params map[string]string
  125. want string
  126. }{
  127. "two unknown": {map[string]string{"zed": "1", "alpha": "1"}, `set has no parameter "alpha"`},
  128. "unknown before bad bool": {map[string]string{"aaa": "1", "verbose": "maybe"}, `set has no parameter "aaa"`},
  129. "bad bool before unknown": {map[string]string{"verbose": "maybe", "zzz": "1"}, `verbose: expected a boolean, got "maybe"`},
  130. "bad value before bad bool": {map[string]string{"wait": "soon", "verbose": "maybe"}, `verbose: expected a boolean`},
  131. }
  132. for name, tc := range cases {
  133. t.Run(name, func(t *testing.T) {
  134. // Enough runs that a map-order dependence would show.
  135. for i := 0; i < 50; i++ {
  136. _, err := Invoke(Request{Command: r.Get("set"), Params: tc.params})
  137. require.Error(t, err)
  138. assert.ErrorContains(t, err, tc.want)
  139. }
  140. })
  141. }
  142. }
  143. // TestInvokeWithABadParameterSetsNothing: a request with any bad parameter must not run
  144. // the command, and must leave nothing behind for the next request to see. The good
  145. // parameter sorts before the bad one, so a one-pass Invoke would have applied it.
  146. func TestInvokeWithABadParameterSetsNothing(t *testing.T) {
  147. r, seen := paramRegistry(t)
  148. _, err := Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "soon"}})
  149. require.ErrorContains(t, err, "wait:")
  150. assert.Empty(t, *seen, "the command must not run with a partial parameter set")
  151. v, err := Invoke(Request{Command: r.Get("set")})
  152. require.NoError(t, err)
  153. assert.Equal(t, "ok", v.Value)
  154. require.Len(t, *seen, 1)
  155. assert.Equal(t, paramsSeen{}, (*seen)[0], "nothing from the failed request carried over")
  156. _, err = Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "2s"}})
  157. require.NoError(t, err)
  158. require.Len(t, *seen, 2)
  159. assert.Equal(t, paramsSeen{Verbose: true, Wait: 2 * time.Second}, (*seen)[1])
  160. }
  161. func TestHelpListsOnlyServedCommands(t *testing.T) {
  162. out := &bytes.Buffer{}
  163. require.NoError(t, testRegistry().Filter(notWithheld, testReason).DispatchArgs(nil, MergedOutput(out)))
  164. assert.Contains(t, out.String(), "served")
  165. assert.NotContains(t, out.String(), "withheld")
  166. }
  167. // TestDispatchReportsUnparseableLine pins that a line shlex rejects says so on Err rather
  168. // than coming back silent, so an operator with an unbalanced quote learns the quote is the
  169. // problem instead of assuming the command is broken.
  170. func TestDispatchReportsUnparseableLine(t *testing.T) {
  171. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  172. testRegistry().Dispatch(`served "unterminated`, Output{Out: out, Err: errOut})
  173. assert.Empty(t, out.String())
  174. assert.Contains(t, errOut.String(), "could not parse the command line")
  175. }
  176. // TestRegisterRejectsReservedFlags: a command that declares a reserved flag is refused
  177. // at Register rather than when an operator first runs it; see reservedFlags.
  178. func TestRegisterRejectsReservedFlags(t *testing.T) {
  179. for _, name := range []string{"json", "pretty", "h", "help", "config", "socket"} {
  180. t.Run(name, func(t *testing.T) {
  181. r := New()
  182. assert.PanicsWithValue(t, "commands: clash declares reserved flag -"+name, func() {
  183. r.Register(&Command{
  184. Name: "clash",
  185. Flags: func() (*flag.FlagSet, any) {
  186. fl := flag.NewFlagSet("clash", flag.ContinueOnError)
  187. fl.Bool(name, false, "")
  188. return fl, nil
  189. },
  190. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  191. })
  192. })
  193. })
  194. }
  195. }
  196. // TestRegistryIsSafeForConcurrentUse runs the operations the surfaces perform, filtering
  197. // and cloning per request or session, against a stream of registrations. It exists to
  198. // be run under the race detector: the map underneath is not goroutine safe.
  199. func TestRegistryIsSafeForConcurrentUse(t *testing.T) {
  200. r := testRegistry()
  201. var wg sync.WaitGroup
  202. for i := 0; i < 8; i++ {
  203. wg.Add(1)
  204. go func(i int) {
  205. defer wg.Done()
  206. for j := 0; j < 50; j++ {
  207. r.Register(&Command{
  208. Name: Name("late-" + strings.Repeat("a", i) + "-" + strings.Repeat("b", j)),
  209. ShortDescription: "registered after the surfaces started",
  210. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  211. })
  212. }
  213. }(i)
  214. }
  215. for i := 0; i < 8; i++ {
  216. wg.Add(1)
  217. go func() {
  218. defer wg.Done()
  219. for j := 0; j < 50; j++ {
  220. f := r.Filter(notWithheld, testReason)
  221. _ = f.Get("served")
  222. _ = f.Schema()
  223. _ = r.Clone().Match("late-")
  224. }
  225. }()
  226. }
  227. wg.Wait()
  228. f := r.Filter(notWithheld, testReason)
  229. assert.Len(t, f.Match("late-"), 8*50, "every late registration is visible once the writers finish")
  230. }
  231. // TestCloneHelpSeesCloneCommands: the ssh session registers logout on its clone, and
  232. // the source does not learn of it. Help on the clone answers for the clone, and -h on a
  233. // command prints what `help <name>` does, on a clone as on the source: both render
  234. // through WriteCommandHelp, and Clone rebinds help to the copy.
  235. func TestCloneHelpSeesCloneCommands(t *testing.T) {
  236. r := testRegistry()
  237. c := r.Clone()
  238. c.Register(&Command{
  239. Name: Logout,
  240. ShortDescription: "ends the session",
  241. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  242. })
  243. assert.Nil(t, r.Get(Logout), "the source is not affected")
  244. out := &bytes.Buffer{}
  245. require.NoError(t, c.DispatchArgs([]string{"help"}, MergedOutput(out)))
  246. assert.Contains(t, out.String(), "logout")
  247. for _, name := range []string{"logout", "served"} {
  248. flag := &bytes.Buffer{}
  249. c.Dispatch(name+" -h", MergedOutput(flag))
  250. cmd := &bytes.Buffer{}
  251. c.Dispatch("help "+name, MergedOutput(cmd))
  252. assert.Equal(t, cmd.String(), flag.String(), name)
  253. assert.Contains(t, flag.String(), name+" - ", name)
  254. }
  255. }
  256. // TestThenRunsAfterTheReply pins the order Then exists for: Run's line is on the writer
  257. // before the action begins, Then's Result follows it, and Then's outcome is the
  258. // command's.
  259. func TestThenRunsAfterTheReply(t *testing.T) {
  260. out := &bytes.Buffer{}
  261. seen := "never ran"
  262. outcome := Did("acted")
  263. r := New()
  264. r.Register(&Command{
  265. Name: "act",
  266. ShortDescription: "replies, then acts",
  267. Run: func(fs any, a []string) (Result, error) {
  268. return Did("about to act"), nil
  269. },
  270. Then: func() Result {
  271. seen = out.String()
  272. return outcome
  273. },
  274. })
  275. require.NoError(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)))
  276. assert.Equal(t, "about to act\n", seen, "the line was written before the action")
  277. assert.Equal(t, "about to act\nacted\n", out.String())
  278. out.Reset()
  279. outcome = Failed("could not act")
  280. var f *Failure
  281. require.ErrorAs(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)), &f)
  282. assert.Equal(t, "about to act\ncould not act\n", out.String())
  283. // In JSON only the outcome travels: one document for the program that asked.
  284. out.Reset()
  285. require.ErrorAs(t, r.DispatchArgs([]string{"act", "-json"}, MergedOutput(out)), &f)
  286. assert.JSONEq(t, `{"ok":false,"reason":"could not act"}`, out.String())
  287. // A Run that refused is the outcome; the action does not happen.
  288. ran := false
  289. r.Register(&Command{
  290. Name: "refuse",
  291. ShortDescription: "refuses, so never acts",
  292. Run: func(fs any, a []string) (Result, error) {
  293. return Failed("not like that"), nil
  294. },
  295. Then: func() Result {
  296. ran = true
  297. return Did("acted anyway")
  298. },
  299. })
  300. out.Reset()
  301. require.ErrorAs(t, r.DispatchArgs([]string{"refuse"}, MergedOutput(out)), &f)
  302. assert.Equal(t, "not like that\n", out.String())
  303. assert.False(t, ran)
  304. out.Reset()
  305. require.ErrorAs(t, r.DispatchArgs([]string{"refuse", "-json"}, MergedOutput(out)), &f)
  306. assert.JSONEq(t, `{"ok":false,"reason":"not like that"}`, out.String())
  307. assert.False(t, ran)
  308. }
  309. // TestWriteCommandHelpIsTheOneRendering pins the layout both `help <command>` on the
  310. // daemon and `-h` in the client print, so the two cannot drift apart again.
  311. func TestWriteCommandHelpIsTheOneRendering(t *testing.T) {
  312. cs := CommandSchema{
  313. Name: "change-remote",
  314. Short: "changes a remote",
  315. Help: "Give it an address.",
  316. Flags: []FlagSchema{
  317. {Name: "address", Usage: "the new remote", Default: "1.2.3.4:4242"},
  318. {Name: "by-index", Bool: true, Usage: "match by index"},
  319. {Name: "json", Bool: true, Usage: "outputs as json"},
  320. },
  321. }
  322. out := &bytes.Buffer{}
  323. require.NoError(t, WriteCommandHelp(cs, out))
  324. assert.Equal(t, "change-remote - changes a remote\n"+
  325. " Give it an address.\n"+
  326. " -address string\n \tthe new remote (default 1.2.3.4:4242)\n"+
  327. " -by-index\n \tmatch by index\n"+
  328. " -json\n \toutputs as json\n", out.String())
  329. }
  330. // addrFlags is the flag struct of a test command that takes -address.
  331. type addrFlags struct{ Address string }
  332. // TestHelpFlagIsReadWhereParseReadsIt: -h asks for help only where the line's parser
  333. // reads it as a flag. As a flag's value or after -- it reaches the command as the
  334. // operand it is, and help is rendered by the same function `help <command>` uses.
  335. func TestHelpFlagIsReadWhereParseReadsIt(t *testing.T) {
  336. var seen []string
  337. r := New()
  338. r.Register(Flagged(&Command{Name: "x", ShortDescription: "takes an address"},
  339. func(fl *flag.FlagSet, s *addrFlags) { fl.StringVar(&s.Address, "address", "", "an address") },
  340. func(s *addrFlags, a []string) (Result, error) {
  341. seen = append(seen, s.Address+"|"+strings.Join(a, ","))
  342. return Nothing(), nil
  343. }))
  344. for _, line := range []string{"x -h", "x -help", "x --help", "x 10.0.0.1 -h", "x -address=1 -h"} {
  345. out := &bytes.Buffer{}
  346. r.Dispatch(line, MergedOutput(out))
  347. assert.Contains(t, out.String(), "x - takes an address\n", line)
  348. assert.Contains(t, out.String(), "-address string", line)
  349. }
  350. assert.Empty(t, seen, "asking for help must not run the command")
  351. for _, line := range []string{"x -address -h", "x -- -h"} {
  352. out := &bytes.Buffer{}
  353. r.Dispatch(line, MergedOutput(out))
  354. assert.Empty(t, out.String(), line)
  355. }
  356. assert.Equal(t, []string{"-h|", "|-h"}, seen)
  357. }
  358. // TestBadFlagIsAnsweredWithTheCommandsHelp: a flag the command does not have is reported
  359. // on the error stream followed by the command's usage, in the rendering help uses.
  360. func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
  361. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  362. r := New()
  363. r.Register(&Command{Name: "x", ShortDescription: "has no flags", Run: func(fs any, a []string) (Result, error) { return Nothing(), nil }})
  364. require.Error(t, r.DispatchArgs([]string{"x", "-nope"}, Output{Out: out, Err: errOut}))
  365. assert.Empty(t, out.String())
  366. assert.Equal(t, "flag provided but not defined: -nope\nx - has no flags\n -json\n \toutputs as json\n -pretty\n \tpretty prints json, assumes -json\n", errOut.String())
  367. }
  368. // TestParameterMistakesAreParamErrors: a surface with status codes needs to tell a
  369. // parameter the caller got wrong from a command that failed, and Invoke says which by
  370. // the error's type rather than its words.
  371. func TestParameterMistakesAreParamErrors(t *testing.T) {
  372. r := New()
  373. r.Register(&Command{
  374. Name: "flagged",
  375. Flags: func() (*flag.FlagSet, any) {
  376. fl := flag.NewFlagSet("", flag.ContinueOnError)
  377. s := struct{ N int }{}
  378. fl.IntVar(&s.N, "n", 0, "")
  379. return fl, &s
  380. },
  381. Run: func(fs any, a []string) (Result, error) { return Result{}, errors.New("ran and failed") },
  382. })
  383. r.Register(&Command{
  384. Name: "bare",
  385. Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
  386. })
  387. var pe *ParamError
  388. for _, tc := range []struct {
  389. name string
  390. params map[string]string
  391. }{
  392. {"flagged", map[string]string{"nope": "1"}},
  393. {"flagged", map[string]string{"n": "x"}},
  394. {"flagged", map[string]string{FlagJSON: "true"}},
  395. {"bare", map[string]string{"n": "1"}},
  396. } {
  397. _, err := Invoke(Request{Command: r.Get(Name(tc.name)), Params: tc.params})
  398. require.Error(t, err, "%s %v", tc.name, tc.params)
  399. require.ErrorAs(t, err, &pe, "%s %v: %v", tc.name, tc.params, err)
  400. }
  401. _, err := Invoke(Request{Command: r.Get("flagged"), Params: map[string]string{"n": "1"}})
  402. require.EqualError(t, err, "ran and failed")
  403. assert.NotErrorAs(t, err, &pe, "a Run error is the command's, not the caller's")
  404. }
  405. // TestRunErrorIsShown: an error Run returns reaches the person at the prompt on the
  406. // error stream, as well as the surface that logs it.
  407. func TestRunErrorIsShown(t *testing.T) {
  408. r := New()
  409. r.Register(&Command{
  410. Name: "broken",
  411. ShortDescription: "cannot do what it is asked",
  412. Run: func(fs any, a []string) (Result, error) {
  413. return Result{}, errors.New("no certificate is loaded")
  414. },
  415. })
  416. out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
  417. require.EqualError(t, r.DispatchArgs([]string{"broken"}, Output{Out: out, Err: errOut}), "no certificate is loaded")
  418. assert.Empty(t, out.String())
  419. assert.Equal(t, "no certificate is loaded\n", errOut.String())
  420. }