From 70f0fca5a2ecaedd381304990a13bfcd60698c53 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 20 Aug 2026 04:14:20 +0000 Subject: [PATCH] Render delivery attempt detail in the event log (closes #202) Expanding a delivery on the event log page now shows each recorded attempt: attempt number, outcome, status code, duration, error and response body. Previously a failure rendered as "target: failed" and diagnosing it meant opening the per-webhook SQLite file by hand. The response body is cut by SQLite via substr over a blob cast, the same projection the event body uses, so an oversized stored response never becomes a Go string. The page reports the cut with a marker. Response bodies and errors are remote content, so both go through a new delivery.Redactor that strips the target's own destination URL, path, query and userinfo, plus the values of credential-shaped request headers, before rendering. A cut body goes through RedactCut as well, which drops any tail that is a proper prefix of a secret: the remote chooses the padding in front of a credential it echoes, so it chooses where the cut falls inside that credential. Target configuration keeps reaching the template only as a TargetView. Redactors are built from an unscoped target load. Deleting a target only soft deletes the row while its deliveries survive, and a scoped load would leave exactly those deliveries rendering unredacted. The views the page lists stay scoped. Attempt loading is chunked so the IN clause cannot exceed SQLite's bound-parameter limit, its error is reported rather than discarded, and the page renders at most 20 attempts per delivery, counting what it leaves out. static/css/tailwind.css is regenerated with the repo's pinned tailwindcss for the utility classes the new markup uses. --- internal/delivery/target_redact.go | 247 ++++++++++ internal/delivery/target_redact_test.go | 237 +++++++++ internal/handlers/delivery_result_view.go | 137 ++++++ .../handlers/delivery_result_view_test.go | 458 ++++++++++++++++++ internal/handlers/export_test.go | 8 + internal/handlers/source_management.go | 238 +++++++-- static/css/tailwind.css | 2 +- templates/source_logs.html | 51 ++ 8 files changed, 1350 insertions(+), 28 deletions(-) create mode 100644 internal/delivery/target_redact.go create mode 100644 internal/delivery/target_redact_test.go create mode 100644 internal/handlers/delivery_result_view.go create mode 100644 internal/handlers/delivery_result_view_test.go diff --git a/internal/delivery/target_redact.go b/internal/delivery/target_redact.go new file mode 100644 index 0000000..4c18241 --- /dev/null +++ b/internal/delivery/target_redact.go @@ -0,0 +1,247 @@ +package delivery + +import ( + "net/url" + "slices" + "strings" + + "sneak.berlin/go/webhooker/internal/database" +) + +// RedactionMarker stands in for a target credential found in +// text the target's remote peer chose. +const RedactionMarker = "(redacted)" + +// Redactor removes one target's own credential material from +// text that target's remote peer chose: a delivery response +// body, or a delivery error stored before the delivery path +// learned to mask the URLs it embeds. +// +// It removes byte-identical echoes of strings taken from the +// target's stored configuration, and nothing else. Anything +// the remote re-encodes survives: JSON "\/" escaping (what +// PHP's json_encode emits by default), percent-encoding, HTML +// entities, and an echo of only part of a path. It cannot +// remove a secret the remote invented. +// +// The zero Redactor removes nothing, which is what a caller +// holding no target for a delivery gets. +type Redactor struct { + secrets []string +} + +// NewRedactor builds the redactor for one target. +func NewRedactor(t *database.Target) Redactor { + secrets := targetSecrets(t) + + // Longest first, so replacing a secret that is contained + // in a longer one cannot leave a fragment of the longer + // one behind. Configured headers arrive in map order, so + // the sort is also what makes the result deterministic. + slices.SortFunc(secrets, func(a, b string) int { + if d := len(b) - len(a); d != 0 { + return d + } + + return strings.Compare(a, b) + }) + + return Redactor{secrets: secrets} +} + +// Redact replaces every occurrence of the target's credential +// material in s. +func (r Redactor) Redact(s string) string { + if s == "" { + return s + } + + for _, secret := range r.secrets { + s = strings.ReplaceAll(s, secret, RedactionMarker) + } + + return s +} + +// RedactCut redacts s, which its caller has already cut to a +// byte budget, and additionally drops any tail of s that is a +// proper prefix of a secret. +// +// The cut lands wherever the remote's padding puts it, so the +// remote chooses where inside the credential it falls. The +// severed prefix left behind equals no secret, so plain +// Redact would render it verbatim. +func (r Redactor) RedactCut(s string) string { + s = r.Redact(s) + + if n := r.secretPrefixSuffix(s); n > 0 { + return s[:len(s)-n] + RedactionMarker + } + + return s +} + +// secretPrefixSuffix returns the length of the longest suffix +// of s that is a proper prefix of one of the secrets, or 0 +// when there is none. +func (r Redactor) secretPrefixSuffix(s string) int { + longest := 0 + + for _, secret := range r.secrets { + // Proper prefixes only: a whole secret at the tail was + // already replaced by Redact. + n := min(len(secret)-1, len(s)) + for ; n > longest; n-- { + if strings.HasSuffix(s, secret[:n]) { + longest = n + + break + } + } + } + + return longest +} + +// targetSecrets returns the credential-bearing strings a +// target's configuration carries. +// +// The destination URL contributes. Its path, query and +// userinfo are the credential for both target types that have +// one — an incoming-webhook URL is a bearer token, which is +// why MaskURL elides exactly those parts — and they are the +// material this service actually sends, so a remote that +// echoes the request back echoes them. +// +// Configured request headers contribute their values, but +// only for the credential-shaped names isCredentialHeaderName +// picks out. That is the same class-based rule applied to +// URLs: an echoed Accept or User-Agent still renders, an +// echoed Authorization does not. +func targetSecrets(t *database.Target) []string { + if t == nil { + return nil + } + + switch t.Type { + case database.TargetTypeSlack: + cfg, err := parseSlackConfig(t.Config) + if err != nil { + return nil + } + + return urlSecrets(cfg.WebhookURL) + case database.TargetTypeHTTP: + cfg, err := parseHTTPConfig(t.Config) + if err != nil { + return nil + } + + return append( + urlSecrets(cfg.URL), + headerSecrets(cfg.Headers)..., + ) + case database.TargetTypeDatabase, database.TargetTypeLog: + // Neither has a destination URL, so neither has + // anything to redact. + return nil + default: + return nil + } +} + +// urlSecrets returns the substrings of a destination URL that +// must not survive into a rendered page: the whole URL, the +// parts of it MaskURL elides, and any userinfo. +// +// No length floor is applied to the path. A short path is +// treated as a credential exactly like a long one, because +// the field takes an arbitrary URL and no segment can be +// assumed non-secret — the same rule MaskURL applies. +func urlSecrets(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + secrets := []string{raw} + + parsed, err := url.Parse(raw) + if err != nil { + return secrets + } + + if parsed.Path != "" && parsed.Path != "/" { + requestURI := parsed.RequestURI() + secrets = append(secrets, requestURI) + + if escaped := parsed.EscapedPath(); escaped != requestURI { + secrets = append(secrets, escaped) + } + } + + if parsed.User != nil { + secrets = append(secrets, parsed.User.String()) + + if pw, ok := parsed.User.Password(); ok && pw != "" { + secrets = append(secrets, pw) + } + } + + return secrets +} + +// minHeaderSecretBytes is the shortest header value treated as +// a credential. Unlike a URL path, a header value can be a +// couple of bytes long, and redacting those would scatter the +// marker through ordinary response text for no gain. +const minHeaderSecretBytes = 4 + +// headerSecrets returns the values of the configured headers +// whose names are credential-shaped. +func headerSecrets(headers map[string]string) []string { + var secrets []string + + for name, value := range headers { + value = strings.TrimSpace(value) + if len(value) < minHeaderSecretBytes { + continue + } + + if isCredentialHeaderName(name) { + secrets = append(secrets, value) + } + } + + return secrets +} + +// isCredentialHeaderName classifies a header by its name. The +// value is never inspected, so the rule is the same +// class-based one MaskURL applies to a destination URL. +func isCredentialHeaderName(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + + // Names that carry a credential by definition. + switch name { + case "authorization", "proxy-authorization", "cookie": + return true + } + + // What operators call their own credential headers: + // X-Api-Key, X-Hub-Signature, X-Auth-Token. + for _, fragment := range []string{ + "auth", + "key", + "password", + "secret", + "signature", + "token", + } { + if strings.Contains(name, fragment) { + return true + } + } + + return false +} diff --git a/internal/delivery/target_redact_test.go b/internal/delivery/target_redact_test.go new file mode 100644 index 0000000..66e5242 --- /dev/null +++ b/internal/delivery/target_redact_test.go @@ -0,0 +1,237 @@ +package delivery_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// The secret path segments of a Slack incoming webhook URL. +const ( + redactSecretPath = "/services/T11111111/B11111111/" + + "YYYYYYYYYYYYYYYYYYYYYYYY" + redactWebhookURL = "https://hooks.slack.com" + + redactSecretPath +) + +func TestRedactor_RemovesSlackWebhookURL(t *testing.T) { + t.Parallel() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + }) + + got := r.Redact("no_service for " + redactWebhookURL) + + assert.NotContains(t, got, redactSecretPath) + assert.NotContains(t, got, "T11111111") + + // One marker, not a marker with the host left in front of + // it: the whole URL is replaced before the path it + // contains, which is what sorting the secrets longest + // first buys. + assert.Equal( + t, + "no_service for "+delivery.RedactionMarker, + got, + ) +} + +// TestRedactor_RemovesSecretSeveredByACut covers the input the +// redactor exists for: text cut to a byte budget with the +// credential straddling the cut. The remote chooses the +// padding, so it chooses where the cut lands inside the +// credential, and the severed prefix that remains equals no +// secret. +func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) { + t.Parallel() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + }) + + // Every cut position inside the credential, not just a + // convenient one. + for n := 1; n < len(redactWebhookURL); n++ { + severed := redactWebhookURL[:n] + cut := "padding " + severed + + got := r.RedactCut(cut) + + assert.Equal( + t, + "padding "+delivery.RedactionMarker, + got, + "cut after %d bytes of the credential", n, + ) + } +} + +// TestRedactor_RedactsCredentialShapedHeaderValues pins the +// class-based header rule: a header whose name says credential +// has its value redacted, and a routine header does not, so +// ordinary response content survives. +func TestRedactor_RedactsCredentialShapedHeaderValues( + t *testing.T, +) { + t.Parallel() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeHTTP, + Config: `{"url":"https://example.com/in",` + + `"headers":{` + + `"Authorization":"Bearer AAAAAAAAAAAA",` + + `"Cookie":"session=BBBBBBBBBBBB",` + + `"X-Api-Key":"CCCCCCCCCCCC",` + + `"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` + + `"Accept":"application/json",` + + `"User-Agent":"webhooker/1.0"}}`, + }) + + for _, secret := range []string{ + "Bearer AAAAAAAAAAAA", + "session=BBBBBBBBBBBB", + "CCCCCCCCCCCC", + "sha256=DDDDDDDDDDDD", + } { + got := r.Redact("echo: " + secret) + + assert.Equal( + t, + "echo: "+delivery.RedactionMarker, + got, + secret, + ) + } + + const routine = "Accept: application/json, " + + "User-Agent: webhooker/1.0" + + assert.Equal(t, routine, r.Redact(routine)) +} + +// TestRedactor_IgnoresVeryShortHeaderValues pins the floor +// under a header value. Redacting a two-byte value would put +// the marker through every response that happens to contain +// those bytes. +func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) { + t.Parallel() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeHTTP, + Config: `{"url":"https://example.com/in",` + + `"headers":{"X-Api-Key":"ab"}}`, + }) + + const response = "rabbit" + + assert.Equal(t, response, r.Redact(response)) +} + +// TestRedactor_RemovesBarePath covers a remote that echoes +// only the request path rather than the whole URL. The path +// segments are the credential on their own. +func TestRedactor_RemovesBarePath(t *testing.T) { + t.Parallel() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + }) + + got := r.Redact("POST " + redactSecretPath + " 404") + + assert.NotContains(t, got, redactSecretPath) + assert.Equal( + t, + "POST "+delivery.RedactionMarker+" 404", + got, + ) +} + +// TestRedactor_RemovesHTTPURLQueryAndUserinfo covers the HTTP +// target, whose destination is an arbitrary URL: the query +// string and the userinfo carry credentials as readily as the +// path does. +func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { + t.Parallel() + + // Assembled rather than written out, so the literal is + // not itself a credential-shaped string. + dest := url.URL{ + Scheme: "https", + User: url.UserPassword("user", "hunter2"), + Host: "example.com", + Path: "/in", + RawQuery: "token=s3cr3t", + } + raw := dest.String() + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeHTTP, + Config: `{"url":"` + raw + `"}`, + }) + + for _, echoed := range []string{ + raw, + "/in?token=s3cr3t", + "hunter2", + } { + got := r.Redact("rejected: " + echoed) + + assert.NotContains(t, got, "s3cr3t", echoed) + assert.NotContains(t, got, "hunter2", echoed) + assert.Contains( + t, got, delivery.RedactionMarker, echoed, + ) + } +} + +// TestRedactor_LeavesUnrelatedTextAlone pins that the +// redactor matches literally: it does not guess at what a +// secret looks like, so ordinary response content survives. +func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) { + t.Parallel() + + const response = "ok=false error=channel_not_found" + + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + }) + + assert.Equal(t, response, r.Redact(response)) +} + +// TestRedactor_ZeroValueAndConfiglessTargets pins that a +// caller with no target, an unparseable config, or a target +// type with no destination URL gets a redactor that changes +// nothing rather than one that panics. +func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) { + t.Parallel() + + const text = "some response body" + + var zero delivery.Redactor + + assert.Equal(t, text, zero.Redact(text)) + assert.Equal(t, text, delivery.NewRedactor(nil).Redact(text)) + + for _, tgt := range []database.Target{ + {Type: database.TargetTypeLog}, + {Type: database.TargetTypeDatabase}, + {Type: database.TargetTypeSlack, Config: "not json"}, + {Type: database.TargetTypeHTTP, Config: ""}, + } { + assert.Equal( + t, text, + delivery.NewRedactor(&tgt).Redact(text), + tgt.Type, + ) + } +} diff --git a/internal/handlers/delivery_result_view.go b/internal/handlers/delivery_result_view.go new file mode 100644 index 0000000..dfc3b86 --- /dev/null +++ b/internal/handlers/delivery_result_view.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "sneak.berlin/go/webhooker/internal/delivery" +) + +// maxRenderedResponseBytes caps how many bytes of one stored +// delivery response body reach the event log page. +// +// It matches the cap the delivery engine applies when it +// records a result, so nothing written by the current engine +// is cut twice. The bound is enforced here anyway, and in +// SQL: this page's memory profile must not depend on a +// constant in another package staying where it is, and rows +// predating that cap or restored from an archive are not +// covered by it at all. +const maxRenderedResponseBytes = 4096 + +// deliveryResultColumns is the delivery attempt projection. +// The casts to blob are load-bearing for the same reason they +// are in eventLogColumns: they make substr and length count +// bytes rather than characters, and they make SQLite do the +// cut, so an oversized stored response never becomes a Go +// string at all. +const deliveryResultColumns = "delivery_id, attempt_num, success, " + + "status_code, error, duration, " + + "substr(cast(response_body as blob), 1, ?) AS response_body, " + + "length(cast(response_body as blob)) AS response_bytes" + +// DeliveryResultView is the display-safe projection of one +// delivery attempt for the event log page. It carries a +// capped response body plus the true stored size, so the page +// can mark a response as truncated without holding the whole +// thing. +// +// Both Error and ResponseBody have been through the target's +// Redactor. The engine already masks the URL out of the +// errors it stores, so for errors this is a second line +// covering rows written before it did; for response bodies it +// is the only line, and its reach is what +// delivery.Redactor documents. +type DeliveryResultView struct { + AttemptNum int + Success bool + + // StatusCode is 0 when the attempt never got a response, + // which is why the page asks HasStatusCode rather than + // printing the number. + StatusCode int + + // Error is the stored failure message, redacted. + Error string + + // DurationMS is how long the attempt took. + DurationMS int64 + + // ResponseBody holds at most maxRenderedResponseBytes + // bytes of the stored response, redacted. It is remote + // content and must only ever be rendered escaped. + ResponseBody string + + // ResponseBytes is the true size of the stored response + // body, before the cut and before redaction. + ResponseBytes int64 + + // ResponseShownBytes is how much of that the page is + // showing. It is the size of the cut, taken before + // redaction, so the truncation marker reports what SQLite + // returned rather than how much the marker substitution + // then changed the length. + ResponseShownBytes int + + // ResponseTruncated reports that the stored response was + // larger than the cap, so the page owes the reader a + // marker. + ResponseTruncated bool +} + +// HasStatusCode reports whether the attempt got as far as an +// HTTP response. A transport failure stores no status code, +// and rendering that as "0" would read as a real status. +func (v DeliveryResultView) HasStatusCode() bool { + return v.StatusCode != 0 +} + +// deliveryResultRow is one row of the delivery attempt +// projection. Its response body arrives already cut to the +// cap by SQLite, with the true size beside it. +type deliveryResultRow struct { + DeliveryID string + AttemptNum int + Success bool + StatusCode int + Error string + Duration int64 + ResponseBody []byte + ResponseBytes int64 +} + +// view projects a loaded row for rendering, stripping the +// target's own credential out of the two fields a remote peer +// gets to influence. +func (r *deliveryResultRow) view( + redactor delivery.Redactor, +) DeliveryResultView { + body := r.ResponseBody + truncated := r.ResponseBytes > int64(len(body)) + + // Only a cut response can have been left mid-sequence by + // this query, exactly as with an event body. + if truncated { + body = trimPartialRune(body) + } + + // A cut body goes through RedactCut: the remote controls + // the padding ahead of a credential it echoes, so it + // controls where the cut falls inside that credential, and + // the severed prefix left behind matches no secret whole. + rendered := string(body) + if truncated { + rendered = redactor.RedactCut(rendered) + } else { + rendered = redactor.Redact(rendered) + } + + return DeliveryResultView{ + AttemptNum: r.AttemptNum, + Success: r.Success, + StatusCode: r.StatusCode, + Error: redactor.Redact(r.Error), + DurationMS: r.Duration, + ResponseBody: rendered, + ResponseBytes: r.ResponseBytes, + ResponseShownBytes: len(body), + ResponseTruncated: truncated, + } +} diff --git a/internal/handlers/delivery_result_view_test.go b/internal/handlers/delivery_result_view_test.go new file mode 100644 index 0000000..9bf393d --- /dev/null +++ b/internal/handlers/delivery_result_view_test.go @@ -0,0 +1,458 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// responseCap is the number of response bytes the event log +// page is allowed to render for one delivery attempt. +const responseCap = handlers.MaxRenderedResponseBytesForTest + +// failedAttempt describes the failed delivery every test in +// this file seeds. The values are distinctive so that finding +// them in the rendered page cannot be a coincidence. +const ( + attemptStatusCode = 502 + attemptDurationMS = 1234 + attemptNumber = 3 + attemptError = "upstream returned 502 Bad Gateway" +) + +// seedFailedDelivery records an event, a failed delivery +// against targetID, and one delivery result carrying the +// given response body. It returns the delivery. +func seedFailedDelivery( + t *testing.T, + dbMgr *database.WebhookDBManager, + webhookID, targetID, responseBody string, +) *database.Delivery { + t.Helper() + + webhookDB, err := dbMgr.GetDB(webhookID) + require.NoError(t, err) + + event := &database.Event{ + WebhookID: webhookID, + Method: http.MethodPost, + Body: `{"test":true}`, + ContentType: "application/json", + } + + require.NoError(t, webhookDB.Omit( + clause.Associations, + ).Create(event).Error) + + dlv := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusFailed, + } + + require.NoError(t, webhookDB.Omit( + clause.Associations, + ).Create(dlv).Error) + + result := &database.DeliveryResult{ + DeliveryID: dlv.ID, + AttemptNum: attemptNumber, + Success: false, + StatusCode: attemptStatusCode, + ResponseBody: responseBody, + Error: attemptError, + Duration: attemptDurationMS, + } + + require.NoError(t, webhookDB.Omit( + clause.Associations, + ).Create(result).Error) + + return dlv +} + +// seedFailureAndRender seeds a failed delivery against a +// target of the given type and config, and returns the +// rendered event log page. +func seedFailureAndRender( + t *testing.T, + targetType database.TargetType, + config, responseBody string, +) string { + t.Helper() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, targetType, config, + ) + + seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID, responseBody) + + return renderSourceLogsPage(t, h, sess, wh.ID) +} + +// TestHandleSourceLogs_RendersFailedAttempt is the regression +// test for the reported gap: a failed delivery used to render +// as the status word alone, so diagnosing it meant opening the +// per-webhook SQLite file by hand. +func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) { + t.Parallel() + + body := seedFailureAndRender( + t, + database.TargetTypeHTTP, + `{"url":"https://example.com/hook/abc"}`, + "upstream exploded", + ) + + assert.Contains( + t, body, strconv.Itoa(attemptStatusCode), + "the attempt's status code must reach the page", + ) + assert.Contains( + t, body, attemptError, + "the attempt's error must reach the page", + ) + assert.Contains( + t, body, strconv.Itoa(attemptDurationMS), + "the attempt's duration must reach the page", + ) + assert.Contains( + t, body, "Attempt "+strconv.Itoa(attemptNumber), + "the attempt number must reach the page", + ) + assert.Contains( + t, body, "upstream exploded", + "the attempt's response body must reach the page", + ) +} + +// TestHandleSourceLogs_EscapesResponseBody proves the +// response body is treated as the untrusted remote content it +// is. The remote chooses these bytes and the page is rendered +// inside the operator's authenticated origin, where the +// application's own CSP allows inline script from 'self'. +func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) { + t.Parallel() + + const payload = `` + + body := seedFailureAndRender( + t, + database.TargetTypeHTTP, + `{"url":"https://example.com/hook/abc"}`, + payload, + ) + + assert.NotContains(t, body, payload) + assert.NotContains(t, body, "