executor_test.go 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  1. package executor
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. "github.com/OliveTin/OliveTin/internal/auth"
  11. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  12. config "github.com/OliveTin/OliveTin/internal/config"
  13. "github.com/OliveTin/OliveTin/internal/entities"
  14. )
  15. func testingExecutor() (*Executor, *config.Config) {
  16. cfg := config.DefaultConfig()
  17. e := DefaultExecutor(cfg)
  18. a1 := &config.Action{
  19. Title: "Do some tickles",
  20. Shell: "echo 'Tickling {{ person }}'",
  21. Arguments: []config.ActionArgument{
  22. {
  23. Name: "person",
  24. Type: "ascii",
  25. },
  26. },
  27. }
  28. cfg.Actions = append(cfg.Actions, a1)
  29. cfg.Sanitize()
  30. return e, cfg
  31. }
  32. func TestGetLogReturnsDefensiveCopy(t *testing.T) {
  33. e := DefaultExecutor(config.DefaultConfig())
  34. e.logs["tracking-id"] = &InternalLogEntry{
  35. Arguments: map[string]string{"message": "original"},
  36. Output: "original",
  37. Tags: []string{"original"},
  38. Binding: &ActionBinding{
  39. ID: "original-binding",
  40. OnDashboards: []DashboardNavigationTarget{
  41. {Title: "original"},
  42. },
  43. },
  44. }
  45. entry, found := e.GetLog("tracking-id")
  46. require.True(t, found)
  47. entry.Arguments["message"] = "changed"
  48. entry.Output = "changed"
  49. entry.Tags[0] = "changed"
  50. entry.Binding.ID = "changed-binding"
  51. entry.Binding.OnDashboards[0].Title = "changed"
  52. stored, found := e.GetLog("tracking-id")
  53. require.True(t, found)
  54. assert.Equal(t, "original", stored.Arguments["message"])
  55. assert.Equal(t, "original", stored.Output)
  56. assert.Equal(t, []string{"original"}, stored.Tags)
  57. require.NotNil(t, stored.Binding)
  58. assert.Equal(t, "original-binding", stored.Binding.ID)
  59. assert.Equal(t, []DashboardNavigationTarget{{Title: "original"}}, stored.Binding.OnDashboards)
  60. }
  61. func TestCreateExecutorAndExec(t *testing.T) {
  62. e, cfg := testingExecutor()
  63. req := ExecutionRequest{
  64. AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"},
  65. Cfg: cfg,
  66. Arguments: map[string]string{
  67. "person": "yourself",
  68. },
  69. }
  70. // Ensure bindings are available and set the binding to the only configured action
  71. e.RebuildActionMap()
  72. if len(cfg.Actions) > 0 {
  73. req.Binding = e.FindBindingWithNoEntity(cfg.Actions[0])
  74. }
  75. assert.NotNil(t, e, "Create an executor")
  76. wg, _ := e.ExecRequest(&req)
  77. wg.Wait()
  78. assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero")
  79. }
  80. func TestStepRequestActionPopulateLogEntryResolvesEntityTemplates(t *testing.T) {
  81. req := &ExecutionRequest{
  82. logEntry: &InternalLogEntry{},
  83. Binding: &ActionBinding{
  84. Action: &config.Action{
  85. Title: "Do something with {{ project.name }}",
  86. Icon: "{{ project.icon }}",
  87. },
  88. Entity: &entities.Entity{
  89. Data: map[string]any{
  90. "name": "foo",
  91. "icon": "🐰",
  92. },
  93. UniqueKey: "foo-key",
  94. },
  95. },
  96. }
  97. stepRequestActionPopulateLogEntry(req)
  98. assert.Equal(t, "Do something with foo", req.logEntry.ActionTitle)
  99. assert.Equal(t, "🐰", req.logEntry.ActionIcon)
  100. assert.Equal(t, "Do something with {{ project.name }}", req.logEntry.ActionConfigTitle)
  101. assert.Equal(t, "foo-key", req.logEntry.EntityPrefix)
  102. }
  103. func TestExecNonExistant(t *testing.T) {
  104. e, cfg := testingExecutor()
  105. req := ExecutionRequest{
  106. // Binding: e.FindBindingWithNoEntity("waffles"),
  107. logEntry: &InternalLogEntry{},
  108. Cfg: cfg,
  109. }
  110. wg, _ := e.ExecRequest(&req)
  111. wg.Wait()
  112. assert.Equal(t, int32(-1337), req.logEntry.ExitCode, "Log entry is set to an internal error code")
  113. assert.Equal(t, "💩", req.logEntry.ActionIcon, "Log entry icon is a poop (not found)")
  114. }
  115. func TestArgumentNameCamelCase(t *testing.T) {
  116. req := newExecRequest()
  117. req.Binding.Action = &config.Action{
  118. Title: "Do some tickles",
  119. Shell: "echo 'Tickling {{ personName }}'",
  120. Arguments: []config.ActionArgument{
  121. {
  122. Name: "personName",
  123. Type: "ascii",
  124. },
  125. },
  126. }
  127. req.Arguments = map[string]string{
  128. "personName": "Fred",
  129. }
  130. out, err := parseActionArguments(req)
  131. assert.Equal(t, "echo 'Tickling Fred'", out)
  132. assert.NoError(t, err)
  133. }
  134. func TestArgumentNameSnakeCase(t *testing.T) {
  135. req := newExecRequest()
  136. req.Binding.Action = &config.Action{
  137. Title: "Do some tickles",
  138. Shell: "echo 'Tickling {{ person_name }}'",
  139. Arguments: []config.ActionArgument{
  140. {
  141. Name: "person_name",
  142. Type: "ascii",
  143. },
  144. },
  145. }
  146. req.Arguments = map[string]string{
  147. "person_name": "Fred",
  148. }
  149. out, err := parseActionArguments(req)
  150. assert.Equal(t, "echo 'Tickling Fred'", out)
  151. assert.NoError(t, err)
  152. }
  153. func TestGetLogsEmpty(t *testing.T) {
  154. e, cfg := testingExecutor()
  155. assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
  156. logs, paging := e.GetLogTrackingIds(0, 10)
  157. assert.NotNil(t, logs, "Logs should not be nil")
  158. assert.Empty(t, logs, "No logs yet")
  159. assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
  160. }
  161. func TestGetLogsLessThanPageSize(t *testing.T) {
  162. e, cfg := testingExecutor()
  163. cfg.Actions = append(cfg.Actions, &config.Action{
  164. Title: "blat",
  165. Shell: "date",
  166. })
  167. cfg.Sanitize()
  168. // Rebuild action map to include newly added action
  169. e.RebuildActionMap()
  170. assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
  171. logEntries, paging := e.GetLogTrackingIds(0, 10)
  172. assert.Empty(t, logEntries, "There should be 0 logs")
  173. assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
  174. execNewReqAndWait(e, cfg)
  175. execNewReqAndWait(e, cfg)
  176. execNewReqAndWait(e, cfg)
  177. execNewReqAndWait(e, cfg)
  178. execNewReqAndWait(e, cfg)
  179. execNewReqAndWait(e, cfg)
  180. execNewReqAndWait(e, cfg)
  181. logEntries, paging = e.GetLogTrackingIds(0, 10)
  182. assert.Len(t, logEntries, 7, "There should be 7 logs")
  183. assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
  184. execNewReqAndWait(e, cfg)
  185. execNewReqAndWait(e, cfg)
  186. execNewReqAndWait(e, cfg)
  187. execNewReqAndWait(e, cfg)
  188. execNewReqAndWait(e, cfg)
  189. logEntries, paging = e.GetLogTrackingIds(0, 10)
  190. assert.Len(t, logEntries, 10, "There should be 10 logs")
  191. assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
  192. }
  193. func execNewReqAndWait(e *Executor, cfg *config.Config) {
  194. req := &ExecutionRequest{
  195. // ActionTitle: title,
  196. Cfg: cfg,
  197. }
  198. // Ensure we have a binding for the requested title
  199. e.RebuildActionMap()
  200. var action *config.Action
  201. for _, a := range cfg.Actions {
  202. if a.Title == "blat" {
  203. action = a
  204. break
  205. }
  206. }
  207. if action != nil {
  208. req.Binding = e.FindBindingWithNoEntity(action)
  209. }
  210. wg, _ := e.ExecRequest(req)
  211. wg.Wait()
  212. }
  213. func TestGetPagingIndexes(t *testing.T) {
  214. assert.Zero(t, getPagingStartIndex(5, 0), "Testing start index from empty list")
  215. assert.Equal(t, int64(4), getPagingStartIndex(5, 10), "Testing start index from mid point")
  216. assert.Equal(t, int64(9), getPagingStartIndex(-1, 10), "Testing start index with negative offset")
  217. assert.Equal(t, int64(0), getPagingStartIndex(15, 10), "Testing start index with large offset")
  218. assert.Equal(t, int64(9), getPagingStartIndex(0, 10), "Testing start index with zero count")
  219. }
  220. func TestUnsetRequiredArgument(t *testing.T) {
  221. req := newExecRequest()
  222. req.Binding.Action = &config.Action{
  223. Title: "Print your name",
  224. Shell: "echo 'Your name is: {{ name }}'",
  225. Arguments: []config.ActionArgument{
  226. {
  227. Name: "name",
  228. Type: "ascii",
  229. },
  230. },
  231. }
  232. req.Arguments = map[string]string{}
  233. out, err := parseActionArguments(req)
  234. assert.Empty(t, out)
  235. assert.Error(t, err)
  236. }
  237. func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
  238. req := newExecRequest()
  239. req.Binding.Action = &config.Action{
  240. Title: "Print your name",
  241. Shell: "echo 'Your name is: {{ name }}'",
  242. Arguments: []config.ActionArgument{
  243. {
  244. Name: "name",
  245. Type: "ascii",
  246. },
  247. {
  248. Name: "age",
  249. Type: "int",
  250. },
  251. },
  252. }
  253. req.Arguments = map[string]string{
  254. "name": "Fred",
  255. "age": "Not an integer",
  256. }
  257. out, err := parseActionArguments(req)
  258. assert.Empty(t, out)
  259. assert.Error(t, err)
  260. }
  261. // https://github.com/OliveTin/OliveTin/issues/564
  262. func TestMangleInvalidArgumentValues(t *testing.T) {
  263. e, cfg := testingExecutor()
  264. a1 := &config.Action{
  265. Title: "Validate my date without seconds because I am from an Android phone",
  266. Shell: "echo 'The date is: {{ date }}'",
  267. Arguments: []config.ActionArgument{
  268. {
  269. Name: "date",
  270. Type: "datetime",
  271. },
  272. },
  273. }
  274. cfg.Actions = append(cfg.Actions, a1)
  275. cfg.Sanitize()
  276. // Build bindings for newly added action
  277. e.RebuildActionMap()
  278. req := ExecutionRequest{
  279. // Action: a1,
  280. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  281. Cfg: cfg,
  282. Arguments: map[string]string{
  283. "date": "1990-01-10T12:00", // Invalid format, should be without seconds
  284. },
  285. }
  286. // Set binding to our appended action
  287. req.Binding = e.FindBindingWithNoEntity(a1)
  288. wg, _ := e.ExecRequest(&req)
  289. wg.Wait()
  290. assert.NotNil(t, req.logEntry, "Log entry should not be nil")
  291. assert.Equal(t, "The date is: 1990-01-10T12:00:00\n", req.logEntry.Output, "Date should be mangled to a valid format")
  292. }
  293. func TestWebhookRejectsShellExecution(t *testing.T) {
  294. cfg := config.DefaultConfig()
  295. e := DefaultExecutor(cfg)
  296. a1 := &config.Action{
  297. Title: "Webhook Shell Reject",
  298. Shell: "echo '{{ msg }}'",
  299. Arguments: []config.ActionArgument{
  300. {Name: "msg", Type: "ascii"},
  301. },
  302. }
  303. cfg.Actions = append(cfg.Actions, a1)
  304. cfg.Sanitize()
  305. e.RebuildActionMap()
  306. req := ExecutionRequest{
  307. Tags: []string{"webhook"},
  308. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  309. Cfg: cfg,
  310. Arguments: map[string]string{"msg": "hello"},
  311. Binding: e.FindBindingWithNoEntity(a1),
  312. }
  313. wg, _ := e.ExecRequest(&req)
  314. wg.Wait()
  315. assert.NotNil(t, req.logEntry)
  316. assert.Equal(t, int32(-1337), req.logEntry.ExitCode)
  317. assert.Contains(t, req.logEntry.Output, "webhooks cannot use Shell execution")
  318. }
  319. func TestWebhookAllowsExecExecution(t *testing.T) {
  320. cfg := config.DefaultConfig()
  321. e := DefaultExecutor(cfg)
  322. a1 := &config.Action{
  323. Title: "Webhook Exec OK",
  324. Exec: []string{"echo", "{{ msg }}"},
  325. Arguments: []config.ActionArgument{
  326. {Name: "msg", Type: "ascii"},
  327. },
  328. }
  329. cfg.Actions = append(cfg.Actions, a1)
  330. cfg.Sanitize()
  331. e.RebuildActionMap()
  332. req := ExecutionRequest{
  333. Tags: []string{"webhook"},
  334. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  335. Cfg: cfg,
  336. Arguments: map[string]string{"msg": "hello"},
  337. Binding: e.FindBindingWithNoEntity(a1),
  338. }
  339. wg, _ := e.ExecRequest(&req)
  340. wg.Wait()
  341. assert.NotNil(t, req.logEntry)
  342. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  343. assert.Contains(t, req.logEntry.Output, "hello")
  344. }
  345. func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
  346. cfg := config.DefaultConfig()
  347. e := DefaultExecutor(cfg)
  348. a1 := &config.Action{
  349. Title: "Webhook After Shell Reject",
  350. Exec: []string{"echo", "{{ msg }}"},
  351. ShellAfterCompleted: "echo after",
  352. Arguments: []config.ActionArgument{
  353. {Name: "msg", Type: "ascii"},
  354. },
  355. }
  356. cfg.Actions = append(cfg.Actions, a1)
  357. cfg.Sanitize()
  358. e.RebuildActionMap()
  359. req := ExecutionRequest{
  360. Tags: []string{"webhook"},
  361. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  362. Cfg: cfg,
  363. Arguments: map[string]string{"msg": "hello"},
  364. Binding: e.FindBindingWithNoEntity(a1),
  365. }
  366. wg, _ := e.ExecRequest(&req)
  367. wg.Wait()
  368. assert.NotNil(t, req.logEntry)
  369. assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
  370. }
  371. func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
  372. cfg := config.DefaultConfig()
  373. e := DefaultExecutor(cfg)
  374. injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
  375. expectedMainOutput := "'; touch " + injectedPath + "; echo '"
  376. a1 := &config.Action{
  377. Title: "After completion escape",
  378. Shell: "printf %s \"" + expectedMainOutput + "\"",
  379. ShellAfterCompleted: "printf %s {{ output }}",
  380. }
  381. cfg.Actions = append(cfg.Actions, a1)
  382. cfg.Sanitize()
  383. e.RebuildActionMap()
  384. req := ExecutionRequest{
  385. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  386. Cfg: cfg,
  387. Binding: e.FindBindingWithNoEntity(a1),
  388. }
  389. wg, _ := e.ExecRequest(&req)
  390. wg.Wait()
  391. assert.NotNil(t, req.logEntry)
  392. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  393. assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
  394. assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
  395. _, err := os.Stat(injectedPath)
  396. assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
  397. }
  398. func TestShellAfterCompletedExpandsQuotedPlaceholders(t *testing.T) {
  399. cases := []struct {
  400. name string
  401. sac string
  402. }{
  403. {"legacy single-quoted", `printf '%s' '{{ output }}'`},
  404. {"modern single-quoted", `printf '%s' '{{ .Arguments.output }}'`},
  405. }
  406. for _, tc := range cases {
  407. t.Run(tc.name, func(t *testing.T) {
  408. cfg := config.DefaultConfig()
  409. executor := DefaultExecutor(cfg)
  410. mainOutput := "quoted-output-ok"
  411. action := &config.Action{
  412. Title: "sac-quoted-" + tc.name,
  413. Shell: "printf %s \"" + mainOutput + "\"",
  414. ShellAfterCompleted: tc.sac,
  415. }
  416. cfg.Actions = append(cfg.Actions, action)
  417. cfg.Sanitize()
  418. executor.RebuildActionMap()
  419. req := ExecutionRequest{
  420. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  421. Cfg: cfg,
  422. Binding: executor.FindBindingWithNoEntity(action),
  423. }
  424. wg, _ := executor.ExecRequest(&req)
  425. wg.Wait()
  426. require.NotNil(t, req.logEntry)
  427. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  428. assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+mainOutput)
  429. })
  430. }
  431. }
  432. func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
  433. payload := func(injectedPath string) string {
  434. return "x; touch " + injectedPath + "; #"
  435. }
  436. cases := []struct {
  437. name string
  438. sac string
  439. }{
  440. {"legacy", "printf %s {{ output }}"},
  441. {"legacy compact", "printf %s {{output}}"},
  442. {"legacy extra spaces", "printf %s {{ output }}"},
  443. {"modern Arguments", "printf %s {{ .Arguments.output }}"},
  444. {"modern compact", "printf %s {{.Arguments.output}}"},
  445. {"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
  446. }
  447. for _, tc := range cases {
  448. t.Run(tc.name, func(t *testing.T) {
  449. cfg := config.DefaultConfig()
  450. executor := DefaultExecutor(cfg)
  451. injectedPath := filepath.Join(t.TempDir(), "injected")
  452. mainPayload := payload(injectedPath)
  453. action := &config.Action{
  454. Title: "sac-injection-" + tc.name,
  455. Shell: "printf %s \"" + mainPayload + "\"",
  456. ShellAfterCompleted: tc.sac,
  457. }
  458. cfg.Actions = append(cfg.Actions, action)
  459. cfg.Sanitize()
  460. executor.RebuildActionMap()
  461. req := ExecutionRequest{
  462. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  463. Cfg: cfg,
  464. Binding: executor.FindBindingWithNoEntity(action),
  465. }
  466. wg, _ := executor.ExecRequest(&req)
  467. wg.Wait()
  468. _, err := os.Stat(injectedPath)
  469. assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
  470. })
  471. }
  472. }
  473. func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
  474. cases := []struct {
  475. in string
  476. want string
  477. }{
  478. {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
  479. {`printf %s {{output}}`, `printf %s "$OUTPUT"`},
  480. {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
  481. {`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
  482. {`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
  483. {`echo {{ exitCode }}`, `echo "$EXITCODE"`},
  484. {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
  485. {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
  486. {`printf '%s' '{{ output }}'`, `printf '%s' ''"$OUTPUT"''`},
  487. {`printf '%s' '{{ .Arguments.output }}'`, `printf '%s' ''"$OUTPUT"''`},
  488. {`printf '%s' '{{ exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
  489. {`printf '%s' '{{ .Arguments.exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
  490. }
  491. for _, tc := range cases {
  492. assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
  493. }
  494. }
  495. func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
  496. args := map[string]string{
  497. "output": "evil; id",
  498. "exitCode": "1",
  499. "ot_username": "alice",
  500. "ot_executionTrackingId": "track-1",
  501. }
  502. templateArgs := shellAfterTemplateArgs(args)
  503. assert.NotContains(t, templateArgs, "output")
  504. assert.NotContains(t, templateArgs, "exitCode")
  505. assert.Equal(t, "alice", templateArgs["ot_username"])
  506. assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
  507. assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
  508. }
  509. func TestFilterToDefinedArgumentsOnly(t *testing.T) {
  510. req := newExecRequest()
  511. req.Binding.Action = &config.Action{
  512. Title: "Filter test",
  513. Shell: "echo '{{ name }}'",
  514. Arguments: []config.ActionArgument{
  515. {Name: "name", Type: "ascii"},
  516. },
  517. }
  518. req.Arguments = map[string]string{
  519. "name": "Alice",
  520. "webhook_path": "/malicious/$(id)",
  521. "extra_undefined": "ignored",
  522. }
  523. filterToDefinedArgumentsOnly(req)
  524. assert.Equal(t, "Alice", req.Arguments["name"])
  525. assert.Empty(t, req.Arguments["webhook_path"])
  526. assert.Empty(t, req.Arguments["extra_undefined"])
  527. }
  528. func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) {
  529. req := newExecRequest()
  530. req.Binding.Action = &config.Action{
  531. Title: "Filter test",
  532. Shell: "echo test",
  533. Arguments: []config.ActionArgument{},
  534. }
  535. req.Arguments = map[string]string{
  536. "ot_executionTrackingId": "track-123",
  537. "ot_username": "webhook",
  538. }
  539. filterToDefinedArgumentsOnly(req)
  540. assert.Empty(t, req.Arguments["ot_executionTrackingId"])
  541. assert.Empty(t, req.Arguments["ot_username"])
  542. }
  543. func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) {
  544. req := newExecRequest()
  545. req.TrackingID = "server-track-456"
  546. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
  547. req.Binding.Action = &config.Action{
  548. Title: "Filter then inject",
  549. Shell: "echo test",
  550. Arguments: []config.ActionArgument{
  551. {Name: "name", Type: "ascii"},
  552. },
  553. }
  554. req.Arguments = map[string]string{
  555. "name": "Alice",
  556. "ot_executionTrackingId": "attacker-track",
  557. "ot_username": "mallory",
  558. "ot_custom": "polluted",
  559. }
  560. assert.True(t, stepParseArgs(req))
  561. assert.Equal(t, "Alice", req.Arguments["name"])
  562. assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"])
  563. assert.Equal(t, "alice", req.Arguments["ot_username"])
  564. assert.Empty(t, req.Arguments["ot_custom"])
  565. }
  566. func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) {
  567. req := newExecRequest()
  568. req.TrackingID = "server-track-456"
  569. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  570. req.Binding.Action = &config.Action{
  571. Title: "No reserved prefix pollution",
  572. Shell: "echo test",
  573. Arguments: []config.ActionArgument{},
  574. }
  575. req.Arguments = map[string]string{
  576. "ot_custom": "polluted",
  577. }
  578. assert.True(t, stepParseArgs(req))
  579. env := buildEnv(req.Arguments)
  580. assert.False(t, containsEnvPrefix(env, "OT_CUSTOM="))
  581. assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com"))
  582. assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456"))
  583. }
  584. func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) {
  585. unsafeTypes := map[string]struct{}{
  586. "email": {},
  587. "password": {},
  588. "raw_string_multiline": {},
  589. "url": {},
  590. "very_dangerous_raw_string": {},
  591. }
  592. seen := map[string]struct{}{}
  593. for _, arg := range systemArgumentDefinitions {
  594. assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix))
  595. assert.NotEmpty(t, arg.Type)
  596. assert.True(t, arg.RejectNull)
  597. _, duplicate := seen[arg.Name]
  598. assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name)
  599. seen[arg.Name] = struct{}{}
  600. _, unsafe := unsafeTypes[arg.Type]
  601. assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type)
  602. }
  603. }
  604. func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
  605. req := newExecRequest()
  606. req.TrackingID = "server-track-456"
  607. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  608. args, err := validatedSystemArgs(req)
  609. require.NoError(t, err)
  610. assert.Len(t, args, len(systemArgumentDefinitions))
  611. for _, arg := range systemArgumentDefinitions {
  612. assert.Contains(t, args, arg.Name)
  613. }
  614. }
  615. func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
  616. req := newExecRequest()
  617. req.logEntry = &InternalLogEntry{
  618. Output: "hello",
  619. ExitCode: 7,
  620. }
  621. req.TrackingID = "server-track-456"
  622. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  623. req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"}
  624. args, err := buildShellAfterArgs(req)
  625. require.NoError(t, err)
  626. assert.Len(t, args, len(systemArgumentDefinitions)+2)
  627. assert.Contains(t, args, "output")
  628. assert.Contains(t, args, "exitCode")
  629. for _, arg := range systemArgumentDefinitions {
  630. assert.Contains(t, args, arg.Name)
  631. }
  632. }
  633. func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) {
  634. req := newExecRequest()
  635. req.logEntry = &InternalLogEntry{}
  636. req.TrackingID = "server-track-456"
  637. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  638. req.Binding.Action = &config.Action{
  639. Title: "Email username",
  640. Shell: "echo test",
  641. Arguments: []config.ActionArgument{},
  642. }
  643. assert.True(t, stepParseArgs(req))
  644. assert.Equal(t, "alice@example.com", req.Arguments["ot_username"])
  645. }
  646. func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) {
  647. req := newExecRequest()
  648. req.logEntry = &InternalLogEntry{}
  649. req.TrackingID = "server-track-456"
  650. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
  651. req.Binding.Action = &config.Action{
  652. Title: "Invalid system arg",
  653. Shell: "echo test",
  654. Arguments: []config.ActionArgument{},
  655. }
  656. assert.False(t, stepParseArgs(req))
  657. assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`)
  658. assert.Empty(t, req.Arguments["ot_username"])
  659. }
  660. func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) {
  661. req := newExecRequest()
  662. req.logEntry = &InternalLogEntry{}
  663. req.TrackingID = "track/../../bad"
  664. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
  665. req.Binding.Action = &config.Action{
  666. Title: "Invalid tracking ID",
  667. Shell: "echo test",
  668. Arguments: []config.ActionArgument{},
  669. }
  670. assert.False(t, stepParseArgs(req))
  671. assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`)
  672. assert.Empty(t, req.Arguments["ot_executionTrackingId"])
  673. }
  674. func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
  675. req := newExecRequest()
  676. req.logEntry = &InternalLogEntry{
  677. Output: "hello",
  678. ExitCode: 7,
  679. }
  680. req.TrackingID = "server-track-456"
  681. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  682. req.Binding.Action = &config.Action{
  683. Title: "Shell after",
  684. ShellAfterCompleted: "echo test",
  685. }
  686. args, err := buildShellAfterArgs(req)
  687. require.NoError(t, err)
  688. assert.Equal(t, "alice@example.com", args["ot_username"])
  689. assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
  690. assert.Equal(t, "hello", args["output"])
  691. assert.Equal(t, "7", args["exitCode"])
  692. }
  693. func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
  694. req := newExecRequest()
  695. req.logEntry = &InternalLogEntry{}
  696. req.TrackingID = "server-track-456"
  697. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
  698. req.Binding.Action = &config.Action{
  699. Title: "Shell after invalid username",
  700. ShellAfterCompleted: "echo test",
  701. }
  702. args, err := buildShellAfterArgs(req)
  703. assert.Nil(t, args)
  704. require.Error(t, err)
  705. assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
  706. }
  707. func containsEnvPrefix(env []string, prefix string) bool {
  708. for _, item := range env {
  709. if strings.HasPrefix(item, prefix) {
  710. return true
  711. }
  712. }
  713. return false
  714. }
  715. func TestTriggerExecutesTriggeredAction(t *testing.T) {
  716. cfg := config.DefaultConfig()
  717. e := DefaultExecutor(cfg)
  718. helloAction := &config.Action{
  719. Title: "Hello world",
  720. Shell: "echo 'Hello World!'",
  721. }
  722. triggerAction := &config.Action{
  723. Title: "Simple action that triggers another action",
  724. Shell: "echo 'Hi'",
  725. Triggers: []string{"Hello world"},
  726. }
  727. cfg.Actions = append(cfg.Actions, helloAction, triggerAction)
  728. cfg.Sanitize()
  729. e.RebuildActionMap()
  730. finishedTitles := make(chan string, 4)
  731. collector := &executionFinishedCollector{ch: finishedTitles}
  732. e.AddListener(collector)
  733. req := &ExecutionRequest{
  734. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  735. Cfg: cfg,
  736. Binding: e.FindBindingWithNoEntity(triggerAction),
  737. }
  738. wg, _ := e.ExecRequest(req)
  739. wg.Wait()
  740. var got []string
  741. for i := range 2 {
  742. select {
  743. case title := <-finishedTitles:
  744. got = append(got, title)
  745. case <-time.After(2 * time.Second):
  746. t.Fatalf("timed out waiting for execution %d; got %v", i+1, got)
  747. }
  748. }
  749. assert.Contains(t, got, "Hello world", "triggered action must run")
  750. assert.Contains(t, got, "Simple action that triggers another action", "triggering action must run")
  751. }
  752. func TestTriggerUnknownActionTitleSkipsWithoutPanic(t *testing.T) {
  753. cfg := config.DefaultConfig()
  754. e := DefaultExecutor(cfg)
  755. triggerAction := &config.Action{
  756. Title: "Action with bad trigger",
  757. Shell: "echo 'ok'",
  758. Triggers: []string{"Nonexistent action"},
  759. }
  760. cfg.Actions = append(cfg.Actions, triggerAction)
  761. cfg.Sanitize()
  762. e.RebuildActionMap()
  763. finishedTitles := make(chan string, 4)
  764. collector := &executionFinishedCollector{ch: finishedTitles}
  765. e.AddListener(collector)
  766. req := &ExecutionRequest{
  767. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  768. Cfg: cfg,
  769. Binding: e.FindBindingWithNoEntity(triggerAction),
  770. }
  771. wg, _ := e.ExecRequest(req)
  772. wg.Wait()
  773. var got []string
  774. select {
  775. case title := <-finishedTitles:
  776. got = append(got, title)
  777. case <-time.After(500 * time.Millisecond):
  778. }
  779. assert.Len(t, got, 1, "only the triggering action runs; unknown trigger is skipped")
  780. if len(got) > 0 {
  781. assert.Equal(t, "Action with bad trigger", got[0])
  782. }
  783. }
  784. type executionFinishedCollector struct {
  785. ch chan string
  786. }
  787. func (c *executionFinishedCollector) OnExecutionStarted(_ *InternalLogEntry) {}
  788. func (c *executionFinishedCollector) OnExecutionFinished(entry *InternalLogEntry) {
  789. c.ch <- entry.ActionTitle
  790. }
  791. func (c *executionFinishedCollector) OnOutputChunk(_ []byte, _ string) {}
  792. func (c *executionFinishedCollector) OnActionMapRebuilt() {}
  793. func TestSanitizeLogFilename(t *testing.T) {
  794. tests := []struct {
  795. title string
  796. want string
  797. }{
  798. {"Echo Test", "Echo Test"},
  799. {"Create/update Monthly Report", "Create_update Monthly Report"},
  800. {`path\with\backslashes`, "path_with_backslashes"},
  801. {`a:b*c?d"e<f>g|h`, "a_b_c_d_e_f_g_h"},
  802. {"has\x00nul", "has_nul"},
  803. {"tab\there\nand\rreturn", "tab_here_and_return"},
  804. }
  805. for _, tt := range tests {
  806. assert.Equal(t, tt.want, sanitizeLogFilename(tt.title), "title=%q", tt.title)
  807. }
  808. }
  809. func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) {
  810. resultsDir := t.TempDir()
  811. outputDir := t.TempDir()
  812. started := time.Unix(1714333384, 0)
  813. trackingID := "5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2"
  814. title := "Create/update Monthly Report"
  815. req := &ExecutionRequest{
  816. Cfg: &config.Config{
  817. SaveLogs: config.SaveLogsConfig{
  818. ResultsDirectory: resultsDir,
  819. OutputDirectory: outputDir,
  820. },
  821. },
  822. Binding: &ActionBinding{
  823. Action: &config.Action{},
  824. },
  825. logEntry: &InternalLogEntry{
  826. ActionTitle: title,
  827. DatetimeStarted: started,
  828. ExecutionTrackingID: trackingID,
  829. Output: "report ok",
  830. },
  831. }
  832. assert.True(t, stepSaveLog(req))
  833. expectedBase := "Create_update Monthly Report.1714333384." + trackingID
  834. resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
  835. outputPath := filepath.Join(outputDir, expectedBase+".log")
  836. assert.FileExists(t, resultsPath)
  837. assert.FileExists(t, outputPath)
  838. resultsEntries, err := os.ReadDir(resultsDir)
  839. require.NoError(t, err)
  840. assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
  841. data, err := os.ReadFile(resultsPath)
  842. require.NoError(t, err)
  843. assert.Contains(t, string(data), title, "YAML content keeps the original action title")
  844. output, err := os.ReadFile(outputPath)
  845. require.NoError(t, err)
  846. assert.Equal(t, "report ok", string(output))
  847. }
  848. func TestStepSaveLogKeepsSafeTitleFilename(t *testing.T) {
  849. resultsDir := t.TempDir()
  850. started := time.Unix(1714333384, 0)
  851. trackingID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  852. req := &ExecutionRequest{
  853. Cfg: &config.Config{
  854. SaveLogs: config.SaveLogsConfig{
  855. ResultsDirectory: resultsDir,
  856. },
  857. },
  858. Binding: &ActionBinding{
  859. Action: &config.Action{},
  860. },
  861. logEntry: &InternalLogEntry{
  862. ActionTitle: "Echo Test",
  863. DatetimeStarted: started,
  864. ExecutionTrackingID: trackingID,
  865. },
  866. }
  867. assert.True(t, stepSaveLog(req))
  868. expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml")
  869. assert.FileExists(t, expectedPath)
  870. }
  871. func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
  872. resultsDir := t.TempDir()
  873. outputDir := t.TempDir()
  874. started := time.Unix(1714333384, 0)
  875. trackingID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
  876. title := "Bad\x00Title"
  877. req := &ExecutionRequest{
  878. Cfg: &config.Config{
  879. SaveLogs: config.SaveLogsConfig{
  880. ResultsDirectory: resultsDir,
  881. OutputDirectory: outputDir,
  882. },
  883. },
  884. Binding: &ActionBinding{
  885. Action: &config.Action{},
  886. },
  887. logEntry: &InternalLogEntry{
  888. ActionTitle: title,
  889. DatetimeStarted: started,
  890. ExecutionTrackingID: trackingID,
  891. Output: "nul ok",
  892. },
  893. }
  894. assert.True(t, stepSaveLog(req))
  895. expectedBase := "Bad_Title.1714333384." + trackingID
  896. resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
  897. outputPath := filepath.Join(outputDir, expectedBase+".log")
  898. assert.FileExists(t, resultsPath)
  899. assert.FileExists(t, outputPath)
  900. assert.NotContains(t, resultsPath, "\x00")
  901. assert.NotContains(t, outputPath, "\x00")
  902. output, err := os.ReadFile(outputPath)
  903. require.NoError(t, err)
  904. assert.Equal(t, "nul ok", string(output))
  905. }
  906. func TestBlockedExecutionPersistsSaveLogs(t *testing.T) {
  907. t.Parallel()
  908. resultsDir := t.TempDir()
  909. outputDir := t.TempDir()
  910. action := &config.Action{
  911. Title: "Blocked report",
  912. Shell: "sleep 1",
  913. MaxConcurrent: 1,
  914. SaveLogs: config.SaveLogsConfig{
  915. ResultsDirectory: resultsDir,
  916. OutputDirectory: outputDir,
  917. },
  918. }
  919. e, cfg := testGroupExecutor([]*config.Action{action}, nil)
  920. binding := e.FindBindingWithNoEntity(action)
  921. wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
  922. Binding: binding,
  923. Cfg: cfg,
  924. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  925. })
  926. waitUntilExecutionStarted(t, e, tracking1)
  927. wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
  928. Binding: binding,
  929. Cfg: cfg,
  930. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  931. })
  932. wg1.Wait()
  933. wg2.Wait()
  934. snapshot, ok := e.SnapshotLog(tracking2)
  935. require.True(t, ok)
  936. require.True(t, snapshot.Blocked)
  937. resultsEntries, err := os.ReadDir(resultsDir)
  938. require.NoError(t, err)
  939. var resultsPath string
  940. for _, entry := range resultsEntries {
  941. if strings.Contains(entry.Name(), tracking2) {
  942. resultsPath = filepath.Join(resultsDir, entry.Name())
  943. break
  944. }
  945. }
  946. require.NotEmpty(t, resultsPath)
  947. outputEntries, err := os.ReadDir(outputDir)
  948. require.NoError(t, err)
  949. var outputPath string
  950. for _, entry := range outputEntries {
  951. if strings.Contains(entry.Name(), tracking2) {
  952. outputPath = filepath.Join(outputDir, entry.Name())
  953. break
  954. }
  955. }
  956. require.NotEmpty(t, outputPath)
  957. resultsData, err := os.ReadFile(resultsPath)
  958. require.NoError(t, err)
  959. assert.Contains(t, string(resultsData), "blocked: true")
  960. assert.Contains(t, string(resultsData), tracking2)
  961. outputData, err := os.ReadFile(outputPath)
  962. require.NoError(t, err)
  963. assert.Contains(t, string(outputData), "Blocked from executing due to concurrency limit")
  964. }
  965. func TestStepSaveLogReturnsFalseWhenDependenciesMissing(t *testing.T) {
  966. started := time.Unix(1714333384, 0)
  967. valid := &ExecutionRequest{
  968. Cfg: &config.Config{},
  969. Binding: &ActionBinding{
  970. Action: &config.Action{},
  971. },
  972. logEntry: &InternalLogEntry{
  973. ActionTitle: "Echo",
  974. DatetimeStarted: started,
  975. ExecutionTrackingID: "cccccccc-dddd-eeee-ffff-000000000000",
  976. },
  977. }
  978. assert.False(t, stepSaveLog(nil))
  979. assert.False(t, stepSaveLog(&ExecutionRequest{}))
  980. missingLog := *valid
  981. missingLog.logEntry = nil
  982. assert.False(t, stepSaveLog(&missingLog))
  983. missingBinding := *valid
  984. missingBinding.Binding = nil
  985. assert.False(t, stepSaveLog(&missingBinding))
  986. missingAction := *valid
  987. missingAction.Binding = &ActionBinding{}
  988. assert.False(t, stepSaveLog(&missingAction))
  989. missingCfg := *valid
  990. missingCfg.Cfg = nil
  991. assert.False(t, stepSaveLog(&missingCfg))
  992. }
  993. func TestLogEntryOutputAvailableWhileRunning(t *testing.T) {
  994. cfg := config.DefaultConfig()
  995. e := DefaultExecutor(cfg)
  996. action := &config.Action{
  997. Title: "Slow output",
  998. Shell: "echo hello-mid-run; sleep 2",
  999. }
  1000. cfg.Actions = append(cfg.Actions, action)
  1001. cfg.Sanitize()
  1002. e.RebuildActionMap()
  1003. binding := e.FindBindingWithNoEntity(action)
  1004. require.NotNil(t, binding)
  1005. wg, trackingID := e.ExecRequest(&ExecutionRequest{
  1006. Binding: binding,
  1007. Cfg: cfg,
  1008. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  1009. })
  1010. var sawOutputWhileRunning bool
  1011. require.Eventually(t, func() bool {
  1012. snapshot, ok := e.SnapshotLog(trackingID)
  1013. if !ok {
  1014. return false
  1015. }
  1016. if snapshot.ExecutionFinished {
  1017. return false
  1018. }
  1019. if strings.Contains(snapshot.Output, "hello-mid-run") {
  1020. sawOutputWhileRunning = true
  1021. return true
  1022. }
  1023. return false
  1024. }, 2*time.Second, 10*time.Millisecond)
  1025. wg.Wait()
  1026. require.True(t, sawOutputWhileRunning, "expected Output to contain printed text before ExecutionFinished")
  1027. snapshot, ok := e.SnapshotLog(trackingID)
  1028. require.True(t, ok)
  1029. assert.True(t, snapshot.ExecutionFinished)
  1030. assert.Contains(t, snapshot.Output, "hello-mid-run")
  1031. }
  1032. func TestRebuildActionMapSkipsNilActions(t *testing.T) {
  1033. e, cfg := testingExecutor()
  1034. cfg.Actions = append(cfg.Actions, nil)
  1035. assert.NotPanics(t, e.RebuildActionMap)
  1036. assert.Len(t, e.MapActionBindings, 1)
  1037. }