package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// The destinations the target edit tests configure. Both are literal
// public addresses rather than hostnames so the SSRF check resolves
// nothing: with a hostname, a sandbox without DNS would reject the
// URL for the wrong reason and a test asserting rejection would pass
// even with the guard removed.
const (
editOriginalURL = "https://93.184.216.34/hooks/original"
editReplacedURL = "https://93.184.216.34/hooks/replaced"
// editBlockedURL resolves to loopback, which the SSRF guard
// refuses. It is what proves the guard runs on the edit path.
editBlockedURL = "http://127.0.0.1/hooks/internal"
)
// editAuthHeader carries a bearer credential, the case the headers
// field exists for.
const (
editBearerSecret = "QQEDITSECRETQQ"
editAuthHeader = "Authorization: Bearer " + editBearerSecret
)
// targetRouter mounts the target create, edit and download routes on
// a chi router so the handlers see the URL parameters they read.
func targetRouter(env *sourceTestEnv) *chi.Mux {
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/download",
env.handlers.HandleTargetDownload(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(),
)
router.Post(
"/hook/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEditSubmit(),
)
return router
}
// serveTarget drives one request through the target routes as the
// authenticated test user.
func serveTarget(
env *sourceTestEnv,
method, path string,
form url.Values,
) *httptest.ResponseRecorder {
body := ""
if form != nil {
body = form.Encode()
}
req := httptest.NewRequestWithContext(
context.Background(), method, path,
strings.NewReader(body),
)
if form != nil {
req.Header.Set(
"Content-Type",
"application/x-www-form-urlencoded",
)
}
for _, c := range env.cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
targetRouter(env).ServeHTTP(w, req)
return w
}
// seedHTTPTarget creates a webhook and an HTTP target on it through
// the real create handler, so every case starts from a target the
// production path produced rather than a hand-written row.
//
// Standing the fx app up is what a handler test mostly costs, and
// internal/handlers is already the slowest package in the suite, so
// the tests below share one env per test function and give each case
// its own webhook rather than its own app.
func seedHTTPTarget(
t *testing.T,
env *sourceTestEnv,
headers, timeout string,
) (database.Webhook, database.Target) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "original-name")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", editOriginalURL)
form.Set("headers", headers)
form.Set("timeout", timeout)
form.Set("max_attempts", "3")
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
return webhook, targets[0]
}
// storedTarget reloads a target row.
func storedTarget(
t *testing.T,
env *sourceTestEnv,
targetID string,
) database.Target {
t.Helper()
var target database.Target
require.NoError(
t,
env.db.DB().Where("id = ?", targetID).
First(&target).Error,
)
return target
}
// editForm is the fully populated edit submission for an HTTP
// target.
func editForm(targetURL, headers, timeout string) url.Values {
form := url.Values{}
form.Set("name", "edited-name")
form.Set("url", targetURL)
form.Set("headers", headers)
form.Set("timeout", timeout)
form.Set("max_attempts", "5")
return form
}
// submitTargetEdit posts the edit form for a target.
func submitTargetEdit(
env *sourceTestEnv,
webhookID, targetID string,
form url.Values,
) *httptest.ResponseRecorder {
return serveTarget(
env, http.MethodPost,
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
form,
)
}
// TestHandleTargetCreate_Configuration proves the add target form
// stores an HTTP target's headers and timeout, and leaves them empty
// when the form leaves them empty.
func TestHandleTargetCreate_Configuration(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
t.Run("stores headers and timeout", func(t *testing.T) {
t.Parallel()
assertCreateStoresHeadersAndTimeout(t, env)
})
t.Run("without them stores only the url", func(t *testing.T) {
t.Parallel()
assertCreateStoresOnlyTheURL(t, env)
})
}
func assertCreateStoresHeadersAndTimeout(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
_, target := seedHTTPTarget(
t, env, editAuthHeader+"\nX-Tenant: acme\n", "12",
)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, editOriginalURL, stored.URL)
assert.Equal(t, 12, stored.TimeoutSeconds)
assert.Equal(
t,
map[string]string{
"Authorization": "Bearer " + editBearerSecret,
"X-Tenant": "acme",
},
stored.Headers,
)
}
func assertCreateStoresOnlyTheURL(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
_, target := seedHTTPTarget(t, env, "", "")
assert.Equal(t, editOriginalURL, target.URL)
assert.Empty(t, target.Headers)
assert.Zero(t, target.TimeoutSeconds)
assert.False(t, target.ForwardQuery)
}
// TestHandleTargetEditSubmit_Saves is the round trip the issue asks
// for: create a target, edit it, and confirm the stored config
// changed.
func TestHandleTargetEditSubmit_Saves(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
t.Run("changes the destination URL", func(t *testing.T) {
t.Parallel()
assertEditChangesDestination(t, env)
})
t.Run("round trips headers and timeout", func(t *testing.T) {
t.Parallel()
assertEditRoundTripsHeadersAndTimeout(t, env)
})
t.Run("clearing them removes them", func(t *testing.T) {
t.Parallel()
assertEditClearingRemovesThem(t, env)
})
t.Run("absent max_attempts is kept", func(t *testing.T) {
t.Parallel()
assertEditKeepsAbsentMaxAttempts(t, env)
})
}
func assertEditChangesDestination(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
webhook, target := seedHTTPTarget(t, env, "", "")
w := submitTargetEdit(
env, webhook.ID, target.ID,
editForm(editReplacedURL, "", ""),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
reloaded := storedTarget(t, env, target.ID)
assert.Equal(t, editReplacedURL, reloaded.URL)
assert.Equal(t, "edited-name", reloaded.Name)
assert.Equal(t, 5, reloaded.MaxAttempts)
assert.Equal(
t, database.TargetTypeHTTP, reloaded.Type,
"the edit form must not change a target's type",
)
}
// The two previously unreachable fields must survive create,
// pre-fill and save.
func assertEditRoundTripsHeadersAndTimeout(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
w := submitTargetEdit(
env, webhook.ID, target.ID,
editForm(
editOriginalURL,
"Authorization: Bearer rotated\nX-Trace: on",
"21",
),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
stored := storedTarget(t, env, target.ID)
assert.Equal(t, 21, stored.TimeoutSeconds)
assert.Equal(
t,
map[string]string{
"Authorization": "Bearer rotated",
"X-Trace": "on",
},
stored.Headers,
)
}
// The direction a naive "only set what was submitted" implementation
// gets wrong: an emptied field must remove the stored value, not
// leave the previous one in place.
func assertEditClearingRemovesThem(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
w := submitTargetEdit(
env, webhook.ID, target.ID,
editForm(editOriginalURL, "", ""),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
stored := storedTarget(t, env, target.ID)
assert.Empty(t, stored.Headers)
assert.Zero(t, stored.TimeoutSeconds)
}
// Delivery attempts are offered only by the forms for target types
// that retry. An absent field means the form does not edit them, not
// that retries should be turned off.
func assertEditKeepsAbsentMaxAttempts(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
webhook, target := seedHTTPTarget(t, env, "", "")
require.Equal(t, 3, target.MaxAttempts)
form := editForm(editOriginalURL, "", "")
form.Del("max_attempts")
w := submitTargetEdit(env, webhook.ID, target.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t, 3, storedTarget(t, env, target.ID).MaxAttempts,
)
}
// TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked covers the
// deliberate exception to the masking rule. The operator cannot
// correct a value they cannot see, so this page — and only this page
// — renders the destination and the header values in full.
func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, editOriginalURL)
assert.Contains(t, page, "Bearer "+editBearerSecret)
assert.Contains(t, page, `value="7"`)
assert.Contains(t, page, "original-name")
}
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
// edit page of a database target gives its type and its settings to
// the ones its badge and the add target form use.
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, "Type: archive.")
assert.Contains(t, page, `class="label">Archive expiry`)
assert.Contains(t, page, `class="label">Archive rotation`)
}
// TestHandleTargetEdit_EveryType edits a target of each type: the edit
// form starts on the settings the add target form stored, and saving
// it stores the settings submitted.
func TestHandleTargetEdit_EveryType(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// created and edited are the add target form's and the edit
// form's fields, as query strings; shown is what the edit form
// must show of created; want is the settings edited stores.
cases := []struct {
targetType database.TargetType
created, edited string
shown []string
want database.Target
}{
{
database.TargetTypeHTTP,
"url=" + editOriginalURL +
"&headers=X-Tenant:+acme&timeout=7&forward_query=on",
"url=" + editReplacedURL + "&timeout=9",
[]string{
`value="` + editOriginalURL + `"`,
"X-Tenant: acme",
`value="7"`,
`name="forward_query" value="on" checked`,
},
database.Target{
URL: editReplacedURL,
Headers: map[string]string{},
TimeoutSeconds: 9,
},
},
{
database.TargetTypeSlack,
"url=" + editOriginalURL,
"url=" + editReplacedURL,
[]string{`value="` + editOriginalURL + `"`},
database.Target{URL: editReplacedURL},
},
{
database.TargetTypeDatabase,
"expiry=720h&rotation=daily",
"expiry=24h&rotation=hourly",
[]string{
`