Deploy main eacf1e7 to prod
check / check (push) Waiting to run

Replaces #559, which conflicted.

Model: opus-5-5
This commit is contained in:
2026-10-25 01:55:36 +00:00
parent 1647b43aa6
commit 537374e4cc
311 changed files with 35614 additions and 14120 deletions
+391 -85
View File
@@ -2,19 +2,18 @@ package handlers_test
import (
"context"
"encoding/json"
"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"
"sneak.berlin/go/webhooker/internal/delivery"
)
// The destinations the target edit tests configure. Both are literal
@@ -37,20 +36,24 @@ const (
editAuthHeader = "Authorization: Bearer " + editBearerSecret
)
// targetRouter mounts the target create and edit routes on a chi
// router so the handlers see the URL parameters they read.
// 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(
"/source/{sourceID}/targets",
"/hook/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
router.Get(
"/source/{sourceID}/targets/{targetID}/edit",
"/hook/{sourceID}/targets/{targetID}/download",
env.handlers.HandleTargetDownload(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(),
)
router.Post(
"/source/{sourceID}/targets/{targetID}/edit",
"/hook/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEditSubmit(),
)
@@ -113,11 +116,11 @@ func seedHTTPTarget(
form.Set("url", editOriginalURL)
form.Set("headers", headers)
form.Set("timeout", timeout)
form.Set("max_retries", "3")
form.Set("max_attempts", "3")
w := serveTarget(
env, http.MethodPost,
"/source/"+webhook.ID+"/targets", form,
"/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
@@ -146,27 +149,6 @@ func storedTarget(
return target
}
// storedHTTPConfig reloads a target and parses its stored HTTP
// configuration.
func storedHTTPConfig(
t *testing.T,
env *sourceTestEnv,
targetID string,
) delivery.HTTPTargetConfig {
t.Helper()
var cfg delivery.HTTPTargetConfig
require.NoError(
t,
json.Unmarshal(
[]byte(storedTarget(t, env, targetID).Config), &cfg,
),
)
return cfg
}
// editForm is the fully populated edit submission for an HTTP
// target.
func editForm(targetURL, headers, timeout string) url.Values {
@@ -175,7 +157,7 @@ func editForm(targetURL, headers, timeout string) url.Values {
form.Set("url", targetURL)
form.Set("headers", headers)
form.Set("timeout", timeout)
form.Set("max_retries", "5")
form.Set("max_attempts", "5")
return form
}
@@ -188,17 +170,14 @@ func submitTargetEdit(
) *httptest.ResponseRecorder {
return serveTarget(
env, http.MethodPost,
"/source/"+webhookID+"/targets/"+targetID+"/edit",
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
form,
)
}
// TestHandleTargetCreate_Configuration covers the half of the gap
// that is not about editing at all: HTTPTargetConfig has carried
// Headers and Timeout, and the delivery path has honoured them, but
// the create form wrote {"url":...} and nothing else, so a
// destination needing an Authorization header could not be
// configured through the UI at all.
// 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()
@@ -209,9 +188,9 @@ func TestHandleTargetCreate_Configuration(t *testing.T) {
assertCreateStoresHeadersAndTimeout(t, env)
})
t.Run("without them keeps a url-only config", func(t *testing.T) {
t.Run("without them stores only the url", func(t *testing.T) {
t.Parallel()
assertCreateKeepsURLOnlyConfig(t, env)
assertCreateStoresOnlyTheURL(t, env)
})
}
@@ -224,33 +203,31 @@ func assertCreateStoresHeadersAndTimeout(
t, env, editAuthHeader+"\nX-Tenant: acme\n", "12",
)
cfg := storedHTTPConfig(t, env, target.ID)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, editOriginalURL, cfg.URL)
assert.Equal(t, 12, cfg.Timeout)
assert.Equal(t, editOriginalURL, stored.URL)
assert.Equal(t, 12, stored.TimeoutSeconds)
assert.Equal(
t,
map[string]string{
"Authorization": "Bearer " + editBearerSecret,
"X-Tenant": "acme",
},
cfg.Headers,
stored.Headers,
)
}
// Without the new fields the stored shape must be the same
// {"url":...} the create form wrote before they existed, so no
// existing target's configuration is rewritten by this change.
func assertCreateKeepsURLOnlyConfig(
func assertCreateStoresOnlyTheURL(
t *testing.T, env *sourceTestEnv,
) {
t.Helper()
_, target := seedHTTPTarget(t, env, "", "")
assert.JSONEq(
t, `{"url":"`+editOriginalURL+`"}`, target.Config,
)
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
@@ -276,9 +253,9 @@ func TestHandleTargetEditSubmit_Saves(t *testing.T) {
assertEditClearingRemovesThem(t, env)
})
t.Run("absent max_retries is not zeroed", func(t *testing.T) {
t.Run("absent max_attempts is kept", func(t *testing.T) {
t.Parallel()
assertEditKeepsAbsentMaxRetries(t, env)
assertEditKeepsAbsentMaxAttempts(t, env)
})
}
@@ -295,15 +272,10 @@ func assertEditChangesDestination(
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t,
editReplacedURL,
storedHTTPConfig(t, env, target.ID).URL,
)
reloaded := storedTarget(t, env, target.ID)
assert.Equal(t, editReplacedURL, reloaded.URL)
assert.Equal(t, "edited-name", reloaded.Name)
assert.Equal(t, 5, reloaded.MaxRetries)
assert.Equal(t, 5, reloaded.MaxAttempts)
assert.Equal(
t, database.TargetTypeHTTP, reloaded.Type,
"the edit form must not change a target's type",
@@ -329,16 +301,16 @@ func assertEditRoundTripsHeadersAndTimeout(
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
cfg := storedHTTPConfig(t, env, target.ID)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, 21, cfg.Timeout)
assert.Equal(t, 21, stored.TimeoutSeconds)
assert.Equal(
t,
map[string]string{
"Authorization": "Bearer rotated",
"X-Trace": "on",
},
cfg.Headers,
stored.Headers,
)
}
@@ -358,31 +330,31 @@ func assertEditClearingRemovesThem(
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
cfg := storedHTTPConfig(t, env, target.ID)
stored := storedTarget(t, env, target.ID)
assert.Empty(t, cfg.Headers)
assert.Zero(t, cfg.Timeout)
assert.Empty(t, stored.Headers)
assert.Zero(t, stored.TimeoutSeconds)
}
// Retries are offered only by the forms for target types that retry.
// An absent field means the form does not edit retries, not that
// they should be turned off.
func assertEditKeepsAbsentMaxRetries(
// 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.MaxRetries)
require.Equal(t, 3, target.MaxAttempts)
form := editForm(editOriginalURL, "", "")
form.Del("max_retries")
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).MaxRetries,
t, 3, storedTarget(t, env, target.ID).MaxAttempts,
)
}
@@ -401,7 +373,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
w := serveTarget(
env, http.MethodGet,
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
@@ -414,6 +386,167 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
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</label>`)
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// 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{
`<option value="720h" selected>`,
`<option value="daily" selected>`,
},
database.Target{
ArchiveExpiry: 24 * time.Hour,
ArchiveRotation: rotationHourly,
},
},
{database.TargetTypeLog, "", "", nil, database.Target{}},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook, target := addTargetThroughForm(
t, env, tc.targetType, tc.created,
)
page := targetEditPage(t, env, webhook.ID, target.ID)
for _, shown := range tc.shown {
assert.Contains(t, page, shown)
}
form, err := url.ParseQuery(tc.edited)
require.NoError(t, err)
form.Set("name", "edited")
w := submitTargetEdit(env, webhook.ID, target.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
got := storedTarget(t, env, target.ID)
assert.Equal(t, "edited", got.Name)
assert.Equal(t, tc.want, targetSettingsOf(got))
})
}
}
// TestHandleTarget_ForwardQuery covers the HTTP target's setting that
// passes each event's query string on to it: the add target form
// stores it checked, the edit form starts with it checked and turns it
// off when saved unchecked, and both forms come back with it checked
// when refused.
func TestHandleTarget_ForwardQuery(t *testing.T) {
t.Parallel()
const checkbox = `name="forward_query" value="on" checked`
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/hook/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "forwarding")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", editOriginalURL)
form.Set("forward_query", "on")
w := serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.True(t, storedTarget(t, env, targets[0].ID).ForwardQuery)
w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
edit := editForm(editOriginalURL, "", "")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedTarget(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
form.Set("url", editBlockedURL)
w = serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "data-forward-query")
}
// TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage.
//
@@ -487,7 +620,7 @@ func assertEditRejectsBlockedDestination(
assert.Contains(t, w.Body.String(), "Invalid target URL")
assert.Equal(
t, editOriginalURL,
storedHTTPConfig(t, env, target.ID).URL,
storedTarget(t, env, target.ID).URL,
"a rejected edit must leave the stored config alone",
)
}
@@ -508,7 +641,7 @@ func assertEditIgnoresQueryString(
w := serveTarget(
env, http.MethodPost,
"/source/"+webhook.ID+"/targets/"+target.ID+
"/hook/"+webhook.ID+"/targets/"+target.ID+
"/edit?url="+url.QueryEscape(editReplacedURL)+
"&headers="+url.QueryEscape(editAuthHeader),
form,
@@ -516,9 +649,9 @@ func assertEditIgnoresQueryString(
assert.Equal(t, http.StatusBadRequest, w.Code)
cfg := storedHTTPConfig(t, env, target.ID)
assert.Equal(t, editOriginalURL, cfg.URL)
assert.Empty(t, cfg.Headers)
stored := storedTarget(t, env, target.ID)
assert.Equal(t, editOriginalURL, stored.URL)
assert.Empty(t, stored.Headers)
}
func assertEditRejectsHeaders(
@@ -536,7 +669,7 @@ func assertEditRejectsHeaders(
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid headers")
assert.Empty(
t, storedHTTPConfig(t, env, target.ID).Headers,
t, storedTarget(t, env, target.ID).Headers,
"a rejected header must not be stored",
)
}
@@ -556,11 +689,83 @@ func assertEditRejectsTimeout(
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid timeout")
assert.Equal(
t, 9, storedHTTPConfig(t, env, target.ID).Timeout,
t, 9, storedTarget(t, env, target.ID).TimeoutSeconds,
"a rejected timeout must leave the stored one alone",
)
}
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
// a target of each type and checks that the edit form comes back with
// the reason and every value submitted, and that nothing is saved.
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator submitted, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_attempts=3",
"Invalid target URL",
},
{
database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_attempts=25",
"Invalid delivery attempts",
},
{
database.TargetTypeDatabase,
"name=edited&expiry=7d&rotation=daily",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
w := submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea, and expiry and
// rotation its selects; every other field is an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
switch field {
case "headers":
shown = ">" + form.Get(field) + "</textarea>"
case "expiry", "rotation":
shown = `<option value="` + form.Get(field) + `" selected>`
}
assert.Contains(t, page, shown)
}
assert.Equal(
t, target.Name, storedTarget(t, env, target.ID).Name,
"a refused edit must save nothing",
)
})
}
}
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
// the delete and toggle routes are: ownership is decided by the
// webhook, and the target is then scoped to it.
@@ -592,7 +797,7 @@ func assertTargetOfAnotherWebhook404s(
get := serveTarget(
env, http.MethodGet,
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, get.Code)
@@ -604,7 +809,7 @@ func assertTargetOfAnotherWebhook404s(
assert.Equal(
t, editOriginalURL,
storedHTTPConfig(t, env, target.ID).URL,
storedTarget(t, env, target.ID).URL,
)
}
@@ -625,13 +830,114 @@ func assertWebhookOfAnotherUser404s(
target := seedConfiguredTarget(
t, env.db, other.ID, database.TargetTypeHTTP,
`{"url":"`+editOriginalURL+`"}`,
database.Target{URL: editOriginalURL},
)
w := serveTarget(
env, http.MethodGet,
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// renamedTargetName is the name the rename tests give a target.
const renamedTargetName = "Long Term"
// TestHandleTargetEditSubmit_RenamesArchive proves that renaming a
// database target renames its archive, that a save that keeps the
// name renames nothing, that a target of another type has no archive
// to rename, and that a target whose archive cannot be renamed keeps
// its stored name. When a file already has the archive's new name,
// the edit is refused with an error naming that file.
func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
rename := url.Values{"name": {renamedTargetName}}
w := submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t,
[]archiveRename{{archive.ID, wh.Name, renamedTargetName}},
env.archives.Renames(),
)
w = submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"a save that keeps the name renames nothing",
)
httpWebhook, httpTarget := seedHTTPTarget(t, env, "", "")
w = submitTargetEdit(
env, httpWebhook.ID, httpTarget.ID,
editForm(editOriginalURL, "", ""),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"an HTTP target has no archive to rename",
)
again := url.Values{"name": {"Again"}}
env.archives.FailRenames(archive.ID, errInjectedRename)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
"a target whose archive was not renamed keeps its name",
)
env.archives.FailRenames(archive.ID, errNameTaken)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="Again"`,
"the form comes back with the name submitted",
)
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
)
}
// TestHandleTargetEditSubmit_FailedSaveRenamesBack proves that when a
// database target's archive is renamed but the new name cannot be
// saved, the archive is renamed back to the stored name and the
// stored name stays.
func TestHandleTargetEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "targets")
form := url.Values{}
form.Set("name", renamedTargetName)
w := submitTargetEdit(env, wh.ID, archive.ID, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, archive.Name, storedTarget(t, env, archive.ID).Name,
)
assert.Equal(
t,
[]archiveRename{
{archive.ID, wh.Name, renamedTargetName},
{archive.ID, wh.Name, archive.Name},
},
env.archives.Renames(),
)
}