Alerting: Add provisioning GET routes for message templates (#48367)

* Template service

* Add GET routes and implement them

* Generate mock for persist layer

* Unit tests for reading templates

* Set up composition root and get integration tests working

* Fix prealloc issue

* Extract setup boilerplate

* Update AuthorizationTest

* Rebase and resolve

* Fix linter error
This commit is contained in:
Alexander Weaver 2022-04-28 13:51:57 -05:00 committed by GitHub
parent d4616cfe26
commit 735822e48a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 529 additions and 9 deletions

View File

@ -78,6 +78,7 @@ type API struct {
AccessControl accesscontrol.AccessControl
Policies *provisioning.NotificationPolicyService
ContactPointService *provisioning.ContactPointService
Templates *provisioning.TemplateService
}
// RegisterAPIEndpoints registers API handlers
@ -136,6 +137,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
log: logger,
policies: api.Policies,
contactPointService: api.ContactPointService,
templates: api.Templates,
}), m)
}
}

View File

@ -20,6 +20,7 @@ type ProvisioningSrv struct {
log log.Logger
policies NotificationPolicyService
contactPointService ContactPointService
templates TemplateService
}
type ContactPointService interface {
@ -29,6 +30,10 @@ type ContactPointService interface {
DeleteContactPoint(ctx context.Context, orgID int64, uid string) error
}
type TemplateService interface {
GetTemplates(ctx context.Context, orgID int64) (map[string]string, error)
}
type NotificationPolicyService interface {
GetPolicyTree(ctx context.Context, orgID int64) (apimodels.Route, error)
UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p alerting_models.Provenance) error
@ -95,3 +100,27 @@ func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) respon
}
return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"})
}
func (srv *ProvisioningSrv) RouteGetTemplates(c *models.ReqContext) response.Response {
templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
result := make([]apimodels.MessageTemplate, 0, len(templates))
for k, v := range templates {
result = append(result, apimodels.MessageTemplate{Name: k, Template: v})
}
return response.JSON(http.StatusOK, result)
}
func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext) response.Response {
id := web.Params(c.Req)[":ID"]
templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
if tmpl, ok := templates[id]; ok {
return response.JSON(http.StatusOK, apimodels.MessageTemplate{Name: id, Template: tmpl})
}
return response.Empty(http.StatusNotFound)
}

View File

