check / check (push) Successful in 4m23s
The webhook page and everything under it move from /source/ID to /hook/ID, the list and new-webhook form to /hooks and /hooks/new, and the event log from .../logs to /hook/ID/events, body download included. Entrypoint URLs move from /webhook/UUID to /h/UUID, and the webhook page shows only that form. The old paths are gone. Links, redirects, form actions, tests, comments and the README follow. Go identifiers and template file names are unchanged. A new route test posts to the entrypoint URL the webhook page shows and checks that the receiver rate limit applies to it. Model: opus-5-5
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
)
|
|
|
|
// retriesTargetURL is the destination the retry-validation targets
|
|
// point at. It is a literal public address rather than a hostname so
|
|
// the SSRF check resolves nothing and a sandbox without DNS cannot
|
|
// make these cases pass or fail for the wrong reason.
|
|
const retriesTargetURL = "https://93.184.216.34/hooks/retries"
|
|
|
|
const (
|
|
// wayAboveCeiling is the typo'd-extra-zero case from the report.
|
|
wayAboveCeiling = "999999999"
|
|
|
|
// notANumber is the plainest garbage an operator can type, and
|
|
// the value the report submitted on the edit form.
|
|
notANumber = "abc"
|
|
|
|
// workingRetries is the retry count a seeded target is already
|
|
// delivering with, which a rejected submission must not disturb.
|
|
workingRetries = 2
|
|
)
|
|
|
|
// aboveCeiling is the smallest rejected whole number.
|
|
func aboveCeiling() string {
|
|
return strconv.Itoa(handlers.MaxTargetRetriesForTest + 1)
|
|
}
|
|
|
|
// overCeilingRetries is whole-number input past the limit, which is
|
|
// rejected with the limit named.
|
|
func overCeilingRetries() []string {
|
|
return []string{aboveCeiling(), wayAboveCeiling}
|
|
}
|
|
|
|
// unparseableRetries is input an operator can type into the field
|
|
// that is not a retry count. Each must be REJECTED: silently reading
|
|
// any of them as 0 turns a store-and-forward proxy into
|
|
// fire-and-forget without saying so.
|
|
//
|
|
// The twenty-digit case is here because it parses as digits but
|
|
// overflows int, which is the one failure the field's own min/max
|
|
// attributes cannot describe.
|
|
func unparseableRetries() []string {
|
|
return []string{
|
|
notANumber,
|
|
"2.7",
|
|
"-5",
|
|
"12345678901234567890",
|
|
"1e3",
|
|
}
|
|
}
|
|
|
|
// createRetriesForm is a complete, otherwise-valid HTTP target
|
|
// creation, so the only thing any case below varies is max_retries.
|
|
func createRetriesForm(retries string) url.Values {
|
|
form := url.Values{}
|
|
form.Set("name", "retries-target")
|
|
form.Set("type", string(database.TargetTypeHTTP))
|
|
form.Set("url", retriesTargetURL)
|
|
|
|
if retries != absentField {
|
|
form.Set("max_retries", retries)
|
|
}
|
|
|
|
return form
|
|
}
|
|
|
|
// absentField marks a field the form does not submit at all, which is
|
|
// the case that legitimately takes a default and must stay distinct
|
|
// from a field submitted with garbage in it.
|
|
const absentField = "\x00absent"
|
|
|
|
// absentRetries is every way of saying "the operator did not set
|
|
// this", each of which takes the default rather than a 400. Blank and
|
|
// whitespace-only count as absent here because they do in the timeout
|
|
// and retention controls on the same forms; a rule the fields do not
|
|
// share would be its own surprise.
|
|
func absentRetries() []string {
|
|
return []string{absentField, "", " "}
|
|
}
|
|
|
|
// createWithRetries posts the target create form for a fresh webhook
|
|
// and returns the webhook and the response.
|
|
func createWithRetries(
|
|
t *testing.T,
|
|
env *sourceTestEnv,
|
|
retries string,
|
|
) (database.Webhook, int, string) {
|
|
t.Helper()
|
|
|
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
|
|
|
w := serveTarget(
|
|
env, http.MethodPost,
|
|
"/hook/"+webhook.ID+"/targets",
|
|
createRetriesForm(retries),
|
|
)
|
|
|
|
return webhook, w.Code, w.Body.String()
|
|
}
|
|
|
|
// TestTargetCreate_RetriesAboveCeilingRejected proves the create form
|
|
// enforces a ceiling at all, and that the 400 names it — a rejection
|
|
// that does not say what the limit is leaves the operator guessing.
|
|
func TestTargetCreate_RetriesAboveCeilingRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
|
|
|
for _, retries := range overCeilingRetries() {
|
|
webhook, code, body := createWithRetries(t, env, retries)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, code,
|
|
"max_retries=%s should be rejected", retries)
|
|
assert.Contains(t, body, ceiling,
|
|
"the rejection for %s should name the ceiling",
|
|
retries)
|
|
assert.Empty(t,
|
|
targetsForWebhook(t, env.db, webhook.ID),
|
|
"no target should be created for %s", retries)
|
|
}
|
|
}
|
|
|
|
// TestTargetCreate_UnparseableRetriesRejected is the core of the
|
|
// defect: each of these was accepted with HTTP 200 and stored as 0.
|
|
func TestTargetCreate_UnparseableRetriesRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, retries := range unparseableRetries() {
|
|
webhook, code, body := createWithRetries(t, env, retries)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, code,
|
|
"max_retries=%q should be rejected, not coerced",
|
|
retries)
|
|
assert.Contains(t, body, "whole number",
|
|
"the rejection for %q should say why", retries)
|
|
assert.Empty(t,
|
|
targetsForWebhook(t, env.db, webhook.ID),
|
|
"no target should be created for %q", retries)
|
|
}
|
|
}
|
|
|
|
// TestTargetCreate_ValidRetriesStored covers the accepting half,
|
|
// including the ceiling itself: a bound that rejects its own limit
|
|
// would make the advertised maximum unreachable.
|
|
func TestTargetCreate_ValidRetriesStored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, want := range []int{0, 3, handlers.MaxTargetRetriesForTest} {
|
|
webhook, code, body := createWithRetries(
|
|
t, env, strconv.Itoa(want),
|
|
)
|
|
|
|
require.Equal(t, http.StatusSeeOther, code, body)
|
|
|
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
require.Len(t, targets, 1)
|
|
assert.Equal(t, want, targets[0].MaxRetries)
|
|
}
|
|
}
|
|
|
|
// TestTargetCreate_AbsentRetriesTakesDefault keeps the two cases
|
|
// distinct. An omitted field is not an operator asking for something
|
|
// invalid, so it still gets the fire-and-forget default rather than a
|
|
// 400 — otherwise the fix above would make the form unusable.
|
|
func TestTargetCreate_AbsentRetriesTakesDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, retries := range absentRetries() {
|
|
webhook, code, body := createWithRetries(t, env, retries)
|
|
|
|
require.Equal(t, http.StatusSeeOther, code, body)
|
|
|
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
require.Len(t, targets, 1)
|
|
assert.Equal(t, 0, targets[0].MaxRetries,
|
|
"an absent max_retries should take the default")
|
|
}
|
|
}
|
|
|
|
// seedRetriesTarget creates an HTTP target already delivering with
|
|
// workingRetries retries, through the real create handler.
|
|
func seedRetriesTarget(
|
|
t *testing.T,
|
|
env *sourceTestEnv,
|
|
) (database.Webhook, database.Target) {
|
|
t.Helper()
|
|
|
|
webhook, code, body := createWithRetries(
|
|
t, env, strconv.Itoa(workingRetries),
|
|
)
|
|
require.Equal(t, http.StatusSeeOther, code, body)
|
|
|
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
|
require.Len(t, targets, 1)
|
|
require.Equal(t, workingRetries, targets[0].MaxRetries)
|
|
|
|
return webhook, targets[0]
|
|
}
|
|
|
|
// editRetriesForm is a complete edit submission that changes the
|
|
// target's name as well, so a rejected submission can be shown to
|
|
// have written nothing at all rather than merely to have left
|
|
// max_retries alone.
|
|
func editRetriesForm(retries string) url.Values {
|
|
form := url.Values{}
|
|
form.Set("name", "renamed-by-edit")
|
|
form.Set("url", retriesTargetURL)
|
|
|
|
if retries != absentField {
|
|
form.Set("max_retries", retries)
|
|
}
|
|
|
|
return form
|
|
}
|
|
|
|
// assertEditRejectedAndUnchanged submits an edit expected to fail and
|
|
// checks both halves of the requirement: the 400 explains itself, and
|
|
// the target it was submitted against is untouched.
|
|
func assertEditRejectedAndUnchanged(
|
|
t *testing.T,
|
|
env *sourceTestEnv,
|
|
retries, wantReason string,
|
|
) {
|
|
t.Helper()
|
|
|
|
webhook, target := seedRetriesTarget(t, env)
|
|
|
|
w := submitTargetEdit(
|
|
env, webhook.ID, target.ID, editRetriesForm(retries),
|
|
)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code,
|
|
"max_retries=%q should be rejected on edit", retries)
|
|
assert.Contains(t, w.Body.String(), wantReason,
|
|
"the rejection for %q should say why", retries)
|
|
|
|
stored := storedTarget(t, env, target.ID)
|
|
assert.Equal(t, workingRetries, stored.MaxRetries,
|
|
"a rejected edit must not destroy the working retry "+
|
|
"count with %q", retries)
|
|
assert.Equal(t, "retries-target", stored.Name,
|
|
"a rejected edit must write nothing at all")
|
|
}
|
|
|
|
// TestTargetEdit_UnparseableRetriesRejected is the damaging half of
|
|
// the defect. A target delivering with two retries, re-saved with a
|
|
// typo in the field, returned 200 and was left with retries disabled
|
|
// and nothing said.
|
|
func TestTargetEdit_UnparseableRetriesRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, retries := range unparseableRetries() {
|
|
assertEditRejectedAndUnchanged(
|
|
t, env, retries, "whole number",
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTargetEdit_RetriesAboveCeilingRejected proves the ceiling
|
|
// applies to the edit path too, naming itself, so the two paths
|
|
// cannot disagree about what is storable.
|
|
func TestTargetEdit_RetriesAboveCeilingRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest)
|
|
|
|
for _, retries := range overCeilingRetries() {
|
|
assertEditRejectedAndUnchanged(t, env, retries, ceiling)
|
|
}
|
|
}
|
|
|
|
// TestTargetEdit_ValidRetriesStored covers the accepting half of the
|
|
// edit path, so the ceiling cannot be enforced by simply refusing
|
|
// every submission.
|
|
func TestTargetEdit_ValidRetriesStored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, want := range []int{0, 9, handlers.MaxTargetRetriesForTest} {
|
|
webhook, target := seedRetriesTarget(t, env)
|
|
|
|
w := submitTargetEdit(
|
|
env, webhook.ID, target.ID,
|
|
editRetriesForm(strconv.Itoa(want)),
|
|
)
|
|
require.Equal(t,
|
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
|
)
|
|
|
|
assert.Equal(t, want,
|
|
storedTarget(t, env, target.ID).MaxRetries)
|
|
}
|
|
}
|
|
|
|
// TestTargetEdit_AbsentRetriesLeavesStoredValue is the edit path's
|
|
// absent-versus-invalid case. Retries are only offered by the forms
|
|
// for types that retry, so a submission without the field must leave
|
|
// the stored count alone rather than be rejected or zeroed.
|
|
func TestTargetEdit_AbsentRetriesLeavesStoredValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
for _, retries := range absentRetries() {
|
|
webhook, target := seedRetriesTarget(t, env)
|
|
|
|
w := submitTargetEdit(
|
|
env, webhook.ID, target.ID,
|
|
editRetriesForm(retries),
|
|
)
|
|
require.Equal(t,
|
|
http.StatusSeeOther, w.Code, w.Body.String(),
|
|
)
|
|
|
|
assert.Equal(t, workingRetries,
|
|
storedTarget(t, env, target.ID).MaxRetries,
|
|
"an absent max_retries must leave the stored "+
|
|
"count alone (%q)", retries)
|
|
}
|
|
}
|
|
|
|
// TestTargetRetries_CreateAndEditAgreeOnEveryCase proves the two
|
|
// paths cannot disagree, which is what let the create form and the
|
|
// edit form drift apart in the first place. Every input is submitted
|
|
// to both and the accept/reject verdicts are compared.
|
|
func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
|
|
accepted := []string{
|
|
"0", "1",
|
|
strconv.Itoa(handlers.MaxTargetRetriesForTest),
|
|
}
|
|
overCeiling := overCeilingRetries()
|
|
unparseable := unparseableRetries()
|
|
|
|
cases := make(
|
|
[]string, 0,
|
|
len(accepted)+len(overCeiling)+len(unparseable),
|
|
)
|
|
cases = append(cases, accepted...)
|
|
cases = append(cases, overCeiling...)
|
|
cases = append(cases, unparseable...)
|
|
|
|
for _, retries := range cases {
|
|
_, createCode, _ := createWithRetries(t, env, retries)
|
|
|
|
webhook, target := seedRetriesTarget(t, env)
|
|
editCode := submitTargetEdit(
|
|
env, webhook.ID, target.ID,
|
|
editRetriesForm(retries),
|
|
).Code
|
|
|
|
assert.Equal(t,
|
|
createCode == http.StatusBadRequest,
|
|
editCode == http.StatusBadRequest,
|
|
"create and edit must agree on max_retries=%q "+
|
|
"(create %d, edit %d)",
|
|
retries, createCode, editCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
|
// non-numeric form value legitimately falls back. A page number says
|
|
// where to send the browser after an action that has already
|
|
// happened, so it is not configuration and rejecting it would report
|
|
// a failure that did not occur.
|
|
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
|
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
|
"%q should fall back to the first page", s)
|
|
}
|
|
|
|
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
|
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
|
}
|