api_log_arguments_test.go 11 KB

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