package loader import ( "context" "errors" "path/filepath" "sort" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector" "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/plugindef" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/setting" ) var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log", "mu"), fsComparer} var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool { fs1Files, err := fs1.Files() if err != nil { panic(err) } fs2Files, err := fs2.Files() if err != nil { panic(err) } sort.SliceStable(fs1Files, func(i, j int) bool { return fs1Files[i] < fs1Files[j] }) sort.SliceStable(fs2Files, func(i, j int) bool { return fs2Files[i] < fs2Files[j] }) return cmp.Equal(fs1Files, fs2Files) && fs1.Base() == fs2.Base() }) func TestLoader_Load(t *testing.T) { tests := []struct { name string class plugins.Class cfg *config.PluginManagementCfg pluginPaths []string want []*plugins.Plugin pluginErrors map[string]*plugins.SignatureError }{ { name: "Load a Core plugin", class: plugins.ClassCore, cfg: &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()}, pluginPaths: []string{filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "cloudwatch", Type: plugins.TypeDataSource, Name: "CloudWatch", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "https://grafana.com", }, Description: "Data source for Amazon AWS monitoring service", Logos: plugins.Logos{ Small: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", Large: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", }, }, Includes: []*plugins.Includes{ {Name: "EC2", Path: "dashboards/ec2.json", Type: "dashboard", Role: "Viewer"}, {Name: "EBS", Path: "dashboards/EBS.json", Type: "dashboard", Role: "Viewer"}, {Name: "Lambda", Path: "dashboards/Lambda.json", Type: "dashboard", Role: "Viewer"}, {Name: "Logs", Path: "dashboards/Logs.json", Type: "dashboard", Role: "Viewer"}, {Name: "RDS", Path: "dashboards/RDS.json", Type: "dashboard", Role: "Viewer"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Category: "cloud", Annotations: true, Metrics: true, Alerting: true, Logs: true, Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, Module: "core:plugin/cloudwatch", BaseURL: "public/app/plugins/datasource/cloudwatch", FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), Signature: plugins.SignatureStatusInternal, Class: plugins.ClassCore, }, }, }, { name: "Load a Bundled plugin", class: plugins.ClassBundled, cfg: &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()}, pluginPaths: []string{filepath.Join(testDataDir(t), "valid-v2-signature")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Will Browne", URL: "https://willbrowne.com", }, Version: "1.0.0", Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Test", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Executable: "test", Backend: true, State: "alpha", }, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-signature/plugin/")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassBundled, }, }, }, { name: "Load plugin with symbolic links", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()}, pluginPaths: []string{filepath.Join(testDataDir(t), "symbolic-plugin-dirs")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-app", Type: "app", Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Test Inc.", URL: "http://test.com", }, Logos: plugins.Logos{ Small: "public/plugins/test-app/img/logo_small.png", Large: "public/plugins/test-app/img/logo_large.png", }, Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, {Name: "License & Terms", URL: "http://license.com"}, }, Description: "Official Grafana Test App & Dashboard bundle", Screenshots: []plugins.Screenshots{ {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, Version: "1.0.0", Updated: "2015-02-10", Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", Plugins: []plugins.Dependency{ {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, }, Includes: []*plugins.Includes{ { Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-connections", }, { Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory", }, { Name: "Nginx Panel", Type: string(plugins.TypePanel), Role: org.RoleViewer, Slug: "nginx-panel", }, { Name: "Nginx Datasource", Type: string(plugins.TypeDataSource), Role: org.RoleViewer, Slug: "nginx-datasource", }, }, }, Class: plugins.ClassExternal, Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", }, }, }, { name: "Load an unsigned plugin (development)", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ DevMode: true, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "https://grafana.com", }, Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Test", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Backend: true, State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), Signature: "unsigned", }, }, }, { name: "Load an unsigned plugin (production)", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()}, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ "test-datasource": { PluginID: "test-datasource", SignatureStatus: plugins.SignatureStatusUnsigned, }, }, }, { name: "Load an unsigned plugin using PluginsAllowUnsigned config (production)", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "https://grafana.com", }, Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Test", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Backend: true, State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), Signature: plugins.SignatureStatusUnsigned, }, }, }, { name: "Load a plugin with v1 manifest should return signatureInvalid", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()}, pluginPaths: []string{filepath.Join(testDataDir(t), "lacking-files")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ "test-datasource": { PluginID: "test-datasource", SignatureStatus: plugins.SignatureStatusInvalid, }, }, }, { name: "Load a plugin with v1 manifest using PluginsAllowUnsigned config (production) should return signatureInvalid", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "lacking-files")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ "test-datasource": { PluginID: "test-datasource", SignatureStatus: plugins.SignatureStatusInvalid, }, }, }, { name: "Load a plugin with manifest which has a file not found in plugin folder", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "invalid-v2-missing-file")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ "test-datasource": { PluginID: "test-datasource", SignatureStatus: plugins.SignatureStatusModified, }, }, }, { name: "Load a plugin with file which is missing from the manifest", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-datasource"}, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "invalid-v2-extra-file")}, want: []*plugins.Plugin{}, pluginErrors: map[string]*plugins.SignatureError{ "test-datasource": { PluginID: "test-datasource", SignatureStatus: plugins.SignatureStatusModified, }, }, }, { name: "Load an app with includes", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ PluginsAllowUnsigned: []string{"test-app"}, Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "test-app-with-includes")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-app", Type: plugins.TypeApp, Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Test Inc.", URL: "http://test.com", }, Description: "Official Grafana Test App & Dashboard bundle", Version: "1.0.0", Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ Small: "public/img/icn-app.svg", Large: "public/img/icn-app.svg", }, Updated: "2015-02-10", Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaDependency: ">=8.0.0", GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Includes: []*plugins.Includes{ {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory"}, {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Backend: false, }, DefaultNavURL: "/plugins/test-app/page/root-page-react", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusUnsigned, Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", }, }, }, { name: "Load a plugin with app sub url set", class: plugins.ClassExternal, cfg: &config.PluginManagementCfg{ DevMode: true, GrafanaAppSubURL: "grafana", Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "unsigned-datasource")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "https://grafana.com", }, Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Test", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Backend: true, State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), Signature: plugins.SignatureStatusUnsigned, }, }, }, } for _, tt := range tests { reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() errTracker := pluginerrs.ProvideSignatureErrorTracker() l := newLoader(t, tt.cfg, reg, procMgr, procPrvdr, errTracker) t.Run(tt.name, func(t *testing.T) { got, err := l.Load(context.Background(), sources.NewLocalSource(tt.class, tt.pluginPaths)) require.NoError(t, err) if !cmp.Equal(got, tt.want, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...)) } pluginErrs := errTracker.SignatureErrors(context.Background()) require.Equal(t, len(tt.pluginErrors), len(pluginErrs)) for _, pluginErr := range pluginErrs { require.Equal(t, tt.pluginErrors[pluginErr.PluginID], pluginErr) } verifyState(t, tt.want, reg, procPrvdr, procMgr) }) } } func TestLoader_Load_ExternalRegistration(t *testing.T) { stringPtr := func(s string) *string { return &s } t.Run("Load a plugin with service account registration", func(t *testing.T) { cfg := &config.PluginManagementCfg{ Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts), PluginsAllowUnsigned: []string{"grafana-test-datasource"}, } pluginPaths := []string{filepath.Join(testDataDir(t), "external-registration")} expected := []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "grafana-test-datasource", Type: plugins.TypeDataSource, Name: "Test", Backend: true, Executable: "gpx_test_datasource", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "https://grafana.com", }, Version: "1.0.0", Logos: plugins.Logos{ Small: "public/plugins/grafana-test-datasource/img/ds.svg", Large: "public/plugins/grafana-test-datasource/img/ds.svg", }, Updated: "2023-08-03", Screenshots: []plugins.Screenshots{}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, IAM: &plugindef.IAM{ Permissions: []plugindef.Permission{ { Action: "read", Scope: stringPtr("datasource"), }, }, }, }, FS: mustNewStaticFSForTests(t, pluginPaths[0]), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusUnsigned, Module: "public/plugins/grafana-test-datasource/module.js", BaseURL: "public/plugins/grafana-test-datasource", ExternalService: &auth.ExternalService{ ClientID: "client-id", ClientSecret: "secretz", }, }, } backendFactoryProvider := fakes.NewFakeBackendProcessProvider() backendFactoryProvider.BackendFactoryFunc = func(ctx context.Context, plugin *plugins.Plugin) backendplugin.PluginFactoryFunc { return func(pluginID string, logger log.Logger, env func() []string) (backendplugin.Plugin, error) { require.Equal(t, "grafana-test-datasource", pluginID) return &fakes.FakeBackendPlugin{}, nil } } l := newLoaderWithOpts(t, cfg, loaderDepOpts{ authServiceRegistry: &fakes.FakeAuthService{ Result: &auth.ExternalService{ ClientID: "client-id", ClientSecret: "secretz", }, }, backendFactoryProvider: backendFactoryProvider, }) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return pluginPaths }, DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) { return plugins.Signature{}, false }, }) require.NoError(t, err) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } }) } func TestLoader_Load_CustomSource(t *testing.T) { t.Run("Load a plugin", func(t *testing.T) { cfg := &config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.example.com", PluginSettings: setting.PluginSettings{ "grafana-worldmap-panel": {"cdn": "true"}, }, Features: featuremgmt.WithFeatures(), } pluginPaths := []string{filepath.Join(testDataDir(t), "cdn")} expected := []*plugins.Plugin{{ JSONData: plugins.JSONData{ ID: "grafana-worldmap-panel", Type: plugins.TypePanel, Name: "Worldmap Panel", Info: plugins.Info{ Version: "0.3.3", Links: []plugins.InfoLink{ {Name: "Project site", URL: "https://github.com/grafana/worldmap-panel"}, {Name: "MIT License", URL: "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"}, }, Logos: plugins.Logos{ // Path substitution Small: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg", Large: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg", }, Screenshots: []plugins.Screenshots{ { Name: "World", Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-world.png", }, { Name: "USA", Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-usa.png", }, { Name: "Light Theme", Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-light-theme.png", }, }, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", Plugins: []plugins.Dependency{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")), Class: plugins.ClassBundled, Signature: plugins.SignatureStatusValid, BaseURL: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", Module: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js", }} l := newLoader(t, cfg, fakes.NewFakePluginRegistry(), fakes.NewFakeProcessManager(), fakes.NewFakeBackendProcessProvider(), newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassBundled }, PluginURIsFunc: func(ctx context.Context) []string { return pluginPaths }, DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) { return plugins.Signature{ Status: plugins.SignatureStatusValid, }, true }, }) require.NoError(t, err) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } }) } func TestLoader_Load_MultiplePlugins(t *testing.T) { t.Run("Load multiple", func(t *testing.T) { tests := []struct { name string cfg *config.PluginManagementCfg pluginPaths []string existingPlugins map[string]struct{} want []*plugins.Plugin pluginErrors map[string]*plugins.SignatureError }{ { name: "Load multiple plugins (broken, valid, unsigned)", cfg: &config.PluginManagementCfg{ GrafanaAppURL: "http://localhost:3000", Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{ filepath.Join(testDataDir(t), "invalid-plugin-json"), // test-app filepath.Join(testDataDir(t), "valid-v2-pvt-signature"), // test filepath.Join(testDataDir(t), "unsigned-panel"), // test-panel }, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Will Browne", URL: "https://willbrowne.com", }, Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Test", Version: "1.0.0", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Backend: true, Executable: "test", State: plugins.ReleaseStateAlpha, }, Class: plugins.ClassExternal, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), Signature: "valid", SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", }, }, pluginErrors: map[string]*plugins.SignatureError{ "test-panel": { PluginID: "test-panel", SignatureStatus: plugins.SignatureStatusUnsigned, }, }, }, } for _, tt := range tests { reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() errTracker := pluginerrs.ProvideSignatureErrorTracker() l := newLoader(t, tt.cfg, reg, procMgr, procPrvdr, errTracker) t.Run(tt.name, func(t *testing.T) { got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return tt.pluginPaths }, }) require.NoError(t, err) sort.SliceStable(got, func(i, j int) bool { return got[i].ID < got[j].ID }) if !cmp.Equal(got, tt.want, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...)) } pluginErrs := errTracker.SignatureErrors(context.Background()) require.Equal(t, len(tt.pluginErrors), len(pluginErrs)) for _, pluginErr := range pluginErrs { require.Equal(t, tt.pluginErrors[pluginErr.PluginID], pluginErr) } verifyState(t, tt.want, reg, procPrvdr, procMgr) }) } }) } func TestLoader_Load_RBACReady(t *testing.T) { tests := []struct { name string cfg *config.PluginManagementCfg pluginPaths []string existingPlugins map[string]struct{} want []*plugins.Plugin }{ { name: "Load plugin defining one RBAC role", cfg: &config.PluginManagementCfg{ GrafanaAppURL: "http://localhost:3000", Features: featuremgmt.WithFeatures(), }, pluginPaths: []string{filepath.Join(testDataDir(t), "test-app-with-roles")}, want: []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-app", Type: plugins.TypeApp, Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Test Inc.", URL: "http://test.com", }, Description: "Test App", Version: "1.0.0", Links: []plugins.InfoLink{}, Logos: plugins.Logos{ Small: "public/img/icn-app.svg", Large: "public/img/icn-app.svg", }, Updated: "2015-02-10", Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", GrafanaDependency: ">=8.0.0", Plugins: []plugins.Dependency{}, }, Includes: []*plugins.Includes{}, Roles: []plugins.RoleRegistration{ { Role: plugins.Role{ Name: "Reader", Description: "View everything in the test-app plugin", Permissions: []plugins.Permission{ {Action: "plugins.app:access", Scope: "plugins.app:id:test-app"}, {Action: "test-app.resource:read", Scope: "resources:*"}, {Action: "test-app.otherresource:toggle"}, }, }, Grants: []string{"Admin"}, }, }, Backend: false, }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "gabrielmabille", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", }, }, }, } for _, tt := range tests { reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() l := newLoader(t, tt.cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return tt.pluginPaths }, }) require.NoError(t, err) if !cmp.Equal(got, tt.want, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...)) } verifyState(t, tt.want, reg, procPrvdr, procMgr) } } func TestLoader_Load_Signature_RootURL(t *testing.T) { t.Run("Private signature verification ignores trailing slash in root URL", func(t *testing.T) { const defaultAppURL = "http://localhost:3000/grafana" expected := []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Test", Info: plugins.Info{ Author: plugins.InfoLink{Name: "Will Browne", URL: "https://willbrowne.com"}, Description: "Test", Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Version: "1.0.0", }, State: plugins.ReleaseStateAlpha, Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}}, Backend: true, Executable: "test", }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", }, } reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() cfg := &config.PluginManagementCfg{GrafanaAppURL: defaultAppURL, Features: featuremgmt.WithFeatures()} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri")} }, }) require.NoError(t, err) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) }) } func TestLoader_Load_DuplicatePlugins(t *testing.T) { t.Run("Load duplicate plugin folders", func(t *testing.T) { expected := []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-app", Type: plugins.TypeApp, Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Test Inc.", URL: "http://test.com", }, Description: "Official Grafana Test App & Dashboard bundle", Version: "1.0.0", Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ Small: "public/plugins/test-app/img/logo_small.png", Large: "public/plugins/test-app/img/logo_large.png", }, Screenshots: []plugins.Screenshots{ {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, Updated: "2015-02-10", Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", Plugins: []plugins.Dependency{ {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, }, Includes: []*plugins.Includes{ {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-connections"}, {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory"}, {Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Slug: "nginx-panel"}, {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Slug: "nginx-datasource"}, }, Backend: false, }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", }, } reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() cfg := &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "test-app"), filepath.Join(testDataDir(t), "test-app")} }, }) require.NoError(t, err) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) }) } func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { t.Run("Load duplicate plugin folders", func(t *testing.T) { pluginDir1 := filepath.Join(testDataDir(t), "test-app") pluginDir2 := filepath.Join(testDataDir(t), "valid-v2-signature") expected := []*plugins.Plugin{ { JSONData: plugins.JSONData{ ID: "test-app", Type: plugins.TypeApp, Name: "Test App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Test Inc.", URL: "http://test.com", }, Description: "Official Grafana Test App & Dashboard bundle", Version: "1.0.0", Links: []plugins.InfoLink{ {Name: "Project site", URL: "http://project.com"}, {Name: "License & Terms", URL: "http://license.com"}, }, Logos: plugins.Logos{ Small: "public/plugins/test-app/img/logo_small.png", Large: "public/plugins/test-app/img/logo_large.png", }, Screenshots: []plugins.Screenshots{ {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, }, Updated: "2015-02-10", Keywords: []string{"test"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", Plugins: []plugins.Dependency{ {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, }, Includes: []*plugins.Includes{ {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-connections"}, {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Slug: "nginx-memory"}, {Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Slug: "nginx-panel"}, {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Slug: "nginx-datasource"}, }, Backend: false, }, FS: mustNewStaticFSForTests(t, pluginDir1), Class: plugins.ClassExternal, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", }, } reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() // Cause an initialization error procPrvdr.BackendFactoryFunc = func(ctx context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { return func(pluginID string, _ log.Logger, _ func() []string) (backendplugin.Plugin, error) { if pluginID == "test-datasource" { return nil, errors.New("failed to initialize") } return &fakes.FakePluginClient{}, nil } } procMgr := fakes.NewFakeProcessManager() cfg := &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{pluginDir1, pluginDir2} }, }) require.NoError(t, err) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) }) } func TestLoader_AngularClass(t *testing.T) { for _, tc := range []struct { name string class plugins.Class expAngularDetectionRun bool }{ { name: "core plugin should skip angular detection", class: plugins.ClassCore, expAngularDetectionRun: false, }, { name: "bundled plugin should skip angular detection", class: plugins.ClassBundled, expAngularDetectionRun: false, }, { name: "external plugin should run angular detection", class: plugins.ClassExternal, expAngularDetectionRun: true, }, { name: "other-class plugin should run angular detection", class: "CDN", // (enterprise-only class) expAngularDetectionRun: true, }, } { t.Run(tc.name, func(t *testing.T) { fakePluginSource := &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return tc.class }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "valid-v2-signature")} }, } // if angularDetected = true, it means that the detection has run l := newLoaderWithOpts(t, &config.PluginManagementCfg{AngularSupportEnabled: true, Features: featuremgmt.WithFeatures()}, loaderDepOpts{ angularInspector: angularinspector.AlwaysAngularFakeInspector, }) p, err := l.Load(context.Background(), fakePluginSource) require.NoError(t, err) require.Len(t, p, 1, "should load 1 plugin") if tc.expAngularDetectionRun { require.True(t, p[0].Angular.Detected, "angular detection should run") } else { require.False(t, p[0].Angular.Detected, "angular detection should not run") } }) } } func TestLoader_Load_Angular(t *testing.T) { fakePluginSource := &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "valid-v2-signature")} }, } for _, cfgTc := range []struct { name string cfg *config.PluginManagementCfg }{ {name: "angular support enabled", cfg: &config.PluginManagementCfg{AngularSupportEnabled: true, Features: featuremgmt.WithFeatures()}}, {name: "angular support disabled", cfg: &config.PluginManagementCfg{AngularSupportEnabled: false, Features: featuremgmt.WithFeatures()}}, } { t.Run(cfgTc.name, func(t *testing.T) { for _, tc := range []struct { name string angularInspector angularinspector.Inspector shouldLoad bool }{ { name: "angular plugin", angularInspector: angularinspector.AlwaysAngularFakeInspector, // angular plugins should load only if allowed by the cfg shouldLoad: cfgTc.cfg.AngularSupportEnabled, }, { name: "non angular plugin", angularInspector: angularinspector.NeverAngularFakeInspector, // non-angular plugins should always load shouldLoad: true, }, } { t.Run(tc.name, func(t *testing.T) { l := newLoaderWithOpts(t, cfgTc.cfg, loaderDepOpts{angularInspector: tc.angularInspector}) p, err := l.Load(context.Background(), fakePluginSource) require.NoError(t, err) if tc.shouldLoad { require.Len(t, p, 1, "plugin should have been loaded") } else { require.Empty(t, p, "plugin shouldn't have been loaded") } }) } }) } } func TestLoader_HideAngularDeprecation(t *testing.T) { fakePluginSource := &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "valid-v2-signature")} }, } for _, tc := range []struct { name string cfg *config.PluginManagementCfg expHideAngularDeprecation bool }{ {name: "with plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: []string{"one-app", "two-panel", "test-datasource", "three-datasource"}, Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: true}, {name: "without plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: []string{"one-app", "two-panel", "three-datasource"}, Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: false}, {name: "with empty HideAngularDeprecation", cfg: &config.PluginManagementCfg{ AngularSupportEnabled: true, HideAngularDeprecation: nil, Features: featuremgmt.WithFeatures(), }, expHideAngularDeprecation: false}, } { t.Run(tc.name, func(t *testing.T) { l := newLoaderWithOpts(t, tc.cfg, loaderDepOpts{ angularInspector: angularinspector.AlwaysAngularFakeInspector, }) p, err := l.Load(context.Background(), fakePluginSource) require.NoError(t, err) require.Len(t, p, 1, "should load 1 plugin") require.Equal(t, tc.expHideAngularDeprecation, p[0].Angular.HideDeprecation) }) } } func TestLoader_Load_NestedPlugins(t *testing.T) { parent := &plugins.Plugin{ JSONData: plugins.JSONData{ ID: "test-datasource", Type: plugins.TypeDataSource, Name: "Parent", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "http://grafana.com", }, Logos: plugins.Logos{ Small: "public/img/icn-datasource.svg", Large: "public/img/icn-datasource.svg", }, Description: "Parent plugin", Version: "1.0.0", Updated: "2020-10-20", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, Backend: true, }, Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, } child := &plugins.Plugin{ JSONData: plugins.JSONData{ ID: "test-panel", Type: plugins.TypePanel, Name: "Child", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Grafana Labs", URL: "http://grafana.com", }, Logos: plugins.Logos{ Small: "public/img/icn-panel.svg", Large: "public/img/icn-panel.svg", }, Description: "Child plugin", Version: "1.0.1", Updated: "2020-10-30", }, Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, }, Module: "public/plugins/test-panel/module.js", BaseURL: "public/plugins/test-panel", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, } parent.Children = []*plugins.Plugin{child} child.Parent = parent t.Run("Load nested External plugins", func(t *testing.T) { procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() reg := fakes.NewFakePluginRegistry() cfg := &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "nested-plugins")} }, }) require.NoError(t, err) // to ensure we can compare with expected sort.SliceStable(got, func(i, j int) bool { return got[i].ID < got[j].ID }) expected := []*plugins.Plugin{parent, child} if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) t.Run("Load will exclude plugins that already exist", func(t *testing.T) { got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "nested-plugins")} }, }) require.NoError(t, err) // to ensure we can compare with expected sort.SliceStable(got, func(i, j int) bool { return got[i].ID < got[j].ID }) if !cmp.Equal(got, []*plugins.Plugin{}, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) }) }) t.Run("Plugin child field `IncludedInAppID` is set to parent app's plugin ID", func(t *testing.T) { parent := &plugins.Plugin{ JSONData: plugins.JSONData{ ID: "myorgid-simple-app", Type: plugins.TypeApp, Name: "Simple App", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Your Name", }, Links: []plugins.InfoLink{ {Name: "Website", URL: "https://github.com/grafana/grafana-starter-app"}, {Name: "License", URL: "https://github.com/grafana/grafana-starter-app/blob/master/LICENSE"}, }, Logos: plugins.Logos{ Small: "public/plugins/myorgid-simple-app/img/logo.svg", Large: "public/plugins/myorgid-simple-app/img/logo.svg", }, Screenshots: []plugins.Screenshots{}, Description: "Grafana App Plugin Template", Version: "", Updated: "", Keywords: []string{"panel", "template"}, }, Dependencies: plugins.Dependencies{ GrafanaVersion: "7.0.0", GrafanaDependency: ">=7.0.0", Plugins: []plugins.Dependency{}, }, Includes: []*plugins.Includes{ { Name: "Root Page (react)", Path: "/a/myorgid-simple-app", Type: "page", Role: org.RoleViewer, AddToNav: true, DefaultNav: true, Slug: "root-page-react", }, { Name: "Root Page (Tab B)", Path: "/a/myorgid-simple-app/?tab=b", Type: "page", Role: org.RoleViewer, AddToNav: true, Slug: "root-page-tab-b", }, { Name: "React Config", Path: "/plugins/myorgid-simple-app/?page=page2", Type: "page", Role: org.RoleAdmin, AddToNav: true, Slug: "react-config", }, { Name: "Streaming Example", Path: "dashboards/streaming.json", Type: "dashboard", Role: org.RoleViewer, Slug: "streaming-example", }, { Name: "Lots of Stats", Path: "dashboards/stats.json", Type: "dashboard", Role: org.RoleViewer, Slug: "lots-of-stats", }, }, Backend: false, }, Module: "public/plugins/myorgid-simple-app/module.js", BaseURL: "public/plugins/myorgid-simple-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, } child := &plugins.Plugin{ JSONData: plugins.JSONData{ ID: "myorgid-simple-panel", Type: plugins.TypePanel, Name: "Grafana Panel Plugin Template", Info: plugins.Info{ Author: plugins.InfoLink{ Name: "Your Name", }, Links: []plugins.InfoLink{ {Name: "Website", URL: "https://github.com/grafana/grafana-starter-panel"}, {Name: "License", URL: "https://github.com/grafana/grafana-starter-panel/blob/master/LICENSE"}, }, Logos: plugins.Logos{ Small: "public/plugins/myorgid-simple-panel/img/logo.svg", Large: "public/plugins/myorgid-simple-panel/img/logo.svg", }, Screenshots: []plugins.Screenshots{}, Description: "Grafana Panel Plugin Template", Version: "", Updated: "", Keywords: []string{"panel", "template"}, }, Dependencies: plugins.Dependencies{ GrafanaDependency: ">=7.0.0", GrafanaVersion: "*", Plugins: []plugins.Dependency{}, }, }, Module: "public/plugins/myorgid-simple-app/child/module.js", BaseURL: "public/plugins/myorgid-simple-app", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), IncludedInAppID: parent.ID, Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, } parent.Children = []*plugins.Plugin{child} child.Parent = parent expected := []*plugins.Plugin{parent, child} reg := fakes.NewFakePluginRegistry() procPrvdr := fakes.NewFakeBackendProcessProvider() procMgr := fakes.NewFakeProcessManager() cfg := &config.PluginManagementCfg{Features: featuremgmt.WithFeatures()} l := newLoader(t, cfg, reg, procMgr, procPrvdr, newFakeSignatureErrorTracker()) got, err := l.Load(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal }, PluginURIsFunc: func(ctx context.Context) []string { return []string{filepath.Join(testDataDir(t), "app-with-child")} }, }) require.NoError(t, err) // to ensure we can compare with expected sort.SliceStable(got, func(i, j int) bool { return got[i].ID < got[j].ID }) if !cmp.Equal(got, expected, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) } verifyState(t, expected, reg, procPrvdr, procMgr) }) } type loaderDepOpts struct { angularInspector angularinspector.Inspector authServiceRegistry auth.ExternalServiceRegistry backendFactoryProvider plugins.BackendFactoryProvider } func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Service, proc process.Manager, backendFactory plugins.BackendFactoryProvider, sigErrTracker pluginerrs.SignatureErrorTracker, ) *Loader { assets := assetpath.ProvideService(cfg, pluginscdn.ProvideService(cfg)) angularInspector := angularinspector.NewStaticInspector() terminate, err := pipeline.ProvideTerminationStage(cfg, reg, proc) require.NoError(t, err) return ProvideService(pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(false, featuremgmt.WithFeatures()), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector, sigErrTracker), pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &fakes.FakeAuthService{}, fakes.NewFakeRoleRegistry(), fakes.NewFakePluginEnvProvider()), terminate) } func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loaderDepOpts) *Loader { assets := assetpath.ProvideService(cfg, pluginscdn.ProvideService(cfg)) reg := fakes.NewFakePluginRegistry() proc := fakes.NewFakeProcessManager() terminate, err := pipeline.ProvideTerminationStage(cfg, reg, proc) require.NoError(t, err) sigErrTracker := pluginerrs.ProvideSignatureErrorTracker() angularInspector := opts.angularInspector if opts.angularInspector == nil { angularInspector = angularinspector.NewStaticInspector() } authServiceRegistry := opts.authServiceRegistry if authServiceRegistry == nil { authServiceRegistry = &fakes.FakeAuthService{} } backendFactoryProvider := opts.backendFactoryProvider if backendFactoryProvider == nil { backendFactoryProvider = fakes.NewFakeBackendProcessProvider() } return ProvideService(pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(false, featuremgmt.WithFeatures()), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector, sigErrTracker), pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, fakes.NewFakeRoleRegistry(), fakes.NewFakePluginEnvProvider()), terminate) } func verifyState(t *testing.T, ps []*plugins.Plugin, reg registry.Service, procPrvdr *fakes.FakeBackendProcessProvider, procMngr *fakes.FakeProcessManager, ) { t.Helper() for _, p := range ps { regP, exists := reg.Plugin(context.Background(), p.ID, p.Info.Version) require.True(t, exists) if !cmp.Equal(p, regP, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, regP, compareOpts...)) } if p.Backend { require.Equal(t, 1, procPrvdr.Requested[p.ID]) require.Equal(t, 1, procPrvdr.Invoked[p.ID]) } else { require.Zero(t, procPrvdr.Requested[p.ID]) require.Zero(t, procPrvdr.Invoked[p.ID]) } require.Equal(t, 1, procMngr.Started[p.ID]) require.Zero(t, procMngr.Stopped[p.ID]) } } func mustNewStaticFSForTests(t *testing.T, dir string) plugins.FS { sfs, err := plugins.NewStaticFS(plugins.NewLocalFS(dir)) require.NoError(t, err) return sfs } func testDataDir(t *testing.T) string { dir, err := filepath.Abs("../../../plugins/manager/testdata") if err != nil { t.Errorf("could not construct absolute path of current dir") return "" } return dir } func corePluginDir(t *testing.T) string { dir, err := filepath.Abs("./../../../../public") if err != nil { t.Errorf("could not construct absolute path of core plugins dir") return "" } return dir }