api_log_arguments_test.go 11 KB

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