diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 1f64879d317..ebb3da684f6 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -86,6 +86,7 @@ export interface PluginMeta { secureJsonData?: KeyValue; secureJsonFields?: KeyValue; enabled?: boolean; + autoEnabled?: boolean; defaultNavUrl?: string; hasUpdate?: boolean; enterprise?: boolean; diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 703178e7000..b2773a70f0e 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -12,6 +12,7 @@ type PluginSetting struct { Id string `json:"id"` Enabled bool `json:"enabled"` Pinned bool `json:"pinned"` + AutoEnabled bool `json:"autoEnabled"` Module string `json:"module"` BaseUrl string `json:"baseUrl"` Info plugins.Info `json:"info"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index d9fe3b73822..2d45da7017d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -721,6 +721,7 @@ func (hs *HTTPServer) pluginSettings(ctx context.Context, orgID int64) (map[stri OrgID: orgID, Enabled: plugin.AutoEnabled, Pinned: plugin.AutoEnabled, + AutoEnabled: plugin.AutoEnabled, PluginVersion: plugin.Info.Version, } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index fffadca6a2d..23178dc700a 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -216,6 +216,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. if plugin.IsApp() { dto.Enabled = plugin.AutoEnabled dto.Pinned = plugin.AutoEnabled + dto.AutoEnabled = plugin.AutoEnabled } ps, err := hs.PluginSettings.GetPluginSettingByPluginID(c.Req.Context(), &pluginsettings.GetByPluginIDArgs{ @@ -254,9 +255,13 @@ func (hs *HTTPServer) UpdatePluginSetting(c *contextmodel.ReqContext) response.R } pluginID := web.Params(c.Req)[":pluginId"] - if _, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID); !exists { + p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) + if !exists { return response.Error(http.StatusNotFound, "Plugin not installed", nil) } + if p.AutoEnabled && !cmd.Enabled { + return response.Error(http.StatusBadRequest, "Cannot disable auto-enabled plugin", nil) + } cmd.OrgId = c.SignedInUser.GetOrgID() cmd.PluginId = pluginID diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 7cb30bd428d..c84e1fe4787 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -871,3 +871,43 @@ func Test_PluginsSettings(t *testing.T) { }) } } + +func Test_UpdatePluginSetting(t *testing.T) { + pID := "test-app" + p1 := createPlugin(plugins.JSONData{ + ID: pID, Type: "app", Name: pID, + Info: plugins.Info{ + Version: "1.0.0", + }, + AutoEnabled: true, + }, plugins.ClassExternal, plugins.NewFakeFS(), + ) + pluginRegistry := &fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + }, + } + + pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{ + pID: {ID: 0, OrgID: 1, PluginID: pID, PluginVersion: "1.0.0", Enabled: true}, + }} + + t.Run("should return an error when trying to disable an auto-enabled plugin", func(t *testing.T) { + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = setting.NewCfg() + hs.PluginSettings = &pluginSettings + hs.pluginStore = pluginstore.New(pluginRegistry, &fakes.FakeLoader{}) + hs.pluginFileStore = filestore.ProvideService(pluginRegistry) + hs.managedPluginsService = managedplugins.NewNoop() + hs.log = log.NewNopLogger() + }) + + input := strings.NewReader(`{"enabled": false}`) + endpoint := fmt.Sprintf("/api/plugins/%s/settings", pID) + req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(1, []ac.Permission{{Action: pluginaccesscontrol.ActionWrite, Scope: "plugins:id:test-app"}})) + res, err := server.SendJSON(req) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) +} diff --git a/pkg/services/pluginsintegration/pluginsettings/models.go b/pkg/services/pluginsintegration/pluginsettings/models.go index 230a3faeddf..762c8c247ee 100644 --- a/pkg/services/pluginsintegration/pluginsettings/models.go +++ b/pkg/services/pluginsintegration/pluginsettings/models.go @@ -27,6 +27,7 @@ type InfoDTO struct { Enabled bool Pinned bool PluginVersion string + AutoEnabled bool } type UpdateArgs struct { diff --git a/pkg/services/provisioning/plugins/plugin_provisioner.go b/pkg/services/provisioning/plugins/plugin_provisioner.go index 83d334a573b..79a859cb360 100644 --- a/pkg/services/provisioning/plugins/plugin_provisioner.go +++ b/pkg/services/provisioning/plugins/plugin_provisioner.go @@ -19,6 +19,7 @@ func Provision(ctx context.Context, configDirectory string, pluginStore pluginst cfgProvider: newConfigReader(logger, pluginStore), pluginSettings: pluginSettings, orgService: orgService, + pluginStore: pluginStore, } return ap.applyChanges(ctx, configDirectory) } @@ -30,6 +31,7 @@ type PluginProvisioner struct { cfgProvider configReader pluginSettings pluginsettings.Service orgService org.Service + pluginStore pluginstore.Store } func (ap *PluginProvisioner) apply(ctx context.Context, cfg *pluginsAsConfig) error { @@ -45,6 +47,14 @@ func (ap *PluginProvisioner) apply(ctx context.Context, cfg *pluginsAsConfig) er app.OrgID = 1 } + p, found := ap.pluginStore.Plugin(ctx, app.PluginID) + if !found { + return errors.New("plugin not found") + } + if p.AutoEnabled && !app.Enabled { + return errors.New("plugin is auto enabled and cannot be disabled") + } + ps, err := ap.pluginSettings.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{ OrgID: app.OrgID, PluginID: app.PluginID, diff --git a/pkg/services/provisioning/plugins/plugin_provisioner_test.go b/pkg/services/provisioning/plugins/plugin_provisioner_test.go index 187762b9c28..a402cc44bd0 100644 --- a/pkg/services/provisioning/plugins/plugin_provisioner_test.go +++ b/pkg/services/provisioning/plugins/plugin_provisioner_test.go @@ -8,9 +8,11 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) func TestPluginProvisioner(t *testing.T) { @@ -37,7 +39,16 @@ func TestPluginProvisioner(t *testing.T) { store := &mockStore{} orgMock := orgtest.NewOrgServiceFake() orgMock.ExpectedOrg = &org.Org{ID: 4} - ap := PluginProvisioner{log: log.New("test"), cfgProvider: reader, pluginSettings: store, orgService: orgMock} + ap := PluginProvisioner{ + log: log.New("test"), + cfgProvider: reader, + pluginSettings: store, + orgService: orgMock, + pluginStore: pluginstore.NewFakePluginStore( + pluginstore.Plugin{JSONData: plugins.JSONData{ID: "test-plugin"}}, + pluginstore.Plugin{JSONData: plugins.JSONData{ID: "test-plugin-2"}}, + ), + } err := ap.applyChanges(context.Background(), "") require.NoError(t, err) @@ -68,6 +79,30 @@ func TestPluginProvisioner(t *testing.T) { require.Equal(t, tc.ExpectedSecureJSONData, cmd.SecureJSONData) } }) + + t.Run("Should return error trying to disable an auto-enabled plugin", func(t *testing.T) { + cfg := []*pluginsAsConfig{ + { + Apps: []*appFromConfig{ + {PluginID: "test-plugin", OrgID: 2, Enabled: false}, + }, + }, + } + reader := &testConfigReader{result: cfg} + store := &mockStore{} + ap := PluginProvisioner{ + log: log.New("test"), + cfgProvider: reader, + pluginSettings: store, + pluginStore: pluginstore.NewFakePluginStore( + pluginstore.Plugin{JSONData: plugins.JSONData{ID: "test-plugin", AutoEnabled: true}}, + ), + } + + err := ap.applyChanges(context.Background(), "") + require.Error(t, err) + require.Contains(t, err.Error(), "plugin is auto enabled and cannot be disabled") + }) } type testConfigReader struct { diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.test.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.test.tsx new file mode 100644 index 00000000000..d544568c61c --- /dev/null +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; + +import { PluginSignatureStatus } from '@grafana/data'; +import { contextSrv } from 'app/core/core'; + +import { usePluginConfig } from '../../hooks/usePluginConfig'; +import { CatalogPlugin } from '../../types'; + +import { GetStartedWithApp } from './GetStartedWithApp'; + +jest.mock('app/core/core', () => ({ + contextSrv: { + hasPermission: jest.fn(), + }, +})); + +jest.mock('../../api', () => ({ + updatePluginSettings: jest.fn(), +})); + +jest.mock('../../hooks/usePluginConfig', () => ({ + usePluginConfig: jest.fn(), +})); + +const mockPlugin: CatalogPlugin = { + id: 'test-plugin', + name: 'Test Plugin', + description: 'Test Plugin Description', + downloads: 0, + hasUpdate: false, + info: { + logos: { + large: 'https://grafana.com/assets/img/brand/grafana_icon.svg', + small: 'https://grafana.com/assets/img/brand/grafana_icon.svg', + }, + keywords: [], + }, + isDev: false, + isCore: false, + isEnterprise: false, + isInstalled: false, + isDisabled: false, + isDeprecated: false, + isManaged: false, + isPreinstalled: { found: false, withVersion: false }, + isPublished: false, + orgName: 'Test Org', + signature: PluginSignatureStatus.valid, + popularity: 0, + publishedAt: '2021-01-01', + updatedAt: '2021-01-01', +}; + +describe('GetStartedWithApp', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders null if pluginConfig is not available', () => { + (usePluginConfig as jest.Mock).mockReturnValue({ value: null }); + (contextSrv.hasPermission as jest.Mock).mockReturnValue(true); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders null if user does not have permission', () => { + (usePluginConfig as jest.Mock).mockReturnValue({ value: { meta: {} } }); + (contextSrv.hasPermission as jest.Mock).mockReturnValue(false); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders enable button if plugin is not enabled', () => { + (usePluginConfig as jest.Mock).mockReturnValue({ value: { meta: { enabled: false } } }); + (contextSrv.hasPermission as jest.Mock).mockReturnValue(true); + + render(); + expect(screen.getByText('Enable')).toBeInTheDocument(); + }); + + it('renders disable button if plugin is enabled and not autoEnabled', () => { + (usePluginConfig as jest.Mock).mockReturnValue({ value: { meta: { enabled: true, autoEnabled: false } } }); + (contextSrv.hasPermission as jest.Mock).mockReturnValue(true); + + render(); + expect(screen.getByText('Disable')).toBeInTheDocument(); + }); + + it('does not render disable button if plugin is enabled and autoEnabled', () => { + (usePluginConfig as jest.Mock).mockReturnValue({ value: { meta: { enabled: true, autoEnabled: true } } }); + (contextSrv.hasPermission as jest.Mock).mockReturnValue(true); + + render(); + expect(screen.queryByText('Disable')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx index bdb80b54d4f..4ca6080d7c7 100644 --- a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx @@ -25,7 +25,7 @@ export function GetStartedWithApp({ plugin }: Props): React.ReactElement | null return null; } - const { enabled, jsonData } = pluginConfig?.meta; + const { enabled, autoEnabled, jsonData } = pluginConfig?.meta; const enable = () => { reportInteraction('plugins_detail_enable_clicked', { @@ -63,7 +63,7 @@ export function GetStartedWithApp({ plugin }: Props): React.ReactElement | null )} - {enabled && ( + {enabled && !autoEnabled && (