All checks were successful
check / check (push) Successful in 6m0s
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 before rendering. Configured HTTP header values are deliberately not redacted; they are as often routine as secret, and replacing them would mangle ordinary responses. Target configuration keeps reaching the template only as a TargetView.
307 lines
7.8 KiB
Go
307 lines
7.8 KiB
Go
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 = `<script>alert("xss")</script>`
|
|
|
|
body := seedFailureAndRender(
|
|
t,
|
|
database.TargetTypeHTTP,
|
|
`{"url":"https://example.com/hook/abc"}`,
|
|
payload,
|
|
)
|
|
|
|
assert.NotContains(t, body, payload)
|
|
assert.NotContains(t, body, "<script>alert")
|
|
assert.Contains(t, body, "alert")
|
|
}
|
|
|
|
// TestHandleSourceLogs_RedactsCredentialEchoedInResponse
|
|
// covers the case that makes rendering a response body a
|
|
// disclosure question at all: the remote echoes back the
|
|
// credential the request carried, and the page would then put
|
|
// it on the operator's screen.
|
|
func TestHandleSourceLogs_RedactsCredentialEchoedInResponse(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
body := seedFailureAndRender(
|
|
t,
|
|
database.TargetTypeSlack,
|
|
`{"webhookUrl":"`+slackWebhookURL+`"}`,
|
|
"no_service: "+slackWebhookURL,
|
|
)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.NotContains(t, body, "T00000000")
|
|
assert.NotContains(t, body, "B00000000")
|
|
assert.Contains(t, body, delivery.RedactionMarker)
|
|
|
|
// The rest of the response is still shown, or the
|
|
// redaction would have cost the operator the diagnosis.
|
|
assert.Contains(t, body, "no_service")
|
|
}
|
|
|
|
// TestHandleSourceLogs_RedactsCredentialEchoedInError covers
|
|
// the same disclosure through the error field. The delivery
|
|
// engine masks the URL out of the errors it stores, so this
|
|
// holds the read path to the rows written before it did.
|
|
func TestHandleSourceLogs_RedactsCredentialEchoedInError(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
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,
|
|
database.TargetTypeSlack,
|
|
`{"webhookUrl":"`+slackWebhookURL+`"}`,
|
|
)
|
|
|
|
dlv := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID, "")
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
// An unmasked transport error, exactly as Go's HTTP
|
|
// client renders one.
|
|
require.NoError(t, webhookDB.Model(
|
|
&database.DeliveryResult{},
|
|
).Where(
|
|
"delivery_id = ?", dlv.ID,
|
|
).Update(
|
|
"error",
|
|
`Post "`+slackWebhookURL+`": dial tcp: i/o timeout`,
|
|
).Error)
|
|
|
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.Contains(t, body, delivery.RedactionMarker)
|
|
assert.Contains(t, body, "i/o timeout")
|
|
}
|
|
|
|
// TestHandleSourceLogs_BoundsOversizeResponse proves the
|
|
// rendered page is bounded by the response cap rather than by
|
|
// the stored response size. The cut happens in SQLite, so the
|
|
// oversized value never becomes a Go string; this asserts the
|
|
// observable consequence, that neither the page nor the
|
|
// projection carries the tail.
|
|
func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const tail = "QQRESPONSETAILQQ"
|
|
|
|
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, database.TargetTypeLog, "",
|
|
)
|
|
|
|
stored := strings.Repeat("A", responseCap*4) + tail
|
|
seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID, stored)
|
|
|
|
views := h.LoadEventLogViewsForTest(
|
|
httptest.NewRecorder(), *wh, 1,
|
|
)
|
|
require.Len(t, views, 1)
|
|
require.Len(t, views[0].Deliveries, 1)
|
|
require.Len(t, views[0].Deliveries[0].Results, 1)
|
|
|
|
attempt := views[0].Deliveries[0].Results[0]
|
|
|
|
assert.LessOrEqual(
|
|
t, len(attempt.ResponseBody), responseCap,
|
|
)
|
|
assert.Equal(
|
|
t, int64(len(stored)), attempt.ResponseBytes,
|
|
)
|
|
assert.True(t, attempt.ResponseTruncated)
|
|
|
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, page, tail)
|
|
assert.Contains(
|
|
t, page, "Response truncated for display",
|
|
)
|
|
}
|