api_entity_related_actions_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package api
  2. import (
  3. "context"
  4. "testing"
  5. "connectrpc.com/connect"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  9. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  10. config "github.com/OliveTin/OliveTin/internal/config"
  11. "github.com/OliveTin/OliveTin/internal/entities"
  12. "github.com/OliveTin/OliveTin/internal/executor"
  13. )
  14. func setupHostEntityTestData(t *testing.T) {
  15. t.Helper()
  16. entities.ClearEntitiesOfType("host")
  17. entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
  18. entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
  19. t.Cleanup(func() {
  20. entities.ClearEntitiesOfType("host")
  21. })
  22. }
  23. func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
  24. t.Helper()
  25. cfg := config.DefaultConfig()
  26. cfg.DefaultPermissions.View = false
  27. cfg.DefaultPermissions.Exec = false
  28. cfg.Actions = append(cfg.Actions,
  29. &config.Action{
  30. Title: "Secret Entity Action",
  31. Shell: "echo secret",
  32. Entity: "host",
  33. },
  34. &config.Action{
  35. Title: "Hidden Host Action",
  36. Shell: "echo hidden",
  37. Entity: "host",
  38. Hidden: true,
  39. },
  40. &config.Action{
  41. ID: "run_playbook",
  42. Title: "Run Automation Playbook",
  43. Shell: "host '{{ ansible_host }}'",
  44. Arguments: []config.ActionArgument{
  45. {
  46. Name: "ansible_host",
  47. Title: "Host",
  48. Entity: "host",
  49. Choices: []config.ActionArgumentChoice{
  50. {Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
  51. },
  52. },
  53. },
  54. },
  55. &config.Action{
  56. Title: "Public Host Action",
  57. Shell: "echo public",
  58. Entity: "host",
  59. },
  60. )
  61. cfg.AccessControlLists = append(cfg.AccessControlLists,
  62. &config.AccessControlList{
  63. Name: "restricted",
  64. MatchUsernames: []string{"low"},
  65. AddToEveryAction: true,
  66. Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
  67. },
  68. &config.AccessControlList{
  69. Name: "full",
  70. MatchUsernames: []string{"admin"},
  71. AddToEveryAction: true,
  72. Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
  73. },
  74. )
  75. cfg.Entities = []*config.EntityFile{
  76. {File: "hosts.yaml", Name: "host", Icon: "ssh"},
  77. }
  78. cfg.Sanitize()
  79. lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
  80. adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
  81. return cfg, lowUser, adminUser
  82. }
  83. func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
  84. t.Helper()
  85. entity, ok := entities.GetEntityInstances(entityType)[entityKey]
  86. require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
  87. related := api.relatedActionsForEntity(user, entityType, entity)
  88. titles := make([]string, 0, len(related))
  89. for _, item := range related {
  90. if item.Action != nil {
  91. titles = append(titles, item.Action.Title)
  92. }
  93. }
  94. return titles
  95. }
  96. func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
  97. t.Helper()
  98. entity, ok := entities.GetEntityInstances(entityType)[entityKey]
  99. require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
  100. related := api.relatedActionsForEntity(user, entityType, entity)
  101. ids := make([]string, 0, len(related))
  102. for _, item := range related {
  103. if item.Action != nil {
  104. ids = append(ids, item.Action.BindingId)
  105. }
  106. }
  107. return ids
  108. }
  109. func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
  110. setupHostEntityTestData(t)
  111. cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
  112. ex := executor.DefaultExecutor(cfg)
  113. ex.RebuildActionMap()
  114. api := newServer(ex)
  115. ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
  116. assert.Empty(t, ids)
  117. titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
  118. assert.Empty(t, titles)
  119. }
  120. func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
  121. setupHostEntityTestData(t)
  122. cfg, _, adminUser := buildRelatedActionsTestConfig(t)
  123. ex := executor.DefaultExecutor(cfg)
  124. ex.RebuildActionMap()
  125. api := newServer(ex)
  126. titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
  127. assert.Contains(t, titles, "Run Automation Playbook")
  128. assert.Contains(t, titles, "Public Host Action")
  129. assert.Contains(t, titles, "Secret Entity Action")
  130. }
  131. func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
  132. setupHostEntityTestData(t)
  133. cfg, _, adminUser := buildRelatedActionsTestConfig(t)
  134. ex := executor.DefaultExecutor(cfg)
  135. ex.RebuildActionMap()
  136. api := newServer(ex)
  137. titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
  138. assert.NotContains(t, titles, "Hidden Host Action")
  139. }
  140. func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
  141. setupHostEntityTestData(t)
  142. cfg := config.DefaultConfig()
  143. cfg.Actions = append(cfg.Actions, &config.Action{
  144. Title: "{{ host.name }} Wake",
  145. Shell: "echo wake",
  146. Entity: "host",
  147. })
  148. ex := executor.DefaultExecutor(cfg)
  149. ex.RebuildActionMap()
  150. api := newServer(ex)
  151. user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  152. titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
  153. assert.Len(t, titlesHost0, 1)
  154. assert.Equal(t, "stuffbox Wake", titlesHost0[0])
  155. titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
  156. assert.Len(t, titlesHost1, 1)
  157. assert.Equal(t, "lurker Wake", titlesHost1[0])
  158. }
  159. func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
  160. setupHostEntityTestData(t)
  161. cfg := config.DefaultConfig()
  162. cfg.Actions = append(cfg.Actions, &config.Action{
  163. ID: "run_playbook",
  164. Title: "Run Automation Playbook",
  165. Shell: "host '{{ ansible_host }}'",
  166. Arguments: []config.ActionArgument{
  167. {
  168. Name: "ansible_host",
  169. Title: "Host",
  170. Entity: "host",
  171. Choices: []config.ActionArgumentChoice{
  172. {Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
  173. },
  174. },
  175. },
  176. })
  177. ex := executor.DefaultExecutor(cfg)
  178. ex.RebuildActionMap()
  179. api := newServer(ex)
  180. user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  181. entity := entities.GetEntityInstances("host")["0"]
  182. related := api.relatedActionsForEntity(user, "host", entity)
  183. require.Len(t, related, 1)
  184. require.NotNil(t, related[0].Action)
  185. assert.Equal(t, "run_playbook", related[0].Action.BindingId)
  186. assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
  187. }
  188. func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
  189. setupHostEntityTestData(t)
  190. cfg := config.DefaultConfig()
  191. cfg.AuthRequireGuestsToLogin = true
  192. ex := executor.DefaultExecutor(cfg)
  193. ex.RebuildActionMap()
  194. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  195. defer ts.Close()
  196. _, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
  197. Type: "host",
  198. UniqueKey: "0",
  199. }))
  200. require.Error(t, err)
  201. assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
  202. }
  203. func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
  204. setupHostEntityTestData(t)
  205. cfg, _, adminUser := buildRelatedActionsTestConfig(t)
  206. cfg.AuthHttpHeaderUsername = "X-Ot-User"
  207. ex := executor.DefaultExecutor(cfg)
  208. ex.RebuildActionMap()
  209. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  210. defer ts.Close()
  211. req := connect.NewRequest(&apiv1.GetEntityRequest{
  212. Type: "host",
  213. UniqueKey: "0",
  214. })
  215. req.Header().Set("X-Ot-User", adminUser.Username)
  216. resp, err := client.GetEntity(context.Background(), req)
  217. require.NoError(t, err)
  218. require.NotNil(t, resp.Msg)
  219. assert.Equal(t, "🔐", resp.Msg.Icon)
  220. foundPlaybook := false
  221. for _, related := range resp.Msg.RelatedActions {
  222. if related.Action != nil && related.Action.BindingId == "run_playbook" {
  223. foundPlaybook = true
  224. assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
  225. }
  226. }
  227. assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
  228. }