api_init_search_hints_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. package api
  2. import (
  3. "context"
  4. "fmt"
  5. "testing"
  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. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  11. config "github.com/OliveTin/OliveTin/internal/config"
  12. "github.com/OliveTin/OliveTin/internal/entities"
  13. "github.com/OliveTin/OliveTin/internal/executor"
  14. )
  15. func TestInitIncludesEntitySearchHints(t *testing.T) {
  16. entities.ClearEntitiesOfType("server")
  17. entities.ClearEntitiesOfType("database")
  18. t.Cleanup(func() {
  19. entities.ClearEntitiesOfType("server")
  20. entities.ClearEntitiesOfType("database")
  21. })
  22. entities.AddEntity("server", "0", map[string]any{
  23. "name": "web01",
  24. "secret": "must-not-appear-in-search-hints",
  25. })
  26. entities.AddEntity("database", "db-1", map[string]any{
  27. "title": "postgres",
  28. })
  29. cfg := config.DefaultConfig()
  30. cfg.Features.HeaderSearch = true
  31. cfg.Sanitize()
  32. testExecutor := executor.DefaultExecutor(cfg)
  33. testExecutor.RebuildActionMap()
  34. testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
  35. defer testServer.Close()
  36. resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
  37. require.NoError(t, err)
  38. require.NotNil(t, resp.Msg.Features)
  39. assert.True(t, resp.Msg.Features.HeaderSearch)
  40. require.NotNil(t, resp.Msg.SearchHints)
  41. byKey := map[string]*apiv1.EntitySearchHint{}
  42. for _, hint := range resp.Msg.SearchHints.Entities {
  43. byKey[hint.Type+":"+hint.UniqueKey] = hint
  44. }
  45. host, ok := byKey["server:0"]
  46. require.True(t, ok, "expected server:0 search hint")
  47. assert.Equal(t, "web01", host.Title)
  48. assert.Equal(t, "server", host.Type)
  49. assert.Equal(t, "0", host.UniqueKey)
  50. db, ok := byKey["database:db-1"]
  51. require.True(t, ok, "expected database:db-1 search hint")
  52. assert.Equal(t, "postgres", db.Title)
  53. }
  54. func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) {
  55. entities.ClearEntitiesOfType("server")
  56. t.Cleanup(func() {
  57. entities.ClearEntitiesOfType("server")
  58. })
  59. entities.AddEntity("server", "0", map[string]any{"name": "web01"})
  60. cfg := config.DefaultConfig()
  61. cfg.AuthRequireGuestsToLogin = true
  62. cfg.Features.HeaderSearch = true
  63. cfg.Sanitize()
  64. testExecutor := executor.DefaultExecutor(cfg)
  65. testExecutor.RebuildActionMap()
  66. testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
  67. defer testServer.Close()
  68. resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
  69. require.NoError(t, err)
  70. require.True(t, resp.Msg.LoginRequired)
  71. assert.Nil(t, resp.Msg.SearchHints)
  72. }
  73. func TestInitHidesNavigationAndDashboardsWhenLoginRequired(t *testing.T) {
  74. cfg := config.DefaultConfig()
  75. cfg.AuthRequireGuestsToLogin = true
  76. cfg.ShowNavigation = true
  77. cfg.Actions = []*config.Action{
  78. {Title: "Ping", Shell: "echo ping"},
  79. }
  80. cfg.Dashboards = []*config.DashboardComponent{
  81. {
  82. Title: "Secret Board",
  83. Contents: []*config.DashboardComponent{
  84. {Title: "Ping"},
  85. },
  86. },
  87. }
  88. cfg.Sanitize()
  89. testExecutor := executor.DefaultExecutor(cfg)
  90. testExecutor.RebuildActionMap()
  91. testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
  92. defer testServer.Close()
  93. resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
  94. require.NoError(t, err)
  95. require.True(t, resp.Msg.LoginRequired)
  96. assert.False(t, resp.Msg.ShowNavigation)
  97. assert.Empty(t, resp.Msg.RootDashboards)
  98. assert.Empty(t, resp.Msg.RootDashboardEntries)
  99. }
  100. func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
  101. entities.ClearEntitiesOfType("server")
  102. t.Cleanup(func() {
  103. entities.ClearEntitiesOfType("server")
  104. })
  105. entities.AddEntity("server", "0", map[string]any{"name": "web01"})
  106. cfg := config.DefaultConfig()
  107. cfg.Sanitize()
  108. require.False(t, cfg.Features.HeaderSearch)
  109. testExecutor := executor.DefaultExecutor(cfg)
  110. testExecutor.RebuildActionMap()
  111. testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
  112. defer testServer.Close()
  113. resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
  114. require.NoError(t, err)
  115. require.NotNil(t, resp.Msg.Features)
  116. assert.False(t, resp.Msg.Features.HeaderSearch)
  117. assert.Nil(t, resp.Msg.SearchHints)
  118. }
  119. func TestBuildSearchHintsRespectsActionACL(t *testing.T) {
  120. cfg := config.DefaultConfig()
  121. cfg.DefaultPermissions.View = false
  122. cfg.DefaultPermissions.Exec = false
  123. cfg.Actions = []*config.Action{
  124. {ID: "public_action", Title: "Public Action", Shell: "echo public"},
  125. {ID: "secret_action", Title: "Secret Action", Shell: "echo secret", Acls: []string{"admins"}},
  126. }
  127. cfg.AccessControlLists = []*config.AccessControlList{
  128. {
  129. Name: "everyone",
  130. MatchUsernames: []string{"guest", "admin"},
  131. AddToEveryAction: false,
  132. Permissions: config.PermissionsList{View: true, Exec: true},
  133. },
  134. {
  135. Name: "admins",
  136. MatchUsernames: []string{"admin"},
  137. Permissions: config.PermissionsList{View: true, Exec: true},
  138. },
  139. }
  140. cfg.Actions[0].Acls = []string{"everyone"}
  141. cfg.Sanitize()
  142. testExecutor := executor.DefaultExecutor(cfg)
  143. testExecutor.RebuildActionMap()
  144. api := newServer(testExecutor)
  145. guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  146. guest.BuildUserAcls(cfg)
  147. admin := &authpublic.AuthenticatedUser{Username: "admin"}
  148. admin.BuildUserAcls(cfg)
  149. guestHints := api.buildSearchHints(guest)
  150. require.NotNil(t, guestHints)
  151. guestActionIDs := actionHintBindingIDs(guestHints.Actions)
  152. assert.Contains(t, guestActionIDs, "public_action")
  153. assert.NotContains(t, guestActionIDs, "secret_action")
  154. adminHints := api.buildSearchHints(admin)
  155. require.NotNil(t, adminHints)
  156. adminActionIDs := actionHintBindingIDs(adminHints.Actions)
  157. assert.Contains(t, adminActionIDs, "public_action")
  158. assert.Contains(t, adminActionIDs, "secret_action")
  159. }
  160. func TestBuildSearchHintsOmitsHiddenActions(t *testing.T) {
  161. cfg := config.DefaultConfig()
  162. cfg.Actions = []*config.Action{
  163. {ID: "visible", Title: "Visible", Shell: "echo visible"},
  164. {ID: "hidden", Title: "Hidden", Shell: "echo hidden", Hidden: true},
  165. }
  166. cfg.Sanitize()
  167. testExecutor := executor.DefaultExecutor(cfg)
  168. testExecutor.RebuildActionMap()
  169. api := newServer(testExecutor)
  170. user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  171. user.BuildUserAcls(cfg)
  172. hints := api.buildSearchHints(user)
  173. require.NotNil(t, hints)
  174. ids := actionHintBindingIDs(hints.Actions)
  175. assert.Contains(t, ids, "visible")
  176. assert.NotContains(t, ids, "hidden")
  177. }
  178. func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
  179. entities.ClearEntitiesOfType("cap_host")
  180. entities.ClearEntitiesOfType("cap_container")
  181. t.Cleanup(func() {
  182. entities.ClearEntitiesOfType("cap_host")
  183. entities.ClearEntitiesOfType("cap_container")
  184. })
  185. for entityIndex := range maxSearchHintEntitiesPerType + 5 {
  186. entities.AddEntity("cap_host", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("host-%03d", entityIndex)})
  187. entities.AddEntity("cap_container", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("ctr-%03d", entityIndex)})
  188. }
  189. cfg := config.DefaultConfig()
  190. cfg.Entities = []*config.EntityFile{
  191. {Name: "cap_host", File: "cap_host.yaml"},
  192. {Name: "cap_container", File: "cap_container.yaml"},
  193. }
  194. cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
  195. for actionIndex := range maxSearchHintActions + 5 {
  196. cfg.Actions = append(cfg.Actions, &config.Action{
  197. ID: fmt.Sprintf("action-%03d", actionIndex),
  198. Title: fmt.Sprintf("Action %03d", actionIndex),
  199. Shell: "echo",
  200. })
  201. }
  202. cfg.Sanitize()
  203. testExecutor := executor.DefaultExecutor(cfg)
  204. testExecutor.RebuildActionMap()
  205. api := newServer(testExecutor)
  206. user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  207. user.BuildUserAcls(cfg)
  208. hints := api.buildSearchHints(user)
  209. require.NotNil(t, hints)
  210. assert.Len(t, hints.Actions, maxSearchHintActions)
  211. assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_host"))
  212. assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_container"))
  213. }
  214. func countEntityHintsByType(hints []*apiv1.EntitySearchHint, entityType string) int {
  215. count := 0
  216. for _, hint := range hints {
  217. if hint.Type == entityType {
  218. count++
  219. }
  220. }
  221. return count
  222. }
  223. func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
  224. entities.ClearEntitiesOfType("host")
  225. t.Cleanup(func() {
  226. entities.ClearEntitiesOfType("host")
  227. })
  228. for i := range 10 {
  229. entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
  230. }
  231. cfg := config.DefaultConfig()
  232. cfg.Actions = []*config.Action{
  233. {ID: "plain", Title: "Plain Action", Shell: "echo plain"},
  234. {Title: "Entity Action {{ name }}", Shell: "echo entity", Entity: "host"},
  235. }
  236. cfg.Sanitize()
  237. testExecutor := executor.DefaultExecutor(cfg)
  238. testExecutor.RebuildActionMap()
  239. api := newServer(testExecutor)
  240. user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
  241. user.BuildUserAcls(cfg)
  242. hints := api.buildSearchHints(user)
  243. require.NotNil(t, hints)
  244. require.NotEmpty(t, hints.Actions)
  245. assert.Equal(t, "plain", hints.Actions[0].BindingId)
  246. }
  247. func actionHintBindingIDs(hints []*apiv1.ActionSearchHint) []string {
  248. ids := make([]string, 0, len(hints))
  249. for _, hint := range hints {
  250. ids = append(ids, hint.BindingId)
  251. }
  252. return ids
  253. }