api_log_arguments_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. package api
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "connectrpc.com/connect"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  10. config "github.com/OliveTin/OliveTin/internal/config"
  11. "github.com/OliveTin/OliveTin/internal/executor"
  12. )
  13. func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
  14. return &config.Action{
  15. Title: title,
  16. Shell: shell,
  17. MaxConcurrent: 1,
  18. Arguments: args,
  19. }
  20. }
  21. func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string) map[string]string {
  22. t.Helper()
  23. deadline := time.Now().Add(2 * time.Second)
  24. for time.Now().Before(deadline) {
  25. entry, ok := ex.GetLog(trackingID)
  26. if ok && entry.Arguments != nil {
  27. return entry.Arguments
  28. }
  29. time.Sleep(5 * time.Millisecond)
  30. }
  31. t.Fatalf("timed out waiting for arguments on log %s", trackingID)
  32. return nil
  33. }
  34. func waitForLogFinished(t *testing.T, ex *executor.Executor, trackingID string) {
  35. t.Helper()
  36. deadline := time.Now().Add(2 * time.Second)
  37. for time.Now().Before(deadline) {
  38. entry, ok := ex.GetLog(trackingID)
  39. if ok && entry.ExecutionFinished {
  40. return
  41. }
  42. time.Sleep(5 * time.Millisecond)
  43. }
  44. t.Fatalf("timed out waiting for execution to finish on log %s", trackingID)
  45. }
  46. func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
  47. t.Helper()
  48. deadline := time.Now().Add(2 * time.Second)
  49. for time.Now().Before(deadline) {
  50. entry, ok := ex.GetLog(trackingID)
  51. if ok && entry.Justification == expected {
  52. return
  53. }
  54. time.Sleep(5 * time.Millisecond)
  55. }
  56. t.Fatalf("timed out waiting for justification %q on log %s", expected, trackingID)
  57. }
  58. func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
  59. cfg := config.DefaultConfig()
  60. cfg.Actions = []*config.Action{
  61. argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
  62. {Name: "host", Type: "ascii_identifier"},
  63. }),
  64. }
  65. ex := executor.DefaultExecutor(cfg)
  66. ex.RebuildActionMap()
  67. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  68. require.NotNil(t, binding)
  69. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  70. defer ts.Close()
  71. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  72. BindingId: binding.ID,
  73. Arguments: []*apiv1.StartActionArgument{
  74. {Name: "host", Value: "example.com"},
  75. },
  76. }))
  77. require.NoError(t, err)
  78. waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
  79. statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
  80. ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
  81. }))
  82. require.NoError(t, err)
  83. require.NotNil(t, statusResp.Msg.LogEntry)
  84. require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
  85. assert.Equal(t, "host", statusResp.Msg.LogEntry.Arguments[0].Name)
  86. assert.Equal(t, "example.com", statusResp.Msg.LogEntry.Arguments[0].Value)
  87. }
  88. func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
  89. cfg := config.DefaultConfig()
  90. cfg.Actions = []*config.Action{
  91. {
  92. Title: "Connect",
  93. Exec: []string{"echo", "{{ user }}"},
  94. MaxConcurrent: 1,
  95. Arguments: []config.ActionArgument{
  96. {Name: "user", Type: "ascii_identifier"},
  97. {Name: "pass", Type: "password"},
  98. },
  99. },
  100. }
  101. ex := executor.DefaultExecutor(cfg)
  102. ex.RebuildActionMap()
  103. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  104. require.NotNil(t, binding)
  105. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  106. defer ts.Close()
  107. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  108. BindingId: binding.ID,
  109. Arguments: []*apiv1.StartActionArgument{
  110. {Name: "user", Value: "alice"},
  111. {Name: "pass", Value: "secret"},
  112. },
  113. }))
  114. require.NoError(t, err)
  115. waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
  116. statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
  117. ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
  118. }))
  119. require.NoError(t, err)
  120. require.NotNil(t, statusResp.Msg.LogEntry)
  121. for _, arg := range statusResp.Msg.LogEntry.Arguments {
  122. assert.NotEqual(t, "pass", arg.Name)
  123. }
  124. require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
  125. assert.Equal(t, "user", statusResp.Msg.LogEntry.Arguments[0].Name)
  126. assert.Equal(t, "alice", statusResp.Msg.LogEntry.Arguments[0].Value)
  127. }
  128. func TestRestartActionReusesStoredArguments(t *testing.T) {
  129. cfg := config.DefaultConfig()
  130. cfg.Actions = []*config.Action{
  131. argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
  132. {Name: "host", Type: "ascii_identifier"},
  133. }),
  134. }
  135. ex := executor.DefaultExecutor(cfg)
  136. ex.RebuildActionMap()
  137. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  138. require.NotNil(t, binding)
  139. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  140. defer ts.Close()
  141. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  142. BindingId: binding.ID,
  143. Arguments: []*apiv1.StartActionArgument{
  144. {Name: "host", Value: "server-a"},
  145. },
  146. }))
  147. require.NoError(t, err)
  148. originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
  149. assert.Equal(t, "server-a", originalArgs["host"])
  150. waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
  151. restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
  152. ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
  153. }))
  154. require.NoError(t, err)
  155. require.NotEmpty(t, restartResp.Msg.ExecutionTrackingId)
  156. assert.NotEqual(t, startResp.Msg.ExecutionTrackingId, restartResp.Msg.ExecutionTrackingId)
  157. restartedArgs := waitForLogArguments(t, ex, restartResp.Msg.ExecutionTrackingId)
  158. assert.Equal(t, "server-a", restartedArgs["host"])
  159. }
  160. func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
  161. cfg := config.DefaultConfig()
  162. cfg.Actions = []*config.Action{
  163. {
  164. Title: "Connect",
  165. Exec: []string{"echo", "{{ user }}"},
  166. MaxConcurrent: 1,
  167. Arguments: []config.ActionArgument{
  168. {Name: "user", Type: "ascii_identifier"},
  169. {Name: "pass", Type: "password"},
  170. },
  171. },
  172. }
  173. ex := executor.DefaultExecutor(cfg)
  174. ex.RebuildActionMap()
  175. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  176. require.NotNil(t, binding)
  177. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  178. defer ts.Close()
  179. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  180. BindingId: binding.ID,
  181. Arguments: []*apiv1.StartActionArgument{
  182. {Name: "user", Value: "alice"},
  183. {Name: "pass", Value: "secret"},
  184. },
  185. }))
  186. require.NoError(t, err)
  187. _, err = client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
  188. ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
  189. }))
  190. require.Error(t, err)
  191. assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
  192. }
  193. func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
  194. cfg := config.DefaultConfig()
  195. cfg.Actions = []*config.Action{
  196. argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
  197. {Name: "host", Type: "ascii_identifier"},
  198. }),
  199. }
  200. ex := executor.DefaultExecutor(cfg)
  201. ex.RebuildActionMap()
  202. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  203. require.NotNil(t, binding)
  204. trackingID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  205. ex.SetLog(trackingID, &executor.InternalLogEntry{
  206. Binding: binding,
  207. ExecutionFinished: true,
  208. ExecutionTrackingID: trackingID,
  209. Arguments: map[string]string{},
  210. })
  211. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  212. defer ts.Close()
  213. _, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
  214. ExecutionTrackingId: trackingID,
  215. }))
  216. require.Error(t, err)
  217. assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
  218. }
  219. func TestLogEntryArgumentsToProto(t *testing.T) {
  220. assert.Nil(t, logEntryArgumentsToProto(nil))
  221. assert.Nil(t, logEntryArgumentsToProto(map[string]string{}))
  222. out := logEntryArgumentsToProto(map[string]string{
  223. "host": "example.com",
  224. "port": "443",
  225. })
  226. require.Len(t, out, 2)
  227. values := map[string]string{}
  228. for _, arg := range out {
  229. values[arg.Name] = arg.Value
  230. }
  231. assert.Equal(t, "example.com", values["host"])
  232. assert.Equal(t, "443", values["port"])
  233. }
  234. func TestCopyStringMap(t *testing.T) {
  235. source := map[string]string{"host": "example.com"}
  236. copied := copyStringMap(source)
  237. assert.Equal(t, source, copied)
  238. source["host"] = "changed"
  239. assert.Equal(t, "example.com", copied["host"])
  240. empty := copyStringMap(nil)
  241. assert.NotNil(t, empty)
  242. assert.Empty(t, empty)
  243. }
  244. func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T) {
  245. cfg := config.DefaultConfig()
  246. cfg.Actions = []*config.Action{
  247. {
  248. Title: "Dangerous action",
  249. Shell: "echo ok",
  250. MaxConcurrent: 1,
  251. Justification: config.JustificationRequiredNoTemplate,
  252. },
  253. }
  254. ex := executor.DefaultExecutor(cfg)
  255. ex.RebuildActionMap()
  256. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  257. require.NotNil(t, binding)
  258. trackingID := "manual-log-without-justification"
  259. ex.SetLog(trackingID, &executor.InternalLogEntry{
  260. Binding: binding,
  261. ExecutionFinished: true,
  262. ExecutionTrackingID: trackingID,
  263. })
  264. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  265. defer ts.Close()
  266. _, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
  267. ExecutionTrackingId: trackingID,
  268. }))
  269. require.Error(t, err)
  270. assert.Contains(t, err.Error(), "justification")
  271. }
  272. func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T) {
  273. cfg := config.DefaultConfig()
  274. cfg.Actions = []*config.Action{
  275. {
  276. Title: "Dangerous action",
  277. Shell: "echo ok",
  278. MaxConcurrent: 1,
  279. Justification: config.JustificationRequiredNoTemplate,
  280. },
  281. }
  282. ex := executor.DefaultExecutor(cfg)
  283. ex.RebuildActionMap()
  284. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  285. require.NotNil(t, binding)
  286. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  287. defer ts.Close()
  288. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  289. BindingId: binding.ID,
  290. Justification: "maintenance window",
  291. }))
  292. require.NoError(t, err)
  293. waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
  294. waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
  295. restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
  296. ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
  297. }))
  298. require.NoError(t, err)
  299. waitForLogJustification(t, ex, restartResp.Msg.ExecutionTrackingId, "maintenance window")
  300. }
  301. func TestGetLogsIncludesStoredArguments(t *testing.T) {
  302. cfg := config.DefaultConfig()
  303. cfg.Actions = []*config.Action{
  304. argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
  305. {Name: "host", Type: "ascii_identifier"},
  306. }),
  307. }
  308. ex := executor.DefaultExecutor(cfg)
  309. ex.RebuildActionMap()
  310. binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
  311. require.NotNil(t, binding)
  312. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  313. defer ts.Close()
  314. startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  315. BindingId: binding.ID,
  316. Arguments: []*apiv1.StartActionArgument{
  317. {Name: "host", Value: "db-1"},
  318. },
  319. }))
  320. require.NoError(t, err)
  321. require.NotEmpty(t, startResp.Msg.ExecutionTrackingId)
  322. waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
  323. logsResp, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{}))
  324. require.NoError(t, err)
  325. require.NotEmpty(t, logsResp.Msg.Logs)
  326. var matched bool
  327. for _, entry := range logsResp.Msg.Logs {
  328. if entry.ExecutionTrackingId != startResp.Msg.ExecutionTrackingId {
  329. continue
  330. }
  331. matched = true
  332. require.Len(t, entry.Arguments, 1)
  333. assert.Equal(t, "host", entry.Arguments[0].Name)
  334. assert.Equal(t, "db-1", entry.Arguments[0].Value)
  335. }
  336. assert.True(t, matched, "expected log entry with stored arguments")
  337. }