@ -181,7 +181,9 @@ func (api *API) authorize(method, path string) web.Handler {
// Grafana-only Provisioning Read Paths
case http.MethodGet + "/api/provisioning/policies",
http.MethodGet + "/api/provisioning/contact-points":
http.MethodGet + "/api/provisioning/contact-points",
http.MethodGet + "/api/provisioning/templates",
http.MethodGet + "/api/provisioning/templates/{ID}":
return middleware.ReqSignedIn
case http.MethodPost + "/api/provisioning/policies",

View File

@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
require.Len(t, paths, 32)
require.Len(t, paths, 34)
ac := acmock.New()
api := &API{AccessControl: ac}

View File

@ -42,3 +42,11 @@ func (f *ForkedProvisioningApi) forkRoutePutContactpoints(ctx *models.ReqContext
func (f *ForkedProvisioningApi) forkRouteDeleteContactpoints(ctx *models.ReqContext) response.Response {
return f.svc.RouteDeleteContactPoint(ctx)
}
func (f *ForkedProvisioningApi) forkRouteGetTemplates(ctx *models.ReqContext) response.Response {
return f.svc.RouteGetTemplates(ctx)
}
func (f *ForkedProvisioningApi) forkRouteGetTemplate(ctx *models.ReqContext) response.Response {
return f.svc.RouteGetTemplate(ctx)
}

View File

@ -23,6 +23,8 @@ type ProvisioningApiForkingService interface {
RouteDeleteContactpoints(*models.ReqContext) response.Response
RouteGetContactpoints(*models.ReqContext) response.Response
RouteGetPolicyTree(*models.ReqContext) response.Response
RouteGetTemplate(*models.ReqContext) response.Response
RouteGetTemplates(*models.ReqContext) response.Response
RoutePostContactpoints(*models.ReqContext) response.Response
RoutePostPolicyTree(*models.ReqContext) response.Response
RoutePutContactpoints(*models.ReqContext) response.Response
@ -40,6 +42,14 @@ func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) respo
return f.forkRouteGetPolicyTree(ctx)
}
func (f *ForkedProvisioningApi) RouteGetTemplate(ctx *models.ReqContext) response.Response {
return f.forkRouteGetTemplate(ctx)
}
func (f *ForkedProvisioningApi) RouteGetTemplates(ctx *models.ReqContext) response.Response {
return f.forkRouteGetTemplates(ctx)
}
func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) response.Response {
conf := apimodels.EmbeddedContactPoint{}
if err := web.Bind(ctx.Req, &conf); err != nil {
@ -96,6 +106,26 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Get(
toMacaronPath("/api/provisioning/templates/{ID}"),
api.authorize(http.MethodGet, "/api/provisioning/templates/{ID}"),
metrics.Instrument(
http.MethodGet,
"/api/provisioning/templates/{ID}",
srv.RouteGetTemplate,
m,
),
)
group.Get(
toMacaronPath("/api/provisioning/templates"),
api.authorize(http.MethodGet, "/api/provisioning/templates"),
metrics.Instrument(
http.MethodGet,
"/api/provisioning/templates",
srv.RouteGetTemplates,
m,
),
)
group.Post(
toMacaronPath("/api/provisioning/contact-points"),
api.authorize(http.MethodPost, "/api/provisioning/contact-points"),

View File

@ -0,0 +1,24 @@
package definitions
// swagger:route GET /api/provisioning/templates provisioning RouteGetTemplates
//
// Get all message templates.
//
// Responses:
// 200: []MessageTemplate
// 400: ValidationError
// swagger:route GET /api/provisioning/templates/{ID} provisioning RouteGetTemplate
//
// Get a message template.
//
// Responses:
// 200: MessageTemplate
// 404: NotFound
type MessageTemplate struct {
Name string
Template string
}
type NotFound struct{}

View File

@ -2868,6 +2868,7 @@
"x-go-package": "github.com/prometheus/alertmanager/timeinterval"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -2900,9 +2901,9 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object",
"x-go-package": "github.com/prometheus/common/config"
"x-go-package": "net/url"
},
"Userinfo": {
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
@ -3553,7 +3554,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"name": {
"description": "name",
@ -3564,7 +3564,9 @@
"required": [
"name"
],
"type": "object"
"type": "object",
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"silence": {
"description": "Silence silence",
@ -4956,6 +4958,43 @@
]
}
},
"/api/provisioning/templates": {
"get": {
"operationId": "RouteGetTemplates",
"responses": {
"200": {
"$ref": "#/responses/MessageTemplate"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Get all message templates.",
"tags": [
"provisioning"
]
}
},
"/api/provisioning/templates/{ID}": {
"get": {
"operationId": "RouteGetTemplate",
"responses": {
"200": {
"$ref": "#/responses/MessageTemplate"
},
"404": {
"$ref": "#/responses/NotFound"
}
},
"summary": "Get a message template.",
"tags": [
"provisioning"
]
}
},
"/api/ruler/grafana/api/v1/rules": {
"get": {
"description": "List rule groups",

View File

@ -1295,6 +1295,43 @@
}
}
},
"/api/provisioning/templates": {
"get": {
"tags": [
"provisioning"
],
"summary": "Get all message templates.",
"operationId": "RouteGetTemplates",
"responses": {
"200": {
"$ref": "#/responses/MessageTemplate"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
}
},
"/api/provisioning/templates/{ID}": {
"get": {
"tags": [
"provisioning"
],
"summary": "Get a message template.",
"operationId": "RouteGetTemplate",
"responses": {
"200": {
"$ref": "#/responses/MessageTemplate"
},
"404": {
"$ref": "#/responses/NotFound"
}
}
}
},
"/api/ruler/grafana/api/v1/rules": {
"get": {
"description": "List rule groups",
@ -4788,8 +4825,9 @@
"x-go-package": "github.com/prometheus/alertmanager/timeinterval"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -4822,7 +4860,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"x-go-package": "github.com/prometheus/common/config"
"x-go-package": "net/url"
},
"Userinfo": {
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
@ -5480,7 +5518,6 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"name"
@ -5492,6 +5529,8 @@
"x-go-name": "Name"
}
},
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/receiver"
},
"silence": {

View File

@ -140,6 +140,7 @@ func (ng *AlertNG) init() error {
// Provisioning
policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log)
contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log)
templateService := provisioning.NewTemplateService(store, store, store, ng.Log)
api := api.API{
Cfg: ng.Cfg,
@ -160,6 +161,7 @@ func (ng *AlertNG) init() error {
AccessControl: ng.accesscontrol,
Policies: policyService,
ContactPointService: contactPointService,
Templates: templateService,
}
api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())

View File

@ -7,6 +7,7 @@ import (
)
// AMStore is a store of Alertmanager configurations.
//go:generate mockery --name AMConfigStore --structname MockAMConfigStore --inpackage --filename persist_mock.go --with-expecter
type AMConfigStore interface {
GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error
UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error

View File

@ -0,0 +1,111 @@
// Code generated by mockery v2.12.0. DO NOT EDIT.
package provisioning
import (
context "context"
models "github.com/grafana/grafana/pkg/services/ngalert/models"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// MockAMConfigStore is an autogenerated mock type for the AMConfigStore type
type MockAMConfigStore struct {
mock.Mock
}
type MockAMConfigStore_Expecter struct {
mock *mock.Mock
}
func (_m *MockAMConfigStore) EXPECT() *MockAMConfigStore_Expecter {
return &MockAMConfigStore_Expecter{mock: &_m.Mock}
}
// GetLatestAlertmanagerConfiguration provides a mock function with given fields: ctx, query
func (_m *MockAMConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error {
ret := _m.Called(ctx, query)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.GetLatestAlertmanagerConfigurationQuery) error); ok {
r0 = rf(ctx, query)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestAlertmanagerConfiguration'
type MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call struct {
*mock.Call
}
// GetLatestAlertmanagerConfiguration is a helper method to define mock.On call
// - ctx context.Context
// - query *models.GetLatestAlertmanagerConfigurationQuery
func (_e *MockAMConfigStore_Expecter) GetLatestAlertmanagerConfiguration(ctx interface{}, query interface{}) *MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call {
return &MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call{Call: _e.mock.On("GetLatestAlertmanagerConfiguration", ctx, query)}
}
func (_c *MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call) Run(run func(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery)) *MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*models.GetLatestAlertmanagerConfigurationQuery))
})
return _c
}
func (_c *MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call) Return(_a0 error) *MockAMConfigStore_GetLatestAlertmanagerConfiguration_Call {
_c.Call.Return(_a0)
return _c
}
// UpdateAlertmanagerConfiguration provides a mock function with given fields: ctx, cmd
func (_m *MockAMConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error {
ret := _m.Called(ctx, cmd)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.SaveAlertmanagerConfigurationCmd) error); ok {
r0 = rf(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAMConfigStore_UpdateAlertmanagerConfiguration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAlertmanagerConfiguration'
type MockAMConfigStore_UpdateAlertmanagerConfiguration_Call struct {
*mock.Call
}
// UpdateAlertmanagerConfiguration is a helper method to define mock.On call
// - ctx context.Context
// - cmd *models.SaveAlertmanagerConfigurationCmd
func (_e *MockAMConfigStore_Expecter) UpdateAlertmanagerConfiguration(ctx interface{}, cmd interface{}) *MockAMConfigStore_UpdateAlertmanagerConfiguration_Call {
return &MockAMConfigStore_UpdateAlertmanagerConfiguration_Call{Call: _e.mock.On("UpdateAlertmanagerConfiguration", ctx, cmd)}
}
func (_c *MockAMConfigStore_UpdateAlertmanagerConfiguration_Call) Run(run func(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd)) *MockAMConfigStore_UpdateAlertmanagerConfiguration_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*models.SaveAlertmanagerConfigurationCmd))
})
return _c
}
func (_c *MockAMConfigStore_UpdateAlertmanagerConfiguration_Call) Return(_a0 error) *MockAMConfigStore_UpdateAlertmanagerConfiguration_Call {
_c.Call.Return(_a0)
return _c
}
// NewMockAMConfigStore creates a new instance of MockAMConfigStore. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockAMConfigStore(t testing.TB) *MockAMConfigStore {
mock := &MockAMConfigStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,48 @@
package provisioning
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type TemplateService struct {
config AMConfigStore
prov ProvisioningStore
xact TransactionManager
log log.Logger
}
func NewTemplateService(config AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *TemplateService {
return &TemplateService{
config: config,
prov: prov,
xact: xact,
log: log,
}
}
func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) (map[string]string, error) {
q := models.GetLatestAlertmanagerConfigurationQuery{
OrgID: orgID,
}
err := t.config.GetLatestAlertmanagerConfiguration(ctx, &q)
if err != nil {
return nil, err
}
if q.Result == nil {
return nil, fmt.Errorf("no alertmanager configuration present in this org")
}
cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration))
if err != nil {
return nil, err
}
if cfg.TemplateFiles == nil {
return map[string]string{}, nil
}
return cfg.TemplateFiles, nil
}

