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{ `