604 lines
16 KiB
Go
604 lines
16 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"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"
|
|
)
|
|
|
|
// resubmitTargetURL is a public destination, so a target configured
|
|
// with it is one the SSRF guard would accept. Nothing in these tests
|
|
// dispatches to it: the notifier is recorded, not run.
|
|
const resubmitTargetURL = "http://93.184.216.34/hook"
|
|
|
|
// resubmitEventHeaders is the stored header JSON a seeded event
|
|
// carries, so a test can prove the copy takes it verbatim.
|
|
const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
|
|
|
|
// seedStoredEvent records one event in a webhook's own database with
|
|
// no deliveries at all, which is the state a captured event is in when
|
|
// the operator has yet to create the target to test.
|
|
func seedStoredEvent(
|
|
t *testing.T,
|
|
dbMgr *database.WebhookDBManager,
|
|
webhookID, body string,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: "entrypoint-" + webhookID,
|
|
Method: http.MethodPost,
|
|
Headers: resubmitEventHeaders,
|
|
Body: body,
|
|
ContentType: contentTypeJSON,
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
return event
|
|
}
|
|
|
|
// postResubmit runs the real resubmit handler for one event.
|
|
func postResubmit(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID, eventID string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := postRequest(
|
|
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
|
authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
),
|
|
map[string]string{
|
|
paramSourceID: webhookID,
|
|
paramEventID: eventID,
|
|
},
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleEventResubmit().ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// listEvents reads every event in a webhook's database, oldest first.
|
|
func listEvents(
|
|
t *testing.T, webhookDB *gorm.DB,
|
|
) []database.Event {
|
|
t.Helper()
|
|
|
|
var events []database.Event
|
|
|
|
require.NoError(t, webhookDB.
|
|
Order("created_at ASC, id ASC").
|
|
Find(&events).Error)
|
|
|
|
return events
|
|
}
|
|
|
|
// theOtherEvent returns the one event that is not excludeID.
|
|
func theOtherEvent(
|
|
t *testing.T, events []database.Event, excludeID string,
|
|
) database.Event {
|
|
t.Helper()
|
|
|
|
var found []database.Event
|
|
|
|
for _, e := range events {
|
|
if e.ID != excludeID {
|
|
found = append(found, e)
|
|
}
|
|
}
|
|
|
|
require.Len(t, found, 1)
|
|
|
|
return found[0]
|
|
}
|
|
|
|
// TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent is the
|
|
// core of the feature and the thing per-delivery replay cannot do: the
|
|
// event was captured before the target existed, so it has no delivery
|
|
// to replay, and the resubmit must still reach the new target.
|
|
func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
original := seedStoredEvent(
|
|
t, dbMgr, wh.ID, `{"captured":"traffic"}`,
|
|
)
|
|
|
|
// The dev backend is registered only now, after the traffic was
|
|
// captured. It has no prior delivery of anything.
|
|
tgt := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(
|
|
t, events, 2,
|
|
"resubmit must create a new event, not mark the old one",
|
|
)
|
|
|
|
fresh := theOtherEvent(t, events, original.ID)
|
|
assertEventCopy(t, original, fresh)
|
|
|
|
// The delivery hangs off the NEW event, and the original event
|
|
// still has none.
|
|
assert.Empty(
|
|
t, listDeliveries(t, webhookDB, original.ID),
|
|
"the original event must be left untouched",
|
|
)
|
|
|
|
deliveries := listDeliveries(t, webhookDB, fresh.ID)
|
|
require.Len(t, deliveries, 1)
|
|
assert.Equal(t, tgt.ID, deliveries[0].TargetID)
|
|
assert.Equal(
|
|
t, database.DeliveryStatusPending, deliveries[0].Status,
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, deliveries[0].ID, tasks[0].DeliveryID)
|
|
assertResubmitTask(t, tasks[0], wh.ID, &fresh, tgt)
|
|
|
|
assertNoLeakedTarget(t, webhookDB)
|
|
}
|
|
|
|
// assertEventCopy proves the resubmitted event copies every stored
|
|
// field of the event it came from, and records the provenance that
|
|
// keeps the log readable. It also pins that a received event carries
|
|
// no source event of its own.
|
|
func assertEventCopy(
|
|
t *testing.T, original *database.Event, fresh database.Event,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, original.Method, fresh.Method)
|
|
assert.Equal(t, original.Headers, fresh.Headers)
|
|
assert.Equal(t, original.Body, fresh.Body)
|
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
|
assert.NotEqual(t, original.ID, fresh.ID)
|
|
|
|
require.NotNil(t, fresh.ResubmittedFromID)
|
|
assert.Equal(t, original.ID, *fresh.ResubmittedFromID)
|
|
|
|
assert.Nil(
|
|
t, original.ResubmittedFromID,
|
|
"a received event records no source event",
|
|
)
|
|
}
|
|
|
|
// assertResubmitTask proves the task handed to the delivery engine is
|
|
// the one the receiver would build for the NEW event and this target.
|
|
func assertResubmitTask(
|
|
t *testing.T,
|
|
task delivery.Task,
|
|
webhookID string,
|
|
fresh *database.Event,
|
|
target *database.Target,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, fresh.ID, task.EventID)
|
|
assert.Equal(t, webhookID, task.WebhookID)
|
|
assert.Equal(t, fresh.EntrypointID, task.EntrypointID)
|
|
assert.Equal(t, target.ID, task.TargetID)
|
|
assert.Equal(t, target.Type, task.TargetType)
|
|
assert.Equal(t, fresh.Method, task.Method)
|
|
assert.Equal(t, fresh.Headers, task.Headers)
|
|
assert.Equal(t, fresh.ContentType, task.ContentType)
|
|
assert.Equal(t, 1, task.AttemptNum)
|
|
|
|
require.NotNil(t, task.Body)
|
|
assert.Equal(t, fresh.Body, *task.Body)
|
|
}
|
|
|
|
// TestHandleEventResubmit_IsRepeatable proves the requirement replay
|
|
// deliberately does not meet: firing the same captured event at a
|
|
// backend over and over must work, with no in-flight refusal, and each
|
|
// press must produce its own event and its own delivery.
|
|
func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
tgt := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
|
|
|
|
// Nothing between the presses marks the earlier deliveries
|
|
// finished, so every one of these is submitted while the last is
|
|
// still pending.
|
|
const presses = 5
|
|
|
|
for range presses {
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
"a resubmit must not be refused while an earlier "+
|
|
"one is in flight",
|
|
)
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, presses+1)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, presses)
|
|
|
|
seen := make(map[string]struct{}, presses)
|
|
|
|
for _, task := range tasks {
|
|
assert.Equal(t, tgt.ID, task.TargetID)
|
|
assert.NotEqual(
|
|
t, original.ID, task.EventID,
|
|
"each resubmit delivers its own new event",
|
|
)
|
|
|
|
_, dup := seen[task.EventID]
|
|
assert.False(t, dup, "each resubmit creates its own event")
|
|
|
|
seen[task.EventID] = struct{}{}
|
|
|
|
require.Len(t, listDeliveries(t, webhookDB, task.EventID), 1)
|
|
}
|
|
|
|
// Every copy names the same source event, so twenty presses stay
|
|
// traceable to the one captured request.
|
|
for _, e := range events {
|
|
if e.ID == original.ID {
|
|
continue
|
|
}
|
|
|
|
require.NotNil(t, e.ResubmittedFromID)
|
|
assert.Equal(t, original.ID, *e.ResubmittedFromID)
|
|
}
|
|
}
|
|
|
|
// TestHandleEventResubmit_OversizeBodySurvivesIntact covers the
|
|
// non-inline case: a body above delivery.MaxInlineBodySize is not
|
|
// carried on the task at all, so it has to be copied into the new
|
|
// event row byte-identically for the engine to load it from there.
|
|
func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// Well over the inline limit, and not text: a multibyte rune, a
|
|
// NUL and a byte that is not valid UTF-8, so a copy that went
|
|
// through a re-encode or a truncation is visible in the compare.
|
|
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
|
|
|
stored := strings.Repeat("A", delivery.MaxInlineBodySize) +
|
|
"é\x00\xff" +
|
|
strings.Repeat("B", 4096) + sentinel
|
|
|
|
require.Greater(t, len(stored), delivery.MaxInlineBodySize)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, stored)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
|
|
|
assert.Len(t, fresh.Body, len(stored))
|
|
assert.Equal(
|
|
t, stored, fresh.Body,
|
|
"the stored body must be copied byte for byte",
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Nil(
|
|
t, tasks[0].Body,
|
|
"a body over the inline limit is fetched from the new "+
|
|
"event row rather than carried on the task",
|
|
)
|
|
|
|
// The engine's own read of the body, against the new event id:
|
|
// what it would send is what was stored.
|
|
var loaded database.Event
|
|
|
|
require.NoError(t, webhookDB.Select("body").
|
|
First(&loaded, "id = ?", tasks[0].EventID).Error)
|
|
assert.Equal(t, stored, loaded.Body)
|
|
}
|
|
|
|
// TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated
|
|
// target is skipped exactly as the receiver skips it — not an error,
|
|
// and not a delivery the operator switched off.
|
|
func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
active := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
off := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`/off"}`,
|
|
)
|
|
|
|
require.NoError(t, db.DB().Model(&database.Target{}).
|
|
Where("id = ?", off.ID).
|
|
Update("active", false).Error)
|
|
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"skip":"one"}`)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
"an inactive target is skipped, not an error",
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, active.ID, tasks[0].TargetID)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
|
require.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
|
|
}
|
|
|
|
// TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a
|
|
// source with nothing to deliver to behaves as the receiver does: the
|
|
// event is stored, nothing is queued, and the operator is told so
|
|
// rather than being shown an error.
|
|
func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"no":"targets"}`)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
assert.Empty(t, notif.Tasks())
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, listEvents(t, webhookDB), 2)
|
|
}
|
|
|
|
// TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the
|
|
// route cannot re-inject an event out of a webhook the session's user
|
|
// does not own, and reports the same 404 for an id that names nothing.
|
|
func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
theirs := seedWebhookFor(t, db, otherTestUserID)
|
|
theirEvent := seedStoredEvent(t, dbMgr, theirs.ID, `{"not":"mine"}`)
|
|
|
|
mine := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, mine.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
|
|
|
|
// Their webhook, as its owner would address it.
|
|
w := postResubmit(t, h, sess, theirs.ID, theirEvent.ID)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// Their event id, addressed through a webhook the user does own.
|
|
w = postResubmit(t, h, sess, mine.ID, theirEvent.ID)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// An id that names no event at all.
|
|
w = postResubmit(t, h, sess, mine.ID, uuid.NewString())
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// A malformed id never reaches the query.
|
|
w = postResubmit(t, h, sess, mine.ID, "not-a-uuid")
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
assert.Empty(
|
|
t, notif.Tasks(),
|
|
"a refused resubmit must queue nothing",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceLogs_ShowsResubmitProvenance proves the event log
|
|
// reports the relationship in both directions, which is what keeps it
|
|
// readable once one captured event has been fired repeatedly.
|
|
func TestHandleSourceLogs_ShowsResubmitProvenance(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)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
|
|
|
|
for range 2 {
|
|
require.Equal(
|
|
t,
|
|
http.StatusSeeOther,
|
|
postResubmit(t, h, sess, wh.ID, original.ID).Code,
|
|
)
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 3)
|
|
|
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(
|
|
t, body, "Resubmitted as 2 new events",
|
|
"the source event must show it has been resubmitted",
|
|
)
|
|
assert.Contains(
|
|
t, body, "Resubmitted from event",
|
|
"a copy must show where it came from",
|
|
)
|
|
assert.Contains(
|
|
t, body,
|
|
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
|
"the log must offer the resubmit action per event",
|
|
)
|
|
}
|