mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 23:55:47 -06:00
This PR adds endpoints for public dashboards to retrieve data from the backend (trusted) query engine. It works by executing queries defined on the backend without any user input and does not support template variables. * Public dashboard query API * Create new API on service for building metric request * Flesh out testing, implement BuildPublicDashboardMetricRequest * Test for errors and missing panels * Refactor tests, add supporting code for multiple datasources * Handle queries from multiple datasources * Explicitly pass no user for querying public dashboard Co-authored-by: Jeff Levin <jeff@levinology.com>
508 lines
14 KiB
Go
508 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
|
"github.com/grafana/grafana/pkg/api/dtos"
|
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
"github.com/grafana/grafana/pkg/models"
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/query"
|
|
"github.com/grafana/grafana/pkg/web/webtest"
|
|
|
|
fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
|
)
|
|
|
|
func TestAPIGetPublicDashboard(t *testing.T) {
|
|
t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) {
|
|
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures())
|
|
dashSvc := dashboards.NewFakeDashboardService(t)
|
|
dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
|
Return(&models.Dashboard{}, nil).Maybe()
|
|
sc.hs.dashboardService = dashSvc
|
|
|
|
setInitCtxSignedInViewer(sc.initCtx)
|
|
response := callAPI(
|
|
sc.server,
|
|
http.MethodGet,
|
|
"/api/public/dashboards",
|
|
nil,
|
|
t,
|
|
)
|
|
assert.Equal(t, http.StatusNotFound, response.Code)
|
|
response = callAPI(
|
|
sc.server,
|
|
http.MethodGet,
|
|
"/api/public/dashboards/asdf",
|
|
nil,
|
|
t,
|
|
)
|
|
assert.Equal(t, http.StatusNotFound, response.Code)
|
|
})
|
|
|
|
dashboardUid := "dashboard-abcd1234"
|
|
pubdashUid := "pubdash-abcd1234"
|
|
|
|
testCases := []struct {
|
|
name string
|
|
uid string
|
|
expectedHttpResponse int
|
|
publicDashboardResult *models.Dashboard
|
|
publicDashboardErr error
|
|
}{
|
|
{
|
|
name: "It gets a public dashboard",
|
|
uid: pubdashUid,
|
|
expectedHttpResponse: http.StatusOK,
|
|
publicDashboardResult: &models.Dashboard{
|
|
Data: simplejson.NewFromAny(map[string]interface{}{
|
|
"Uid": dashboardUid,
|
|
}),
|
|
IsPublic: true,
|
|
},
|
|
publicDashboardErr: nil,
|
|
},
|
|
{
|
|
name: "It should return 404 if isPublicDashboard is false",
|
|
uid: pubdashUid,
|
|
expectedHttpResponse: http.StatusNotFound,
|
|
publicDashboardResult: nil,
|
|
publicDashboardErr: models.ErrPublicDashboardNotFound,
|
|
},
|
|
}
|
|
|
|
for _, test := range testCases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards))
|
|
dashSvc := dashboards.NewFakeDashboardService(t)
|
|
dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
|
Return(test.publicDashboardResult, test.publicDashboardErr)
|
|
sc.hs.dashboardService = dashSvc
|
|
|
|
setInitCtxSignedInViewer(sc.initCtx)
|
|
response := callAPI(
|
|
sc.server,
|
|
http.MethodGet,
|
|
fmt.Sprintf("/api/public/dashboards/%v", test.uid),
|
|
nil,
|
|
t,
|
|
)
|
|
|
|
assert.Equal(t, test.expectedHttpResponse, response.Code)
|
|
|
|
if test.publicDashboardErr == nil {
|
|
var dashResp dtos.DashboardFullWithMeta
|
|
err := json.Unmarshal(response.Body.Bytes(), &dashResp)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, dashboardUid, dashResp.Dashboard.Get("Uid").MustString())
|
|
assert.Equal(t, true, dashResp.Meta.IsPublic)
|
|
assert.Equal(t, false, dashResp.Meta.CanEdit)
|
|
assert.Equal(t, false, dashResp.Meta.CanDelete)
|
|
assert.Equal(t, false, dashResp.Meta.CanSave)
|
|
} else {
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
err := json.Unmarshal(response.Body.Bytes(), &errResp)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.publicDashboardErr.Error(), errResp.Error)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIGetPublicDashboardConfig(t *testing.T) {
|
|
pdc := &models.PublicDashboardConfig{IsPublic: true}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
dashboardUid string
|
|
expectedHttpResponse int
|
|
publicDashboardConfigResult *models.PublicDashboardConfig
|
|
publicDashboardConfigError error
|
|
}{
|
|
{
|
|
name: "retrieves public dashboard config when dashboard is found",
|
|
dashboardUid: "1",
|
|
expectedHttpResponse: http.StatusOK,
|
|
publicDashboardConfigResult: pdc,
|
|
publicDashboardConfigError: nil,
|
|
},
|
|
{
|
|
name: "returns 404 when dashboard not found",
|
|
dashboardUid: "77777",
|
|
expectedHttpResponse: http.StatusNotFound,
|
|
publicDashboardConfigResult: nil,
|
|
publicDashboardConfigError: models.ErrDashboardNotFound,
|
|
},
|
|
{
|
|
name: "returns 500 when internal server error",
|
|
dashboardUid: "1",
|
|
expectedHttpResponse: http.StatusInternalServerError,
|
|
publicDashboardConfigResult: nil,
|
|
publicDashboardConfigError: errors.New("database broken"),
|
|
},
|
|
}
|
|
|
|
for _, test := range testCases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards))
|
|
dashSvc := dashboards.NewFakeDashboardService(t)
|
|
dashSvc.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
|
Return(test.publicDashboardConfigResult, test.publicDashboardConfigError)
|
|
sc.hs.dashboardService = dashSvc
|
|
|
|
setInitCtxSignedInViewer(sc.initCtx)
|
|
response := callAPI(
|
|
sc.server,
|
|
http.MethodGet,
|
|
"/api/dashboards/uid/1/public-config",
|
|
nil,
|
|
t,
|
|
)
|
|
|
|
assert.Equal(t, test.expectedHttpResponse, response.Code)
|
|
|
|
if response.Code == http.StatusOK {
|
|
var pdcResp models.PublicDashboardConfig
|
|
err := json.Unmarshal(response.Body.Bytes(), &pdcResp)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.publicDashboardConfigResult, &pdcResp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApiSavePublicDashboardConfig(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
dashboardUid string
|
|
publicDashboardConfig *models.PublicDashboardConfig
|
|
expectedHttpResponse int
|
|
saveDashboardError error
|
|
}{
|
|
{
|
|
name: "returns 200 when update persists",
|
|
dashboardUid: "1",
|
|
publicDashboardConfig: &models.PublicDashboardConfig{IsPublic: true},
|
|
expectedHttpResponse: http.StatusOK,
|
|
saveDashboardError: nil,
|
|
},
|
|
{
|
|
name: "returns 500 when not persisted",
|
|
expectedHttpResponse: http.StatusInternalServerError,
|
|
publicDashboardConfig: &models.PublicDashboardConfig{},
|
|
saveDashboardError: errors.New("backend failed to save"),
|
|
},
|
|
{
|
|
name: "returns 404 when dashboard not found",
|
|
expectedHttpResponse: http.StatusNotFound,
|
|
publicDashboardConfig: &models.PublicDashboardConfig{},
|
|
saveDashboardError: models.ErrDashboardNotFound,
|
|
},
|
|
}
|
|
|
|
for _, test := range testCases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards))
|
|
|
|
dashSvc := dashboards.NewFakeDashboardService(t)
|
|
dashSvc.On("SavePublicDashboardConfig", mock.Anything, mock.AnythingOfType("*dashboards.SavePublicDashboardConfigDTO")).
|
|
Return(&models.PublicDashboardConfig{IsPublic: true}, test.saveDashboardError)
|
|
sc.hs.dashboardService = dashSvc
|
|
|
|
setInitCtxSignedInViewer(sc.initCtx)
|
|
response := callAPI(
|
|
sc.server,
|
|
http.MethodPost,
|
|
"/api/dashboards/uid/1/public-config",
|
|
strings.NewReader(`{ "isPublic": true }`),
|
|
t,
|
|
)
|
|
|
|
assert.Equal(t, test.expectedHttpResponse, response.Code)
|
|
|
|
// check the result if it's a 200
|
|
if response.Code == http.StatusOK {
|
|
val, err := json.Marshal(test.publicDashboardConfig)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(val), response.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// `/public/dashboards/:uid/query`` endpoint test
|
|
func TestAPIQueryPublicDashboard(t *testing.T) {
|
|
queryReturnsError := false
|
|
|
|
qds := query.ProvideService(
|
|
nil,
|
|
&fakeDatasources.FakeCacheService{
|
|
DataSources: []*models.DataSource{
|
|
{Uid: "mysqlds"},
|
|
{Uid: "promds"},
|
|
{Uid: "promds2"},
|
|
},
|
|
},
|
|
nil,
|
|
&fakePluginRequestValidator{},
|
|
&fakeDatasources.FakeDataSourceService{},
|
|
&fakePluginClient{
|
|
QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
|
if queryReturnsError {
|
|
return nil, errors.New("error")
|
|
}
|
|
|
|
resp := backend.Responses{}
|
|
|
|
for _, query := range req.Queries {
|
|
resp[query.RefID] = backend.DataResponse{
|
|
Frames: []*data.Frame{
|
|
{
|
|
RefID: query.RefID,
|
|
Name: "query-" + query.RefID,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
return &backend.QueryDataResponse{Responses: resp}, nil
|
|
},
|
|
},
|
|
&fakeOAuthTokenService{},
|
|
)
|
|
|
|
setup := func(enabled bool) (*webtest.Server, *dashboards.FakeDashboardService) {
|
|
fakeDashboardService := &dashboards.FakeDashboardService{}
|
|
|
|
return SetupAPITestServer(t, func(hs *HTTPServer) {
|
|
hs.queryDataService = qds
|
|
hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards, enabled)
|
|
hs.dashboardService = fakeDashboardService
|
|
}), fakeDashboardService
|
|
}
|
|
|
|
t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) {
|
|
server, _ := setup(false)
|
|
|
|
req := server.NewPostRequest(
|
|
"/api/public/dashboards/abc123/panels/2/query",
|
|
strings.NewReader("{}"),
|
|
)
|
|
resp, err := server.SendJSON(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) {
|
|
server, _ := setup(true)
|
|
|
|
req := server.NewPostRequest(
|
|
"/api/public/dashboards/abc123/panels/notanumber/query",
|
|
strings.NewReader("{}"),
|
|
)
|
|
resp, err := server.SendJSON(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) {
|
|
server, fakeDashboardService := setup(true)
|
|
|
|
fakeDashboardService.On(
|
|
"BuildPublicDashboardMetricRequest",
|
|
mock.Anything,
|
|
"abc123",
|
|
int64(2),
|
|
).Return(dtos.MetricRequest{
|
|
Queries: []*simplejson.Json{
|
|
simplejson.MustJson([]byte(`
|
|
{
|
|
"datasource": {
|
|
"type": "prometheus",
|
|
"uid": "promds"
|
|
},
|
|
"exemplar": true,
|
|
"expr": "query_2_A",
|
|
"interval": "",
|
|
"legendFormat": "",
|
|
"refId": "A"
|
|
}
|
|
`)),
|
|
},
|
|
}, nil)
|
|
req := server.NewPostRequest(
|
|
"/api/public/dashboards/abc123/panels/2/query",
|
|
strings.NewReader("{}"),
|
|
)
|
|
resp, err := server.SendJSON(req)
|
|
require.NoError(t, err)
|
|
bodyBytes, err := ioutil.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.JSONEq(
|
|
t,
|
|
`{
|
|
"results": {
|
|
"A": {
|
|
"frames": [
|
|
{
|
|
"data": {
|
|
"values": []
|
|
},
|
|
"schema": {
|
|
"fields": [],
|
|
"refId": "A",
|
|
"name": "query-A"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}`,
|
|
string(bodyBytes),
|
|
)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("Status code is 500 when the query fails", func(t *testing.T) {
|
|
server, fakeDashboardService := setup(true)
|
|
|
|
fakeDashboardService.On(
|
|
"BuildPublicDashboardMetricRequest",
|
|
mock.Anything,
|
|
"abc123",
|
|
int64(2),
|
|
).Return(dtos.MetricRequest{
|
|
Queries: []*simplejson.Json{
|
|
simplejson.MustJson([]byte(`
|
|
{
|
|
"datasource": {
|
|
"type": "prometheus",
|
|
"uid": "promds"
|
|
},
|
|
"exemplar": true,
|
|
"expr": "query_2_A",
|
|
"interval": "",
|
|
"legendFormat": "",
|
|
"refId": "A"
|
|
}
|
|
`)),
|
|
},
|
|
}, nil)
|
|
req := server.NewPostRequest(
|
|
"/api/public/dashboards/abc123/panels/2/query",
|
|
strings.NewReader("{}"),
|
|
)
|
|
queryReturnsError = true
|
|
resp, err := server.SendJSON(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
|
queryReturnsError = false
|
|
})
|
|
|
|
t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) {
|
|
server, fakeDashboardService := setup(true)
|
|
|
|
fakeDashboardService.On(
|
|
"BuildPublicDashboardMetricRequest",
|
|
mock.Anything,
|
|
"abc123",
|
|
int64(2),
|
|
).Return(dtos.MetricRequest{
|
|
Queries: []*simplejson.Json{
|
|
simplejson.MustJson([]byte(`
|
|
{
|
|
"datasource": {
|
|
"type": "prometheus",
|
|
"uid": "promds"
|
|
},
|
|
"exemplar": true,
|
|
"expr": "query_2_A",
|
|
"interval": "",
|
|
"legendFormat": "",
|
|
"refId": "A"
|
|
}
|
|
`)),
|
|
simplejson.MustJson([]byte(`
|
|
{
|
|
"datasource": {
|
|
"type": "prometheus",
|
|
"uid": "promds2"
|
|
},
|
|
"exemplar": true,
|
|
"expr": "query_2_B",
|
|
"interval": "",
|
|
"legendFormat": "",
|
|
"refId": "B"
|
|
}
|
|
`)),
|
|
},
|
|
}, nil)
|
|
req := server.NewPostRequest(
|
|
"/api/public/dashboards/abc123/panels/2/query",
|
|
strings.NewReader("{}"),
|
|
)
|
|
resp, err := server.SendJSON(req)
|
|
require.NoError(t, err)
|
|
bodyBytes, err := ioutil.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.JSONEq(
|
|
t,
|
|
`{
|
|
"results": {
|
|
"A": {
|
|
"frames": [
|
|
{
|
|
"data": {
|
|
"values": []
|
|
},
|
|
"schema": {
|
|
"fields": [],
|
|
"refId": "A",
|
|
"name": "query-A"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"B": {
|
|
"frames": [
|
|
{
|
|
"data": {
|
|
"values": []
|
|
},
|
|
"schema": {
|
|
"fields": [],
|
|
"refId": "B",
|
|
"name": "query-B"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}`,
|
|
string(bodyBytes),
|
|
)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
}
|