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, "/source/"+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 ")) }