View File

@ -0,0 +1,140 @@
package provisioning
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/setting"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestTemplateService(t *testing.T) {
t.Run("service returns templates from config file", func(t *testing.T) {
sut := createTemplateServiceSut()
sut.config.(*MockAMConfigStore).EXPECT().
setupGetConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithTemplates,
})
result, err := sut.GetTemplates(context.Background(), 1)
require.NoError(t, err)
require.Len(t, result, 1)
})
t.Run("service returns empty map when config file contains no templates", func(t *testing.T) {
sut := createTemplateServiceSut()
sut.config.(*MockAMConfigStore).EXPECT().
setupGetConfig(models.AlertConfiguration{
AlertmanagerConfiguration: defaultConfig,
})
result, err := sut.GetTemplates(context.Background(), 1)
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("service propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut := createTemplateServiceSut()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed"))
_, err := sut.GetTemplates(context.Background(), 1)
require.Error(t, err)
})
t.Run("when config is invalid", func(t *testing.T) {
sut := createTemplateServiceSut()
sut.config.(*MockAMConfigStore).EXPECT().
setupGetConfig(models.AlertConfiguration{
AlertmanagerConfiguration: brokenConfig,
})
_, err := sut.GetTemplates(context.Background(), 1)
require.ErrorContains(t, err, "failed to deserialize")
})
t.Run("when no AM config in current org", func(t *testing.T) {
sut := createTemplateServiceSut()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(nil)
_, err := sut.GetTemplates(context.Background(), 1)
require.ErrorContains(t, err, "no alertmanager configuration")
})
})
}
func createTemplateServiceSut() *TemplateService {
return &TemplateService{
config: &MockAMConfigStore{},
prov: NewFakeProvisioningStore(),
xact: newNopTransactionManager(),
log: log.NewNopLogger(),
}
}
func (m *MockAMConfigStore_Expecter) setupGetConfig(ac models.AlertConfiguration) *MockAMConfigStore_Expecter {
m.GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Run(func(ctx context.Context, q *models.GetLatestAlertmanagerConfigurationQuery) {
q.Result = &ac
}).
Return(nil)
return m
}
var defaultConfig = setting.GetAlertmanagerDefaultConfiguration()
var configWithTemplates = `
{
"template_files": {
"a": "template"
},
"alertmanager_config": {
"route": {
"receiver": "grafana-default-email"
},
"receivers": [{
"name": "grafana-default-email",
"grafana_managed_receiver_configs": [{
"uid": "",
"name": "email receiver",
"type": "email",
"isDefault": true,
"settings": {
"addresses": "<example@email.com>"
}
}]
}]
}
}
`
var brokenConfig = `
"alertmanager_config": {
"route": {
"receiver": "grafana-default-email"
},
"receivers": [{
"name": "grafana-default-email",
"grafana_managed_receiver_configs": [{
"uid": "abc",
"name": "default-email",
"type": "email",
"isDefault": true,
"settings": {}
}]
}]
}
}`

