587 lines
17 KiB
Go
587 lines
17 KiB
Go
// Copyright 2019 Prometheus Team
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package v2
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-openapi/runtime"
|
|
"github.com/go-openapi/runtime/middleware"
|
|
"github.com/go-openapi/strfmt"
|
|
"github.com/prometheus/common/model"
|
|
"github.com/prometheus/common/promslog"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
open_api_models "github.com/prometheus/alertmanager/api/v2/models"
|
|
general_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/general"
|
|
receiver_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/receiver"
|
|
silence_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/silence"
|
|
"github.com/prometheus/alertmanager/config"
|
|
"github.com/prometheus/alertmanager/pkg/labels"
|
|
"github.com/prometheus/alertmanager/silence"
|
|
"github.com/prometheus/alertmanager/silence/silencepb"
|
|
"github.com/prometheus/alertmanager/types"
|
|
)
|
|
|
|
// If api.peers == nil, Alertmanager cluster feature is disabled. Make sure to
|
|
// not try to access properties of peer, which would trigger a nil pointer
|
|
// dereference.
|
|
func TestGetStatusHandlerWithNilPeer(t *testing.T) {
|
|
api := API{
|
|
uptime: time.Now(),
|
|
peer: nil,
|
|
alertmanagerConfig: &config.Config{},
|
|
}
|
|
|
|
// Test ensures this method call does not panic.
|
|
status := api.getStatusHandler(general_ops.GetStatusParams{}).(*general_ops.GetStatusOK)
|
|
|
|
c := status.Payload.Cluster
|
|
|
|
if c == nil || c.Status == nil {
|
|
t.Fatal("expected cluster status not to be nil, violating the openapi specification")
|
|
}
|
|
|
|
if c.Peers == nil {
|
|
t.Fatal("expected cluster peers to be not nil when api.peer is nil, violating the openapi specification")
|
|
}
|
|
if len(c.Peers) != 0 {
|
|
t.Fatal("expected cluster peers to be empty when api.peer is nil, violating the openapi specification")
|
|
}
|
|
|
|
if c.Name != "" {
|
|
t.Fatal("expected cluster name to be empty, violating the openapi specification")
|
|
}
|
|
}
|
|
|
|
func assertEqualStrings(t *testing.T, expected, actual string) {
|
|
if expected != actual {
|
|
t.Fatal("expected: ", expected, ", actual: ", actual)
|
|
}
|
|
}
|
|
|
|
var (
|
|
testComment = "comment"
|
|
createdBy = "test"
|
|
)
|
|
|
|
func newSilences(t *testing.T) *silence.Silences {
|
|
silences, err := silence.New(silence.Options{})
|
|
require.NoError(t, err)
|
|
|
|
return silences
|
|
}
|
|
|
|
func gettableSilence(id, state string,
|
|
updatedAt, start, end string,
|
|
) *open_api_models.GettableSilence {
|
|
updAt, err := strfmt.ParseDateTime(updatedAt)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
strAt, err := strfmt.ParseDateTime(start)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
endAt, err := strfmt.ParseDateTime(end)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &open_api_models.GettableSilence{
|
|
Silence: open_api_models.Silence{
|
|
StartsAt: &strAt,
|
|
EndsAt: &endAt,
|
|
Comment: &testComment,
|
|
CreatedBy: &createdBy,
|
|
},
|
|
ID: &id,
|
|
UpdatedAt: &updAt,
|
|
Status: &open_api_models.SilenceStatus{
|
|
State: &state,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestGetSilencesHandler(t *testing.T) {
|
|
updateTime := "2019-01-01T12:00:00+00:00"
|
|
silences := []*open_api_models.GettableSilence{
|
|
gettableSilence("silence-6-expired", "expired", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T11:00:00+00:00"),
|
|
gettableSilence("silence-1-active", "active", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T13:00:00+00:00"),
|
|
gettableSilence("silence-7-expired", "expired", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T10:00:00+00:00"),
|
|
gettableSilence("silence-5-expired", "expired", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"),
|
|
gettableSilence("silence-0-active", "active", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"),
|
|
gettableSilence("silence-4-pending", "pending", updateTime,
|
|
"2019-01-01T13:00:00+00:00", "2019-01-01T12:00:00+00:00"),
|
|
gettableSilence("silence-3-pending", "pending", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"),
|
|
gettableSilence("silence-2-active", "active", updateTime,
|
|
"2019-01-01T12:00:00+00:00", "2019-01-01T14:00:00+00:00"),
|
|
}
|
|
SortSilences(open_api_models.GettableSilences(silences))
|
|
|
|
for i, sil := range silences {
|
|
assertEqualStrings(t, "silence-"+strconv.Itoa(i)+"-"+*sil.Status.State, *sil.ID)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSilenceHandler(t *testing.T) {
|
|
now := time.Now()
|
|
silences := newSilences(t)
|
|
|
|
m := &silencepb.Matcher{Type: silencepb.Matcher_EQUAL, Name: "a", Pattern: "b"}
|
|
|
|
unexpiredSil := &silencepb.Silence{
|
|
Matchers: []*silencepb.Matcher{m},
|
|
StartsAt: now,
|
|
EndsAt: now.Add(time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
require.NoError(t, silences.Set(unexpiredSil))
|
|
|
|
expiredSil := &silencepb.Silence{
|
|
Matchers: []*silencepb.Matcher{m},
|
|
StartsAt: now.Add(-time.Hour),
|
|
EndsAt: now.Add(time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
require.NoError(t, silences.Set(expiredSil))
|
|
require.NoError(t, silences.Expire(expiredSil.Id))
|
|
|
|
for i, tc := range []struct {
|
|
sid string
|
|
expectedCode int
|
|
}{
|
|
{
|
|
"unknownSid",
|
|
404,
|
|
},
|
|
{
|
|
unexpiredSil.Id,
|
|
200,
|
|
},
|
|
{
|
|
expiredSil.Id,
|
|
200,
|
|
},
|
|
} {
|
|
api := API{
|
|
uptime: time.Now(),
|
|
silences: silences,
|
|
logger: promslog.NewNopLogger(),
|
|
}
|
|
|
|
r, err := http.NewRequest("DELETE", "/api/v2/silence/${tc.sid}", nil)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
p := runtime.TextProducer()
|
|
responder := api.deleteSilenceHandler(silence_ops.DeleteSilenceParams{
|
|
SilenceID: strfmt.UUID(tc.sid),
|
|
HTTPRequest: r,
|
|
})
|
|
responder.WriteResponse(w, p)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
|
|
require.Equal(t, tc.expectedCode, w.Code, fmt.Sprintf("test case: %d, response: %s", i, string(body)))
|
|
}
|
|
}
|
|
|
|
func TestPostSilencesHandler(t *testing.T) {
|
|
now := time.Now()
|
|
silences := newSilences(t)
|
|
|
|
m := &silencepb.Matcher{Type: silencepb.Matcher_EQUAL, Name: "a", Pattern: "b"}
|
|
|
|
unexpiredSil := &silencepb.Silence{
|
|
Matchers: []*silencepb.Matcher{m},
|
|
StartsAt: now,
|
|
EndsAt: now.Add(time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
require.NoError(t, silences.Set(unexpiredSil))
|
|
|
|
expiredSil := &silencepb.Silence{
|
|
Matchers: []*silencepb.Matcher{m},
|
|
StartsAt: now.Add(-time.Hour),
|
|
EndsAt: now.Add(time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
require.NoError(t, silences.Set(expiredSil))
|
|
require.NoError(t, silences.Expire(expiredSil.Id))
|
|
|
|
t.Run("Silences CRUD", func(t *testing.T) {
|
|
for i, tc := range []struct {
|
|
name string
|
|
sid string
|
|
start, end time.Time
|
|
expectedCode int
|
|
}{
|
|
{
|
|
"with an non-existent silence ID - it returns 404",
|
|
"unknownSid",
|
|
now.Add(time.Hour),
|
|
now.Add(time.Hour * 2),
|
|
404,
|
|
},
|
|
{
|
|
"with no silence ID - it creates the silence",
|
|
"",
|
|
now.Add(time.Hour),
|
|
now.Add(time.Hour * 2),
|
|
200,
|
|
},
|
|
{
|
|
"with an active silence ID - it extends the silence",
|
|
unexpiredSil.Id,
|
|
now.Add(time.Hour),
|
|
now.Add(time.Hour * 2),
|
|
200,
|
|
},
|
|
{
|
|
"with an expired silence ID - it re-creates the silence",
|
|
expiredSil.Id,
|
|
now.Add(time.Hour),
|
|
now.Add(time.Hour * 2),
|
|
200,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := API{
|
|
uptime: time.Now(),
|
|
silences: silences,
|
|
logger: promslog.NewNopLogger(),
|
|
}
|
|
|
|
sil := createSilence(t, tc.sid, "silenceCreator", tc.start, tc.end)
|
|
w := httptest.NewRecorder()
|
|
postSilences(t, w, api.postSilencesHandler, sil)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
require.Equal(t, tc.expectedCode, w.Code, fmt.Sprintf("test case: %d, response: %s", i, string(body)))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPostSilencesHandlerMissingIdCreatesSilence(t *testing.T) {
|
|
now := time.Now()
|
|
silences := newSilences(t)
|
|
api := API{
|
|
uptime: time.Now(),
|
|
silences: silences,
|
|
logger: promslog.NewNopLogger(),
|
|
}
|
|
|
|
// Create a new silence. It should be assigned a random UUID.
|
|
sil := createSilence(t, "", "silenceCreator", now.Add(time.Hour), now.Add(time.Hour*2))
|
|
w := httptest.NewRecorder()
|
|
postSilences(t, w, api.postSilencesHandler, sil)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Get the silences from the API.
|
|
w = httptest.NewRecorder()
|
|
getSilences(t, w, api.getSilencesHandler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp []open_api_models.GettableSilence
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
|
require.Len(t, resp, 1)
|
|
|
|
// Change the ID. It should return 404 Not Found.
|
|
sil = open_api_models.PostableSilence{
|
|
ID: "unknownID",
|
|
Silence: resp[0].Silence,
|
|
}
|
|
w = httptest.NewRecorder()
|
|
postSilences(t, w, api.postSilencesHandler, sil)
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// Remove the ID. It should duplicate the silence with a different UUID.
|
|
sil = open_api_models.PostableSilence{
|
|
ID: "",
|
|
Silence: resp[0].Silence,
|
|
}
|
|
w = httptest.NewRecorder()
|
|
postSilences(t, w, api.postSilencesHandler, sil)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Get the silences from the API. There should now be 2 silences.
|
|
w = httptest.NewRecorder()
|
|
getSilences(t, w, api.getSilencesHandler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
|
require.Len(t, resp, 2)
|
|
require.NotEqual(t, resp[0].ID, resp[1].ID)
|
|
}
|
|
|
|
func getSilences(
|
|
t *testing.T,
|
|
w *httptest.ResponseRecorder,
|
|
handlerFunc func(params silence_ops.GetSilencesParams) middleware.Responder,
|
|
) {
|
|
r, err := http.NewRequest("GET", "/api/v2/silences", nil)
|
|
require.NoError(t, err)
|
|
|
|
p := runtime.TextProducer()
|
|
responder := handlerFunc(silence_ops.GetSilencesParams{
|
|
HTTPRequest: r,
|
|
Filter: nil,
|
|
})
|
|
responder.WriteResponse(w, p)
|
|
}
|
|
|
|
func postSilences(
|
|
t *testing.T,
|
|
w *httptest.ResponseRecorder,
|
|
handlerFunc func(params silence_ops.PostSilencesParams) middleware.Responder,
|
|
sil open_api_models.PostableSilence,
|
|
) {
|
|
b, err := json.Marshal(sil)
|
|
require.NoError(t, err)
|
|
|
|
r, err := http.NewRequest("POST", "/api/v2/silences", bytes.NewReader(b))
|
|
require.NoError(t, err)
|
|
|
|
p := runtime.TextProducer()
|
|
responder := handlerFunc(silence_ops.PostSilencesParams{
|
|
HTTPRequest: r,
|
|
Silence: &sil,
|
|
})
|
|
responder.WriteResponse(w, p)
|
|
}
|
|
|
|
func TestCheckSilenceMatchesFilterLabels(t *testing.T) {
|
|
type test struct {
|
|
silenceMatchers []*silencepb.Matcher
|
|
filterMatchers []*labels.Matcher
|
|
expected bool
|
|
}
|
|
|
|
tests := []test{
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchEqual)},
|
|
true,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "novalue", labels.MatchEqual)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "(foo|bar)", silencepb.Matcher_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)},
|
|
true,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "foo", silencepb.Matcher_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)},
|
|
false,
|
|
},
|
|
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchRegexp)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchEqual)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)},
|
|
true,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)},
|
|
true,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_EQUAL)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_REGEXP)},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)},
|
|
false,
|
|
},
|
|
{
|
|
[]*silencepb.Matcher{
|
|
createSilenceMatcher(t, "label", "(foo|bar)", silencepb.Matcher_REGEXP),
|
|
createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL),
|
|
},
|
|
[]*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)},
|
|
true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
silence := silencepb.Silence{
|
|
Matchers: test.silenceMatchers,
|
|
}
|
|
actual := CheckSilenceMatchesFilterLabels(&silence, test.filterMatchers)
|
|
if test.expected != actual {
|
|
t.Fatal("unexpected match result between silence and filter. expected:", test.expected, ", actual:", actual)
|
|
}
|
|
}
|
|
}
|
|
|
|
func convertDateTime(ts time.Time) *strfmt.DateTime {
|
|
dt := strfmt.DateTime(ts)
|
|
return &dt
|
|
}
|
|
|
|
func TestAlertToOpenAPIAlert(t *testing.T) {
|
|
var (
|
|
start = time.Now().Add(-time.Minute)
|
|
updated = time.Now()
|
|
active = "active"
|
|
fp = "0223b772b51c29e1"
|
|
receivers = []string{"receiver1", "receiver2"}
|
|
|
|
alert = &types.Alert{
|
|
Alert: model.Alert{
|
|
Labels: model.LabelSet{"severity": "critical", "alertname": "alert1"},
|
|
StartsAt: start,
|
|
},
|
|
UpdatedAt: updated,
|
|
}
|
|
)
|
|
openAPIAlert := AlertToOpenAPIAlert(alert, types.AlertStatus{State: types.AlertStateActive}, receivers, nil)
|
|
require.Equal(t, &open_api_models.GettableAlert{
|
|
Annotations: open_api_models.LabelSet{},
|
|
Alert: open_api_models.Alert{
|
|
Labels: open_api_models.LabelSet{"severity": "critical", "alertname": "alert1"},
|
|
},
|
|
StartsAt: convertDateTime(start),
|
|
EndsAt: convertDateTime(time.Time{}),
|
|
UpdatedAt: convertDateTime(updated),
|
|
Fingerprint: &fp,
|
|
Receivers: []*open_api_models.Receiver{
|
|
{Name: &receivers[0]},
|
|
{Name: &receivers[1]},
|
|
},
|
|
Status: &open_api_models.AlertStatus{
|
|
State: &active,
|
|
InhibitedBy: []string{},
|
|
SilencedBy: []string{},
|
|
MutedBy: []string{},
|
|
},
|
|
}, openAPIAlert)
|
|
}
|
|
|
|
func TestMatchFilterLabels(t *testing.T) {
|
|
sms := map[string]string{
|
|
"foo": "bar",
|
|
}
|
|
|
|
testCases := []struct {
|
|
matcher labels.MatchType
|
|
name string
|
|
val string
|
|
expected bool
|
|
}{
|
|
{labels.MatchEqual, "foo", "bar", true},
|
|
{labels.MatchEqual, "baz", "", true},
|
|
{labels.MatchEqual, "baz", "qux", false},
|
|
{labels.MatchEqual, "baz", "qux|", false},
|
|
{labels.MatchRegexp, "foo", "bar", true},
|
|
{labels.MatchRegexp, "baz", "", true},
|
|
{labels.MatchRegexp, "baz", "qux", false},
|
|
{labels.MatchRegexp, "baz", "qux|", true},
|
|
{labels.MatchNotEqual, "foo", "bar", false},
|
|
{labels.MatchNotEqual, "baz", "", false},
|
|
{labels.MatchNotEqual, "baz", "qux", true},
|
|
{labels.MatchNotEqual, "baz", "qux|", true},
|
|
{labels.MatchNotRegexp, "foo", "bar", false},
|
|
{labels.MatchNotRegexp, "baz", "", false},
|
|
{labels.MatchNotRegexp, "baz", "qux", true},
|
|
{labels.MatchNotRegexp, "baz", "qux|", false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
m, err := labels.NewMatcher(tc.matcher, tc.name, tc.val)
|
|
require.NoError(t, err)
|
|
|
|
ms := []*labels.Matcher{m}
|
|
require.Equal(t, tc.expected, matchFilterLabels(ms, sms))
|
|
}
|
|
}
|
|
|
|
func TestGetReceiversHandler(t *testing.T) {
|
|
in := `
|
|
route:
|
|
receiver: team-X
|
|
|
|
receivers:
|
|
- name: 'team-X'
|
|
- name: 'team-Y'
|
|
`
|
|
cfg, _ := config.Load(in)
|
|
api := API{
|
|
uptime: time.Now(),
|
|
logger: promslog.NewNopLogger(),
|
|
alertmanagerConfig: cfg,
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
body string
|
|
expectedCode int
|
|
}{
|
|
{
|
|
`[{"name":"team-X"},{"name":"team-Y"}]`,
|
|
200,
|
|
},
|
|
} {
|
|
r, err := http.NewRequest("GET", "/api/v2/receivers", nil)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
p := runtime.TextProducer()
|
|
responder := api.getReceiversHandler(receiver_ops.GetReceiversParams{
|
|
HTTPRequest: r,
|
|
})
|
|
responder.WriteResponse(w, p)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
|
|
require.Equal(t, tc.expectedCode, w.Code)
|
|
require.Equal(t, tc.body, string(body))
|
|
}
|
|
}
|