package api import ( "context" "fmt" "testing" "connectrpc.com/connect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/executor" ) func TestInitIncludesEntitySearchHints(t *testing.T) { entities.ClearEntitiesOfType("server") entities.ClearEntitiesOfType("database") t.Cleanup(func() { entities.ClearEntitiesOfType("server") entities.ClearEntitiesOfType("database") }) entities.AddEntity("server", "0", map[string]any{ "name": "web01", "secret": "must-not-appear-in-search-hints", }) entities.AddEntity("database", "db-1", map[string]any{ "title": "postgres", }) cfg := config.DefaultConfig() cfg.Features.HeaderSearch = true cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor) defer testServer.Close() resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{})) require.NoError(t, err) require.NotNil(t, resp.Msg.Features) assert.True(t, resp.Msg.Features.HeaderSearch) require.NotNil(t, resp.Msg.SearchHints) byKey := map[string]*apiv1.EntitySearchHint{} for _, hint := range resp.Msg.SearchHints.Entities { byKey[hint.Type+":"+hint.UniqueKey] = hint } host, ok := byKey["server:0"] require.True(t, ok, "expected server:0 search hint") assert.Equal(t, "web01", host.Title) assert.Equal(t, "server", host.Type) assert.Equal(t, "0", host.UniqueKey) db, ok := byKey["database:db-1"] require.True(t, ok, "expected database:db-1 search hint") assert.Equal(t, "postgres", db.Title) } func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) { entities.ClearEntitiesOfType("server") t.Cleanup(func() { entities.ClearEntitiesOfType("server") }) entities.AddEntity("server", "0", map[string]any{"name": "web01"}) cfg := config.DefaultConfig() cfg.AuthRequireGuestsToLogin = true cfg.Features.HeaderSearch = true cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor) defer testServer.Close() resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{})) require.NoError(t, err) require.True(t, resp.Msg.LoginRequired) assert.Nil(t, resp.Msg.SearchHints) } func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) { entities.ClearEntitiesOfType("server") t.Cleanup(func() { entities.ClearEntitiesOfType("server") }) entities.AddEntity("server", "0", map[string]any{"name": "web01"}) cfg := config.DefaultConfig() cfg.Sanitize() require.False(t, cfg.Features.HeaderSearch) testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor) defer testServer.Close() resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{})) require.NoError(t, err) require.NotNil(t, resp.Msg.Features) assert.False(t, resp.Msg.Features.HeaderSearch) assert.Nil(t, resp.Msg.SearchHints) } func TestBuildSearchHintsRespectsActionACL(t *testing.T) { cfg := config.DefaultConfig() cfg.DefaultPermissions.View = false cfg.DefaultPermissions.Exec = false cfg.Actions = []*config.Action{ {ID: "public_action", Title: "Public Action", Shell: "echo public"}, {ID: "secret_action", Title: "Secret Action", Shell: "echo secret", Acls: []string{"admins"}}, } cfg.AccessControlLists = []*config.AccessControlList{ { Name: "everyone", MatchUsernames: []string{"guest", "admin"}, AddToEveryAction: false, Permissions: config.PermissionsList{View: true, Exec: true}, }, { Name: "admins", MatchUsernames: []string{"admin"}, Permissions: config.PermissionsList{View: true, Exec: true}, }, } cfg.Actions[0].Acls = []string{"everyone"} cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() api := newServer(testExecutor) guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} guest.BuildUserAcls(cfg) admin := &authpublic.AuthenticatedUser{Username: "admin"} admin.BuildUserAcls(cfg) guestHints := api.buildSearchHints(guest) require.NotNil(t, guestHints) guestActionIDs := actionHintBindingIDs(guestHints.Actions) assert.Contains(t, guestActionIDs, "public_action") assert.NotContains(t, guestActionIDs, "secret_action") adminHints := api.buildSearchHints(admin) require.NotNil(t, adminHints) adminActionIDs := actionHintBindingIDs(adminHints.Actions) assert.Contains(t, adminActionIDs, "public_action") assert.Contains(t, adminActionIDs, "secret_action") } func TestBuildSearchHintsOmitsHiddenActions(t *testing.T) { cfg := config.DefaultConfig() cfg.Actions = []*config.Action{ {ID: "visible", Title: "Visible", Shell: "echo visible"}, {ID: "hidden", Title: "Hidden", Shell: "echo hidden", Hidden: true}, } cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() api := newServer(testExecutor) user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} user.BuildUserAcls(cfg) hints := api.buildSearchHints(user) require.NotNil(t, hints) ids := actionHintBindingIDs(hints.Actions) assert.Contains(t, ids, "visible") assert.NotContains(t, ids, "hidden") } func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) { entities.ClearEntitiesOfType("cap_host") entities.ClearEntitiesOfType("cap_container") t.Cleanup(func() { entities.ClearEntitiesOfType("cap_host") entities.ClearEntitiesOfType("cap_container") }) for entityIndex := 0; entityIndex < maxSearchHintEntitiesPerType+5; entityIndex++ { entities.AddEntity("cap_host", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("host-%03d", entityIndex)}) entities.AddEntity("cap_container", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("ctr-%03d", entityIndex)}) } cfg := config.DefaultConfig() cfg.Entities = []*config.EntityFile{ {Name: "cap_host", File: "cap_host.yaml"}, {Name: "cap_container", File: "cap_container.yaml"}, } cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5) for actionIndex := 0; actionIndex < maxSearchHintActions+5; actionIndex++ { cfg.Actions = append(cfg.Actions, &config.Action{ ID: fmt.Sprintf("action-%03d", actionIndex), Title: fmt.Sprintf("Action %03d", actionIndex), Shell: "echo", }) } cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() api := newServer(testExecutor) user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} user.BuildUserAcls(cfg) hints := api.buildSearchHints(user) require.NotNil(t, hints) assert.Len(t, hints.Actions, maxSearchHintActions) assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_host")) assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_container")) } func countEntityHintsByType(hints []*apiv1.EntitySearchHint, entityType string) int { count := 0 for _, hint := range hints { if hint.Type == entityType { count++ } } return count } func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) { entities.ClearEntitiesOfType("host") t.Cleanup(func() { entities.ClearEntitiesOfType("host") }) for i := 0; i < 10; i++ { entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)}) } cfg := config.DefaultConfig() cfg.Actions = []*config.Action{ {ID: "plain", Title: "Plain Action", Shell: "echo plain"}, {Title: "Entity Action {{ name }}", Shell: "echo entity", Entity: "host"}, } cfg.Sanitize() testExecutor := executor.DefaultExecutor(cfg) testExecutor.RebuildActionMap() api := newServer(testExecutor) user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} user.BuildUserAcls(cfg) hints := api.buildSearchHints(user) require.NotNil(t, hints) require.NotEmpty(t, hints.Actions) assert.Equal(t, "plain", hints.Actions[0].BindingId) } func actionHintBindingIDs(hints []*apiv1.ActionSearchHint) []string { ids := make([]string, 0, len(hints)) for _, hint := range hints { ids = append(ids, hint.BindingId) } return ids }