View File

@ -136,6 +136,7 @@ func TestProvisioning(t *testing.T) {
require.Equal(t, 202, resp.StatusCode)
})
})
t.Run("when provisioning contactpoints", func(t *testing.T) {
url := fmt.Sprintf("http://%s/api/provisioning/contact-points", grafanaListedAddr)
body := `
@ -228,6 +229,50 @@ func TestProvisioning(t *testing.T) {
require.Equal(t, 202, resp.StatusCode)
})
})
t.Run("when provisioning templates", func(t *testing.T) {
url := fmt.Sprintf("http://%s/api/provisioning/templates", grafanaListedAddr)
t.Run("un-authenticated GET should 401", func(t *testing.T) {
req := createTestRequest("GET", url, "", "")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, 401, resp.StatusCode)
})
t.Run("viewer GET should succeed", func(t *testing.T) {
req := createTestRequest("GET", url, "viewer", "")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, 200, resp.StatusCode)
})
t.Run("editor GET should succeed", func(t *testing.T) {
req := createTestRequest("GET", url, "editor", "")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, 200, resp.StatusCode)
})
t.Run("admin GET should succeed", func(t *testing.T) {
req := createTestRequest("GET", url, "admin", "")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, 200, resp.StatusCode)
})
})
}
func createTestRequest(method string, url string, user string, body string) *http.Request {