package api import ( "context" "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 setupHostEntityTestData(t *testing.T) { t.Helper() entities.ClearEntitiesOfType("host") entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"}) entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"}) t.Cleanup(func() { entities.ClearEntitiesOfType("host") }) } func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) { t.Helper() cfg := config.DefaultConfig() cfg.DefaultPermissions.View = false cfg.DefaultPermissions.Exec = false cfg.Actions = append(cfg.Actions, &config.Action{ Title: "Secret Entity Action", Shell: "echo secret", Entity: "host", }, &config.Action{ Title: "Hidden Host Action", Shell: "echo hidden", Entity: "host", Hidden: true, }, &config.Action{ ID: "run_playbook", Title: "Run Automation Playbook", Shell: "host '{{ ansible_host }}'", Arguments: []config.ActionArgument{ { Name: "ansible_host", Title: "Host", Entity: "host", Choices: []config.ActionArgumentChoice{ {Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"}, }, }, }, }, &config.Action{ Title: "Public Host Action", Shell: "echo public", Entity: "host", }, ) cfg.AccessControlLists = append(cfg.AccessControlLists, &config.AccessControlList{ Name: "restricted", MatchUsernames: []string{"low"}, AddToEveryAction: true, Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false}, }, &config.AccessControlList{ Name: "full", MatchUsernames: []string{"admin"}, AddToEveryAction: true, Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true}, }, ) cfg.Entities = []*config.EntityFile{ {File: "hosts.yaml", Name: "host", Icon: "ssh"}, } cfg.Sanitize() lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}} adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}} return cfg, lowUser, adminUser } func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string { t.Helper() entity, ok := entities.GetEntityInstances(entityType)[entityKey] require.True(t, ok, "entity %s/%s must exist", entityType, entityKey) related := api.relatedActionsForEntity(user, entityType, entity) titles := make([]string, 0, len(related)) for _, item := range related { if item.Action != nil { titles = append(titles, item.Action.Title) } } return titles } func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string { t.Helper() entity, ok := entities.GetEntityInstances(entityType)[entityKey] require.True(t, ok, "entity %s/%s must exist", entityType, entityKey) related := api.relatedActionsForEntity(user, entityType, entity) ids := make([]string, 0, len(related)) for _, item := range related { if item.Action != nil { ids = append(ids, item.Action.BindingId) } } return ids } func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) { setupHostEntityTestData(t) cfg, lowUser, _ := buildRelatedActionsTestConfig(t) ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() api := newServer(ex) ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0") assert.Empty(t, ids) titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0") assert.Empty(t, titles) } func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) { setupHostEntityTestData(t) cfg, _, adminUser := buildRelatedActionsTestConfig(t) ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() api := newServer(ex) titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0") assert.Contains(t, titles, "Run Automation Playbook") assert.Contains(t, titles, "Public Host Action") assert.Contains(t, titles, "Secret Entity Action") } func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) { setupHostEntityTestData(t) cfg, _, adminUser := buildRelatedActionsTestConfig(t) ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() api := newServer(ex) titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0") assert.NotContains(t, titles, "Hidden Host Action") } func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) { setupHostEntityTestData(t) cfg := config.DefaultConfig() cfg.Actions = append(cfg.Actions, &config.Action{ Title: "{{ host.name }} Wake", Shell: "echo wake", Entity: "host", }) ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() api := newServer(ex) user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0") assert.Len(t, titlesHost0, 1) assert.Equal(t, "stuffbox Wake", titlesHost0[0]) titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1") assert.Len(t, titlesHost1, 1) assert.Equal(t, "lurker Wake", titlesHost1[0]) } func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) { setupHostEntityTestData(t) cfg := config.DefaultConfig() cfg.Actions = append(cfg.Actions, &config.Action{ ID: "run_playbook", Title: "Run Automation Playbook", Shell: "host '{{ ansible_host }}'", Arguments: []config.ActionArgument{ { Name: "ansible_host", Title: "Host", Entity: "host", Choices: []config.ActionArgumentChoice{ {Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"}, }, }, }, }) ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() api := newServer(ex) user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"} entity := entities.GetEntityInstances("host")["0"] related := api.relatedActionsForEntity(user, "host", entity) require.Len(t, related, 1) require.NotNil(t, related[0].Action) assert.Equal(t, "run_playbook", related[0].Action.BindingId) assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"]) } func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) { setupHostEntityTestData(t) cfg := config.DefaultConfig() cfg.AuthRequireGuestsToLogin = true ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() ts, client := getNewTestServerAndClientWithExecutor(cfg, ex) defer ts.Close() _, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{ Type: "host", UniqueKey: "0", })) require.Error(t, err) assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) } func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) { setupHostEntityTestData(t) cfg, _, adminUser := buildRelatedActionsTestConfig(t) cfg.AuthHttpHeaderUsername = "X-Ot-User" ex := executor.DefaultExecutor(cfg) ex.RebuildActionMap() ts, client := getNewTestServerAndClientWithExecutor(cfg, ex) defer ts.Close() req := connect.NewRequest(&apiv1.GetEntityRequest{ Type: "host", UniqueKey: "0", }) req.Header().Set("X-Ot-User", adminUser.Username) resp, err := client.GetEntity(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp.Msg) assert.Equal(t, "🔐", resp.Msg.Icon) foundPlaybook := false for _, related := range resp.Msg.RelatedActions { if related.Action != nil && related.Action.BindingId == "run_playbook" { foundPlaybook = true assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"]) } } assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response") }