Store a target's settings as columns (closes #543)
check / check (push) Waiting to run

A target's settings move from the per-type JSON string in
targets.config to columns: url, headers, timeout_seconds,
forward_query, archive_expiry and archive_rotation. A column a type
does not use stays empty. headers holds the header map as JSON text
through GORM's JSON serializer, and archive_expiry holds a
time.Duration, 0 meaning never. The forms check each setting once,
with the same refusals and messages, and the edit form is filled from
the columns, so the per-type config structs, their parsers and the
"could not be read" branches are gone. A delivery task carries the
six settings in place of the JSON string; tests check that each copy
into a task, and back out of one, keeps all six.

Model: opus-5-5
This commit was merged in pull request #554.
This commit is contained in:
2026-10-15 23:02:39 +02:00
parent ded39aa158
commit 8406c77949
71 changed files with 1665 additions and 2168 deletions
+23 -1
View File
@@ -1,8 +1,14 @@
package handlers
import (
"strconv"
"time"
)
const (
// archiveExpiryNever is the archive expiry that keeps archived
// events forever. A stored empty expiry means the same.
// events forever. It is stored as an ArchiveExpiry of 0, and an
// empty expiry on a form means the same.
archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the
@@ -56,3 +62,19 @@ func archiveExpiryOptions(expiry string) []archiveChoice {
return append([]archiveChoice{own}, options...)
}
// formatArchiveExpiry writes a stored ArchiveExpiry the way the
// expiry select spells its values, so the target edit form starts on
// the stored choice: never for 0, a whole number of hours as "720h",
// and anything else as Go writes a duration, which the form accepts
// back unchanged.
func formatArchiveExpiry(expiry time.Duration) string {
switch {
case expiry == 0:
return archiveExpiryNever
case expiry%time.Hour == 0:
return strconv.FormatInt(int64(expiry/time.Hour), 10) + "h"
default:
return expiry.String()
}
}
+20 -16
View File
@@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -94,14 +95,18 @@ func TestArchiveExpiryChoices(t *testing.T) {
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
choices := []struct {
value string
stored time.Duration
shown string
}{
{expiryNever, 0, expiryNever},
{"1h", time.Hour, "1 hour"},
{"12h", 12 * time.Hour, "12 hours"},
{"24h", 24 * time.Hour, "1 day"},
{"720h", 720 * time.Hour, "30 days"},
{"2160h", 2160 * time.Hour, "90 days"},
{"8760h", 8760 * time.Hour, "365 days"},
}
for _, choice := range choices {
@@ -122,9 +127,7 @@ func TestArchiveExpiryChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(t, choice.stored, targets[0].ArchiveExpiry)
assert.Equal(
t, []string{choice.shown},
@@ -138,7 +141,7 @@ func TestArchiveExpiryChoices(t *testing.T) {
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// database target whose stored expiry is 0, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
@@ -148,7 +151,8 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{},
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
@@ -157,7 +161,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
database.Target{ArchiveExpiry: 36 * time.Hour},
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
@@ -179,7 +183,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
assert.Equal(
t, 36*time.Hour, storedTarget(t, env, unlisted.ID).ArchiveExpiry,
)
}
+38 -24
View File
@@ -16,8 +16,12 @@ import (
"sneak.berlin/go/webhooker/internal/database"
)
// rotationNone is the archive rotation that keeps one file.
const rotationNone = "none"
// The archive rotations these tests store: none keeps one file.
const (
rotationNone = "none"
rotationDaily = "daily"
rotationHourly = "hourly"
)
// rotationShown returns the archive rotations the webhook page's
// target list shows.
@@ -41,7 +45,9 @@ func TestArchiveRotationChoices(t *testing.T) {
env := setupSourceTest(t)
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
for _, rotation := range []string{
rotationNone, "monthly", rotationDaily, rotationHourly,
} {
t.Run(rotation, func(t *testing.T) {
t.Parallel()
@@ -60,10 +66,8 @@ func TestArchiveRotationChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry)
assert.Equal(t, rotation, targets[0].ArchiveRotation)
assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID))
@@ -78,26 +82,37 @@ func TestArchiveRotationChoices(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
`{"expiry":"720h","rotation":"hourly"}`,
storedTarget(t, env, targets[0].ID).Config,
)
stored := storedTarget(t, env, targets[0].ID)
assert.Equal(t, 720*time.Hour, stored.ArchiveExpiry)
assert.Equal(t, rotationHourly, stored.ArchiveRotation)
})
}
}
// TestArchiveRotationEditStartsOnNone checks the edit form of a
// database target with no rotation stored starts on none.
// TestArchiveRotationEditStartsOnNone checks that a database target
// added with no rotation is stored with none and shown with none, and
// that its edit form starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
page := targetEditPage(t, env, webhook.ID, target.ID)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
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)
assert.Equal(t, rotationNone, targets[0].ArchiveRotation)
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
}
@@ -125,7 +140,7 @@ func TestArchiveRotationRefused(t *testing.T) {
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
database.Target{ArchiveRotation: rotationDaily},
)
form.Del("type")
@@ -133,8 +148,8 @@ func TestArchiveRotationRefused(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.JSONEq(t,
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
assert.Equal(
t, rotationDaily, storedTarget(t, env, target.ID).ArchiveRotation,
)
}
@@ -161,9 +176,8 @@ func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry)
assert.Equal(t, rotationDaily, targets[0].ArchiveRotation)
}
// TestArchiveFileView_Rotated describes a daily target's archive files
@@ -178,7 +192,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
database.Target{ArchiveRotation: rotationDaily},
)
path := archive.Path(env.dbMgr, &webhook, target)
+20 -15
View File
@@ -300,21 +300,26 @@ func createReplayDelivery(
}
return delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: target.Type,
TargetConfig: target.Config,
MaxAttempts: target.MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
AttemptNum: 1,
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: target.Type,
TargetURL: target.URL,
TargetHeaders: target.Headers,
TimeoutSeconds: target.TimeoutSeconds,
ForwardQuery: target.ForwardQuery,
ArchiveExpiry: target.ArchiveExpiry,
ArchiveRotation: target.ArchiveRotation,
MaxAttempts: target.MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
AttemptNum: 1,
}, nil
}
+54 -17
View File
@@ -192,7 +192,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(
@@ -205,13 +205,13 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
before := loadDelivery(t, webhookDB, original.ID)
// The operator fixes the destination, which is the whole reason
// to replay. The replay must use this, not the config the
// original delivery ran against.
const fixedConfig = `{"url":"http://93.184.216.34/fixed"}`
// to replay. The replay must use this, not the URL the original
// delivery ran against.
const fixedURL = "http://93.184.216.34/fixed"
require.NoError(t, db.DB().Model(&database.Target{}).
Where("id = ?", tgt.ID).
Update("config", fixedConfig).Error)
Update("url", fixedURL).Error)
w := postReplay(t, h, sess, wh.ID, original.ID)
@@ -240,7 +240,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assertReplayTask(
t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedConfig,
t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedURL,
)
assertNoLeakedTarget(t, webhookDB)
}
@@ -277,15 +277,15 @@ func assertDeliveryUntouched(
// assertReplayTask proves the task handed to the delivery engine is
// the one the receiver would build for this event and this target, and
// that it carries wantConfig — the target's configuration as it stands
// now rather than as the original delivery ran against it.
// that it carries wantURL — the target's URL as it stands now rather
// than as the original delivery ran against it.
func assertReplayTask(
t *testing.T,
task delivery.Task,
webhookID string,
event *database.Event,
target *database.Target,
wantDeliveryID, wantConfig string,
wantDeliveryID, wantURL string,
) {
t.Helper()
@@ -295,9 +295,9 @@ func assertReplayTask(
assert.Equal(t, event.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.JSONEq(
t, wantConfig, task.TargetConfig,
"replay must use the target's current configuration",
assert.Equal(
t, wantURL, task.TargetURL,
"replay must use the target's current URL",
)
assert.Equal(t, event.Method, task.Method)
assert.Equal(
@@ -327,6 +327,43 @@ func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
)
}
// TestHandleDeliveryReplay_TaskCarriesEveryTargetSetting proves the
// task a replay hands the delivery engine carries every one of the
// target's settings.
func TestHandleDeliveryReplay_TaskCarriesEveryTargetSetting(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
notif *recordingNotifier
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
targetWithEverySetting(),
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assertTaskCarriesTargetSettings(t, tasks[0], tgt)
}
// TestHandleDeliveryReplay_RefusesDeletedTarget proves the required
// refusal: a target deleted since the delivery ran is reported as
// deleted rather than erroring, and nothing is created or queued.
@@ -349,7 +386,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(
@@ -424,7 +461,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(
@@ -498,7 +535,7 @@ func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -556,7 +593,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -615,7 +652,7 @@ func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
+12 -12
View File
@@ -86,12 +86,12 @@ func seedFailedDeliveryWithResponse(
}
// seedFailureAndRender seeds a failed delivery against a
// target of the given type and config, and returns the
// target of the given type and URL, and returns the
// rendered event log page.
func seedFailureAndRender(
t *testing.T,
targetType database.TargetType,
config, responseBody string,
targetURL, responseBody string,
) string {
t.Helper()
@@ -109,7 +109,7 @@ func seedFailureAndRender(
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, targetType, config,
t, db, wh.ID, targetType, database.Target{URL: targetURL},
)
seedFailedDeliveryWithResponse(
@@ -129,7 +129,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
body := seedFailureAndRender(
t,
database.TargetTypeHTTP,
`{"url":"https://example.com/hook/abc"}`,
"https://example.com/hook/abc",
"upstream exploded",
)
@@ -167,7 +167,7 @@ func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
body := seedFailureAndRender(
t,
database.TargetTypeHTTP,
`{"url":"https://example.com/hook/abc"}`,
"https://example.com/hook/abc",
payload,
)
@@ -189,7 +189,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInResponse(
body := seedFailureAndRender(
t,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
slackWebhookURL,
"no_service: "+slackWebhookURL,
)
@@ -228,7 +228,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInError(
tgt := seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
database.Target{URL: slackWebhookURL},
)
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -300,7 +300,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredByTheEngineCut(
body := seedFailureAndRender(
t,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
slackWebhookURL,
stored,
)
@@ -335,7 +335,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredBySQLCut(
body := seedFailureAndRender(
t,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
slackWebhookURL,
stored,
)
@@ -373,7 +373,7 @@ func TestHandleSourceLogs_RedactsForSoftDeletedTarget(
tgt := seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
database.Target{URL: slackWebhookURL},
)
seedFailedDeliveryWithResponse(
@@ -413,7 +413,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, "",
t, db, wh.ID, database.TargetTypeLog, database.Target{},
)
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -480,7 +480,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, "",
t, db, wh.ID, database.TargetTypeLog, database.Target{},
)
stored := strings.Repeat("A", responseCap*4) + tail
+3 -3
View File
@@ -13,8 +13,8 @@ import (
// DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the
// stored configuration blob — which holds the target's
// credential — has no path to the template.
// target's URL and headers — which hold the target's
// credential — have no path to the template.
type DeliveryView struct {
ID string
Status database.DeliveryStatus
@@ -126,7 +126,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// loadTargetMap loads targets into a map of display-safe
// views keyed by target ID, each paired with its redactor.
// The projection happens here so that no caller can hand a
// raw target, configuration blob and all, to a template: the
// raw target, URL and headers and all, to a template: the
// raw rows do not leave this function.
//
// The load is Unscoped because deleting a target only soft
+7 -7
View File
@@ -151,7 +151,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
// captured. It has no prior delivery of anything.
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
w := postResubmit(t, h, sess, wh.ID, original.ID)
@@ -274,7 +274,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
@@ -368,7 +368,7 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
original := seedStoredEvent(t, dbMgr, wh.ID, stored)
@@ -425,11 +425,11 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
wh := seedWebhook(t, db)
active := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
off := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`/off"}`,
database.Target{URL: resubmitTargetURL + "/off"},
)
require.NoError(t, db.DB().Model(&database.Target{}).
@@ -528,7 +528,7 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
mine := seedWebhook(t, db)
seedConfiguredTarget(
t, db, mine.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
@@ -575,7 +575,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+resubmitTargetURL+`"}`,
database.Target{URL: resubmitTargetURL},
)
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
+17 -25
View File
@@ -133,36 +133,28 @@ func (s *Handlers) RenderTemplateForTest(
s.renderTemplate(w, r, pageTemplate, data)
}
// BuildSlackTargetConfigForTest exposes
// buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
// SetSlackTargetSettingsForTest exposes setSlackTargetSettings for use
// in the handlers_test package.
func (s *Handlers) SetSlackTargetSettingsForTest(
ctx context.Context,
target *database.Target,
targetURL string,
) (string, string, error) {
return s.buildSlackTargetConfig(ctx, targetURL)
) string {
return s.setSlackTargetSettings(ctx, target, targetURL)
}
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package, taking the form fields
// an HTTP target's configuration is built from.
func (s *Handlers) BuildHTTPTargetConfigForTest(
ctx context.Context,
targetURL, headers, timeout string,
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
})
}
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
// SetDatabaseTargetSettingsForTest exposes setDatabaseTargetSettings
// for use in the handlers_test package.
func SetDatabaseTargetSettingsForTest(
target *database.Target,
expiry, rotation string,
) (string, string, error) {
return buildDatabaseTargetConfig(expiry, rotation)
) string {
return setDatabaseTargetSettings(target, expiry, rotation)
}
// FormatArchiveExpiryForTest exposes formatArchiveExpiry.
func FormatArchiveExpiryForTest(expiry time.Duration) string {
return formatArchiveExpiry(expiry)
}
// ArchiveFileViewForTest exposes archiveFileView, which describes a
+86 -55
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
@@ -354,7 +355,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
)
}
func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
func TestSetSlackTargetSettings_AcceptsPublicURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
@@ -364,16 +365,17 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
)
const hookURL = "http://93.184.216.34/services/T00/B00/xxx"
var target database.Target
errMsg := h.SetSlackTargetSettingsForTest(t.Context(), &target, hookURL)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Contains(t, cfg, "webhookUrl")
assert.Equal(t, hookURL, target.URL)
}
func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
func TestSetSlackTargetSettings_RejectsReservedURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
@@ -383,13 +385,14 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
var target database.Target
errMsg := h.SetSlackTargetSettingsForTest(
t.Context(), &target, "http://169.254.169.254/latest/meta-data/",
)
require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL")
assert.Empty(t, cfg)
assert.Empty(t, target.URL)
}
func TestRenderTemplate(t *testing.T) {
@@ -483,72 +486,100 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
func TestSetDatabaseTargetSettings_Valid(t *testing.T) {
t.Parallel()
// Empty expiry and rotation: the keep-forever, one-file default,
// empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
cases := []struct {
expiry, rotation string
wantExpiry time.Duration
wantRotation string
}{
// Empty expiry and rotation: the keep-forever, one-file
// default.
{"", "", 0, rotationNone},
{expiryNever, "", 0, rotationNone},
{" 36h ", "", 36 * time.Hour, rotationNone},
{"", rotationDaily, 0, rotationDaily},
{"90m", rotationHourly, 90 * time.Minute, rotationHourly},
}
// Explicit never is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
for _, tc := range cases {
var target database.Target
// A positive duration is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
errMsg := handlers.SetDatabaseTargetSettingsForTest(
&target, tc.expiry, tc.rotation,
)
// A rotation is stored as config, with or without an expiry.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
"720h", "hourly",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
assert.Empty(t, errMsg, tc)
assert.Equal(t, tc.wantExpiry, target.ArchiveExpiry, tc)
assert.Equal(t, tc.wantRotation, target.ArchiveRotation, tc)
}
}
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
// TestSetDatabaseTargetSettings_RejectsBadExpiry pins each refusal's
// whole message, as the form shows it.
func TestSetDatabaseTargetSettings_RejectsBadExpiry(
t *testing.T,
) {
t.Parallel()
for _, bad := range []string{"nonsense", "7d", "-5h"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
cases := map[string]string{
"nonsense": `Invalid archive expiry: expiry must be "never" or ` +
`a Go duration such as "720h": time: invalid duration ` +
`"nonsense"`,
"7d": `Invalid archive expiry: expiry must be "never" or a Go ` +
`duration such as "720h": time: unknown unit "d" in ` +
`duration "7d"`,
"-5h": `Invalid archive expiry: expiry must be a positive ` +
`duration or "never": "-5h"`,
}
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive expiry",
"expiry %q should be refused", bad,
for bad, want := range cases {
var target database.Target
errMsg := handlers.SetDatabaseTargetSettingsForTest(
&target, bad, "",
)
assert.Empty(t, cfg)
assert.Equal(t, want, errMsg, "expiry %q", bad)
}
}
func TestBuildDatabaseTargetConfig_RejectsBadRotation(
func TestSetDatabaseTargetSettings_RejectsBadRotation(
t *testing.T,
) {
t.Parallel()
for _, bad := range []string{"weekly", "Daily", " none"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
var target database.Target
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive rotation",
"rotation %q should be refused", bad,
errMsg := handlers.SetDatabaseTargetSettingsForTest(
&target, "", bad,
)
assert.Equal(
t,
"Invalid archive rotation: rotation must be none, "+
"monthly, daily or hourly: "+strconv.Quote(bad),
errMsg,
)
assert.Empty(t, cfg)
}
}
// TestFormatArchiveExpiry covers the expiries the expiry choices do
// not: TestArchiveExpiryChoices shows the edit form starting on each
// choice. An expiry in whole hours is written in hours, and any other
// as Go writes it, which the form accepts back unchanged.
func TestFormatArchiveExpiry(t *testing.T) {
t.Parallel()
cases := map[time.Duration]string{
0: expiryNever,
36 * time.Hour: "36h",
90 * time.Minute: "1h30m0s",
}
for expiry, want := range cases {
assert.Equal(t, want, handlers.FormatArchiveExpiryForTest(expiry))
}
}
+23 -12
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -54,15 +55,26 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
env := setupSourceTest(t)
// Each value the archive pruning choice submits, after an empty
// one that stands for the archive checkbox left off.
expiries := []string{
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
// Each value the archive pruning choice submits and the expiry it
// stores, after an empty one that stands for the archive checkbox
// left off.
expiries := []struct {
value string
stored time.Duration
}{
{"", 0},
{expiryNever, 0},
{"1h", time.Hour},
{"12h", 12 * time.Hour},
{"24h", 24 * time.Hour},
{"720h", 720 * time.Hour},
{"2160h", 2160 * time.Hour},
{"8760h", 8760 * time.Hour},
}
for _, httpURL := range []string{"", editOriginalURL} {
for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry
name := "url=" + httpURL + " archive=" + expiry.value
t.Run(name, func(t *testing.T) {
t.Parallel()
@@ -72,9 +84,9 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
form.Set("http_url", httpURL)
form.Set("archive_expiry", "720h")
if expiry != "" {
if expiry.value != "" {
form.Set("archive", "on")
form.Set("archive_expiry", expiry)
form.Set("archive_expiry", expiry.value)
}
w := submitCreateForm(env, form)
@@ -96,17 +108,16 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
wantCount++
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
byType[database.TargetTypeHTTP].Config)
assert.Equal(t, httpURL, byType[database.TargetTypeHTTP].URL)
}
if expiry != "" {
if expiry.value != "" {
wantCount++
assert.Equal(t, "Archive",
byType[database.TargetTypeDatabase].Name)
assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
byType[database.TargetTypeDatabase].Config)
assert.Equal(t, expiry.stored,
byType[database.TargetTypeDatabase].ArchiveExpiry)
}
assert.Len(t, byType, wantCount)
+62 -86
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
@@ -25,25 +26,24 @@ const (
slackSecretPath
)
// seedConfiguredTarget inserts a target with a stored config
// blob and returns it.
// seedConfiguredTarget inserts an active target of targetType with
// the settings (URL to ArchiveRotation) that settings carries, and
// returns it.
func seedConfiguredTarget(
t *testing.T,
db *database.Database,
webhookID string,
targetType database.TargetType,
config string,
settings database.Target,
) *database.Target {
t.Helper()
tgt := &database.Target{
WebhookID: webhookID,
Name: "t-" + string(targetType),
Type: targetType,
Active: true,
Config: config,
MaxAttempts: 1,
}
tgt := &settings
tgt.WebhookID = webhookID
tgt.Name = "t-" + string(targetType)
tgt.Type = targetType
tgt.Active = true
tgt.MaxAttempts = 1
require.NoError(
t,
@@ -108,89 +108,59 @@ func serveSourceDetailPage(
return w
}
// TestHandleSourceDetail_MasksSlackWebhookURL is the
// load-bearing regression test for the credential leak: the
// rendered page must show the Slack target without any of the
// secret path segments of its webhook URL.
func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) {
// TestHandleSourceDetail_MasksTargetURL is the load-bearing
// regression test for the credential leak: the rendered page must
// show a Slack target, and an http target, without any of the secret
// path segments of its URL. An http target's destination is routinely
// an incoming-webhook endpoint too, so the same Slack URL serves for
// both.
func TestHandleSourceDetail_MasksTargetURL(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
labels := map[database.TargetType]string{
database.TargetTypeSlack: "Webhook URL",
database.TargetTypeHTTP: "Destination URL",
}
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
for targetType, label := range labels {
t.Run(string(targetType), func(t *testing.T) {
t.Parallel()
t.Cleanup(app.RequireStop)
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
body := renderSourceDetailPage(t, h, sess, wh.ID)
t.Cleanup(app.RequireStop)
assert.NotContains(t, body, slackSecretPath)
assert.NotContains(t, body, "T00000000")
assert.NotContains(t, body, "B00000000")
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.NotContains(t, body, "webhookUrl")
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID, targetType,
database.Target{URL: slackWebhookURL},
)
assert.Contains(t, body, "Webhook URL")
assert.Contains(t, body, "https://hooks.slack.com/...")
}
body := renderSourceDetailPage(t, h, sess, wh.ID)
// TestHandleSourceDetail_MasksHTTPDestinationURL is the
// regression test for the same leak reached through the http
// target: its destination is routinely an incoming-webhook
// endpoint whose path segments are the credential, so the
// rendered page must not contain them.
func TestHandleSourceDetail_MasksHTTPDestinationURL(
t *testing.T,
) {
t.Parallel()
assert.NotContains(t, body, slackSecretPath)
assert.NotContains(t, body, "T00000000")
assert.NotContains(t, body, "B00000000")
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeHTTP,
`{"url":"`+slackWebhookURL+`"}`,
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.NotContains(t, body, slackSecretPath)
assert.NotContains(t, body, "T00000000")
assert.NotContains(t, body, "B00000000")
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.Contains(t, body, "Destination URL")
assert.Contains(t, body, "https://hooks.slack.com/...")
assert.Contains(t, body, label)
assert.Contains(t, body, "https://hooks.slack.com/...")
})
}
}
// TestHandleSourceDetail_RendersNamedTargetFields proves the
// other target types render labelled fields rather than the
// stored blob.
// stored values.
func TestHandleSourceDetail_RendersNamedTargetFields(
t *testing.T,
) {
@@ -212,18 +182,24 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeHTTP,
`{"url":"https://example.com/hook","timeout":30,`+
`"headers":{"Authorization":"Bearer sekrit"}}`,
database.Target{
URL: "https://example.com/hook",
TimeoutSeconds: 30,
Headers: map[string]string{"X-Api-Key": "sekrit"},
},
)
seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeDatabase,
`{"expiry":"720h"}`,
database.Target{
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: rotationNone,
},
)
seedConfiguredTarget(
t, db, wh.ID,
database.TargetType("carrier-pigeon"),
`{"beak":"sharp"}`,
database.Target{URL: "https://pigeon.example/beak"},
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
@@ -242,8 +218,8 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, list, "Archive rotation: none")
assert.Contains(t, body, `<option value="database">Archive</option>`)
// An unknown type gets the neutral placeholder, never the
// stored blob.
// An unknown type gets the neutral placeholder, never its
// stored URL.
assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak")
}
@@ -161,7 +161,7 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) {
tgt := seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
database.Target{URL: slackWebhookURL},
)
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
@@ -175,7 +175,6 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) {
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.NotContains(t, body, "webhookUrl")
// The name is there; only the credential is not.
assert.Contains(t, body, tgt.Name+deletedMarker)
+1 -2
View File
@@ -138,7 +138,7 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
tgt := seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
database.Target{URL: slackWebhookURL},
)
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
@@ -151,7 +151,6 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.NotContains(t, body, "webhookUrl")
// The page still identifies the delivery's target.
assert.Contains(t, body, tgt.Name)
+72 -107
View File
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -65,20 +64,14 @@ func (h *Handlers) processTargetCreate(
) {
in := targetFormInputFrom(r)
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
target, errMsg := h.newTarget(r.Context(), webhook.ID, in)
if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg)
return
}
err = h.db.DB().Create(target).Error
err := h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, r, "failed to create target", err)
@@ -93,15 +86,13 @@ func (h *Handlers) processTargetCreate(
// newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form
// shows. An error is the server's fault, not a refusal: the accepted
// configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
// shows. Every form that creates a target goes through here, so they
// all accept and refuse the same things.
func (h *Handlers) newTarget(
ctx context.Context,
webhookID string,
in targetFormInput,
) (*database.Target, string, error) {
) (*database.Target, string) {
target := &database.Target{
WebhookID: webhookID,
Type: in.Type,
@@ -109,34 +100,33 @@ func (h *Handlers) newTarget(
MaxAttempts: 1,
}
errMsg, err := h.setTargetFromForm(ctx, target, in)
if err != nil || errMsg != "" {
return nil, errMsg, err
errMsg := h.setTargetFromForm(ctx, target, in)
if errMsg != "" {
return nil, errMsg
}
return target, "", nil
return target, ""
}
// setTargetFromForm validates a target form against the target's type
// and, when it accepts it, sets the target's name, configuration and
// number of delivery attempts from it. It returns the message the form
// shows for anything it refuses, an unknown type among them, and then
// leaves the target unchanged; an error is the server's fault, as for
// newTarget.
// and sets the target's name, settings and number of delivery attempts
// from it. It returns the message the form shows for anything it
// refuses, an unknown type among them; the caller then discards the
// target, which may be partly set.
// The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm(
ctx context.Context,
target *database.Target,
in targetFormInput,
) (string, error) {
) string {
if in.Name == "" {
return "Name is required", nil
return "Name is required"
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if err != nil || errMsg != "" {
return errMsg, err
errMsg := h.setTargetSettings(ctx, target, in)
if errMsg != "" {
return errMsg
}
// An empty max_attempts keeps the target's count: the default of
@@ -147,14 +137,13 @@ func (h *Handlers) setTargetFromForm(
// target is delivering with.
maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts)
if err != nil {
return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil
return "Invalid delivery attempts: " + attemptsErrorMessage(err)
}
target.Name = in.Name
target.Config = configJSON
target.MaxAttempts = maxAttempts
return "", nil
return ""
}
// targetFormInput carries the raw values of a target form. Both the
@@ -213,84 +202,81 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, or returns the message the form shows
// for a value it refuses. An error is the server's fault, not a
// refusal: the accepted configuration could not be encoded. Which
// fields of in apply depends on the target type; a type without a URL
// ignores any URL submitted.
func (h *Handlers) buildTargetConfig(
// setTargetSettings sets a target's settings from the submitted form
// values, or returns the message the form shows for a value it
// refuses. Which fields of in apply depends on the target type; a
// type without a URL ignores any URL submitted, and the settings a
// type does not use are left empty.
func (h *Handlers) setTargetSettings(
ctx context.Context,
targetType database.TargetType,
target *database.Target,
in targetFormInput,
) (string, string, error) {
switch targetType {
) string {
switch target.Type {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(ctx, in)
return h.setHTTPTargetSettings(ctx, target, in)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
return h.setSlackTargetSettings(ctx, target, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
return setDatabaseTargetSettings(target, in.Expiry, in.Rotation)
case database.TargetTypeLog:
return "", "", nil
return ""
default:
return "", "Invalid target type", nil
return "Invalid target type"
}
}
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers, timeout and
// query string setting the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
// setHTTPTargetSettings sets an HTTP target's SSRF-validated
// destination plus the optional headers, timeout and query string
// setting the delivery path honours.
func (h *Handlers) setHTTPTargetSettings(
ctx context.Context,
target *database.Target,
in targetFormInput,
) (string, string, error) {
) string {
errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets",
)
if errMsg != "" {
return "", errMsg, nil
return errMsg
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
return "", fmt.Sprintf("Invalid headers: %v", err), nil
return fmt.Sprintf("Invalid headers: %v", err)
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
return fmt.Sprintf("Invalid timeout: %v", err)
}
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
ForwardQuery: in.ForwardQuery,
})
target.URL = in.URL
target.Headers = headers
target.TimeoutSeconds = timeout
target.ForwardQuery = in.ForwardQuery
return configJSON, "", err
return ""
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
// setSlackTargetSettings sets a Slack target's one setting, its
// SSRF-validated webhook URL.
func (h *Handlers) setSlackTargetSettings(
ctx context.Context,
target *database.Target,
targetURL string,
) (string, string, error) {
) string {
errMsg := h.validateTargetURL(
ctx, targetURL,
"Webhook URL is required for Slack targets",
)
if errMsg != "" {
return "", errMsg, nil
return errMsg
}
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
target.URL = targetURL
return configJSON, "", err
return ""
}
// validateTargetURL refuses an empty or SSRF-blocked destination,
@@ -341,52 +327,31 @@ func (h *Handlers) validateTargetURL(
return ""
}
// marshalTargetConfig serialises a target configuration for storage.
func marshalTargetConfig(cfg any) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(configBytes), nil
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
// setDatabaseTargetSettings sets a database (archive) target's
// expiry and rotation. Both are optional: an empty or never expiry
// keeps the archive forever, and an empty rotation is stored as none,
// one file. A bad value is refused here, at the form, instead of
// being stored.
func setDatabaseTargetSettings(
target *database.Target,
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
err := archive.ValidateExpiry(expiry)
) string {
archiveExpiry, err := archive.ParseExpiry(strings.TrimSpace(expiry))
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
return fmt.Sprintf("Invalid archive expiry: %v", err)
}
err = archive.ValidateRotation(rotation)
if err != nil {
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
return fmt.Sprintf("Invalid archive rotation: %v", err)
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
if rotation == "" {
rotation = archiveRotationNone
}
if rotation != "" {
cfg["rotation"] = rotation
}
target.ArchiveExpiry = archiveExpiry
target.ArchiveRotation = rotation
if len(cfg) == 0 {
return "", "", nil
}
configJSON, err := marshalTargetConfig(cfg)
return configJSON, "", err
return ""
}
@@ -176,7 +176,7 @@ func TestHandleTargetCreate_BodyURLStillCreatesTheTarget(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Contains(t, targets[0].Config, targetSecretSegments)
assert.Equal(t, targetSecretURL, targets[0].URL)
// The body carried the credential, so the access log must still
// not have it: the log records the request line only.
+74 -33
View File
@@ -6,73 +6,114 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHandleTargetCreate_EveryType adds a target of each type. Each
// submission carries a url: only the http and slack types store one.
// TestHandleTargetCreate_EveryType adds a target of each type and
// checks the settings it stores. Each submission carries a url and an
// expiry: only the http and slack types store the url, only the
// database type the expiry, and every setting a type does not use
// stays empty.
func TestHandleTargetCreate_EveryType(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is the rest of each submission, as a query string.
// fields is the rest of each submission, as a query string, and
// want holds the settings stored.
cases := []struct {
targetType database.TargetType
fields string
wantConfig string
want database.Target
wantAttempts int
}{
{
database.TargetTypeHTTP, "timeout=12&max_attempts=3",
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
database.TargetTypeHTTP,
"timeout=12&max_attempts=3&forward_query=on",
database.Target{
URL: editOriginalURL,
Headers: map[string]string{},
TimeoutSeconds: 12,
ForwardQuery: true,
}, 3,
},
{
database.TargetTypeSlack, "max_attempts=4",
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
database.Target{URL: editOriginalURL}, 4,
},
{
database.TargetTypeDatabase, "expiry=720h",
`{"expiry":"720h"}`, 1,
database.TargetTypeDatabase, "rotation=daily",
database.Target{
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: rotationDaily,
}, 1,
},
{database.TargetTypeLog, "", "", 1},
{database.TargetTypeLog, "", database.Target{}, 1},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(tc.targetType))
form.Set("url", editOriginalURL)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
_, got := addTargetThroughForm(
t, env, tc.targetType,
"url="+editOriginalURL+"&expiry=720h&"+tc.fields,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts)
if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config)
} else {
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
}
assert.Equal(t, tc.targetType, got.Type)
assert.Equal(t, tc.wantAttempts, got.MaxAttempts)
assert.Equal(t, tc.want, targetSettingsOf(got))
})
}
}
// addTargetThroughForm adds a target of targetType named every-type to
// a new webhook through the add target form, with the rest of the form
// given as a query string, and returns the webhook and the target the
// form stored.
func addTargetThroughForm(
t *testing.T,
env *sourceTestEnv,
targetType database.TargetType,
fields string,
) (database.Webhook, database.Target) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
form, err := url.ParseQuery(fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(targetType))
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]
}
// targetSettingsOf returns a target's settings, URL to
// ArchiveRotation, and nothing else, for comparing with the settings a
// test expects.
func targetSettingsOf(target database.Target) database.Target {
return database.Target{
URL: target.URL,
Headers: target.Headers,
TimeoutSeconds: target.TimeoutSeconds,
ForwardQuery: target.ForwardQuery,
ArchiveExpiry: target.ArchiveExpiry,
ArchiveRotation: target.ArchiveRotation,
}
}
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
// type and checks that the webhook page comes back with the add target
// form open on that type, the values entered, and the reason.
+27 -52
View File
@@ -25,22 +25,15 @@ const (
tmplKeyMaxTimeout = "MaxTimeout"
)
// configUnreadableMessage is shown when a target's stored
// configuration does not parse. It says plainly that saving replaces
// the stored value rather than preserving it, because the form
// cannot pre-fill what it could not read.
const configUnreadableMessage = "The stored configuration for this " +
"target could not be read. Enter the values below; saving " +
"replaces the stored configuration."
// targetEditView is the display model for the target edit page: the
// target's row fields as stored. The values the form shows, the
// UNMASKED configuration among them, come separately, as a
// UNMASKED URL and headers among them, come separately, as a
// targetFormInput.
//
// It deliberately omits database.Target's raw Config blob: the form
// renders named fields, and giving the template the blob as well
// would put an unreviewed second path to the credential on the page.
// It deliberately omits database.Target's settings: the form renders
// them from the targetFormInput, and giving the template the target's
// URL and headers as well would put an unreviewed second path to the
// credential on the page.
type targetEditView struct {
ID string
Name string
@@ -48,13 +41,16 @@ type targetEditView struct {
Active bool
}
// HandleTargetEdit shows the form to edit a target.
// HandleTargetEdit shows the form to edit a target, filled with the
// target's settings as stored.
//
// This page is the one place the full destination URL and header
// values are shown. It is reachable only through the
// /hook/{sourceID} route group, which supplies RequireAuth and
// NoCache, and only for a target of a webhook the session's user
// owns; masking (delivery.TargetView) is unchanged everywhere else.
// values are shown: an operator cannot correct a value they cannot
// see. It is reachable only through the /hook/{sourceID} route
// group, which supplies RequireAuth and NoCache, and only for a
// target of a webhook the session's user owns; masking
// (delivery.TargetView) is unchanged everywhere else. Do not fill a
// form like this one on any other page.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, target, ok := h.ownedTarget(w, r)
@@ -62,34 +58,19 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
return
}
cfg, err := delivery.NewTargetConfigForm(target)
msg := ""
if err != nil {
// The error carries the parse failure, never the
// blob, so it is safe to log against the target id.
h.log.Warn(
"stored target config could not be read for editing",
"target_id", target.ID,
"error", err,
)
msg = configUnreadableMessage
}
form := targetFormInput{
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery,
URL: target.URL,
Headers: delivery.FormatTargetHeaders(target.Headers),
Timeout: delivery.FormatTargetTimeout(target.TimeoutSeconds),
ForwardQuery: target.ForwardQuery,
MaxAttempts: strconv.Itoa(target.MaxAttempts),
Expiry: cfg.Expiry,
Rotation: cfg.Rotation,
Expiry: formatArchiveExpiry(target.ArchiveExpiry),
Rotation: target.ArchiveRotation,
}
h.renderTargetEdit(
w, r, webhook, target, form, msg, http.StatusOK,
w, r, webhook, target, form, "", http.StatusOK,
)
}
}
@@ -125,11 +106,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// does, so an edited destination is SSRF-validated exactly as a new
// one is.
//
// The target's type is not editable. Each type stores a different
// configuration shape and its delivery history is recorded against
// the target row, so changing the type of an existing target is
// really the creation of a different one. The stored type decides
// which fields the form offers and which builder runs.
// The target's type is not editable. Each type uses different
// settings and its delivery history is recorded against the target
// row, so changing the type of an existing target is really the
// creation of a different one. The stored type decides which fields
// the form offers and which settings are set.
func (h *Handlers) applyTargetEdit(
w http.ResponseWriter,
r *http.Request,
@@ -142,13 +123,7 @@ func (h *Handlers) applyTargetEdit(
// as stored, for the page shown again when the save is refused.
edited := *target
errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
errMsg := h.setTargetFromForm(r.Context(), &edited, in)
if errMsg != "" {
h.renderTargetEdit(
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
@@ -160,7 +135,7 @@ func (h *Handlers) applyTargetEdit(
// A new name renames the archive file before it is saved (see
// archive.Writers.Rename). If either step fails, it goes back to
// the name that is still stored.
err = h.renameTargetArchive(
err := h.renameTargetArchive(
target, webhook.Name, target.Name, edited.Name,
)
if err == nil {
+116 -64
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
@@ -150,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 {
@@ -197,12 +175,9 @@ func submitTargetEdit(
)
}
// 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()
@@ -213,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)
})
}
@@ -228,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
@@ -299,13 +272,8 @@ 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.MaxAttempts)
assert.Equal(
@@ -333,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,
)
}
@@ -362,10 +330,10 @@ 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)
}
// Delivery attempts are offered only by the forms for target types
@@ -442,6 +410,90 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
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
@@ -467,7 +519,7 @@ func TestHandleTarget_ForwardQuery(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
assert.True(t, storedTarget(t, env, targets[0].ID).ForwardQuery)
w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
@@ -479,7 +531,7 @@ func TestHandleTarget_ForwardQuery(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
assert.False(t, storedTarget(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on")
@@ -568,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",
)
}
@@ -597,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(
@@ -617,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",
)
}
@@ -637,7 +689,7 @@ 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",
)
}
@@ -757,7 +809,7 @@ func assertTargetOfAnotherWebhook404s(
assert.Equal(
t, editOriginalURL,
storedHTTPConfig(t, env, target.ID).URL,
storedTarget(t, env, target.ID).URL,
)
}
@@ -778,7 +830,7 @@ func assertWebhookOfAnotherUser404s(
target := seedConfiguredTarget(
t, env.db, other.ID, database.TargetTypeHTTP,
`{"url":"`+editOriginalURL+`"}`,
database.Target{URL: editOriginalURL},
)
w := serveTarget(
+1 -4
View File
@@ -243,10 +243,7 @@ func readTargetDeliveries(
func (h *Handlers) archiveFileView(
webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView {
current, err := archive.PathAt(h.dbMgr, webhook, target, now)
if err != nil {
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
}
current := archive.PathAt(h.dbMgr, webhook, target, now)
view := &ArchiveFileView{Name: filepath.Base(current)}
+1 -3
View File
@@ -60,7 +60,5 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxAttempts)
assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
)
assert.Equal(t, editReplacedURL, stored.URL)
}
+20 -15
View File
@@ -425,21 +425,26 @@ func buildDeliveryTasks(
}
tasks = append(tasks, delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
EntrypointID: event.EntrypointID,
TargetID: targets[i].ID,
TargetName: targets[i].Name,
TargetType: targets[i].Type,
TargetConfig: targets[i].Config,
MaxAttempts: targets[i].MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
AttemptNum: 1,
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
EntrypointID: event.EntrypointID,
TargetID: targets[i].ID,
TargetName: targets[i].Name,
TargetType: targets[i].Type,
TargetURL: targets[i].URL,
TargetHeaders: targets[i].Headers,
TimeoutSeconds: targets[i].TimeoutSeconds,
ForwardQuery: targets[i].ForwardQuery,
ArchiveExpiry: targets[i].ArchiveExpiry,
ArchiveRotation: targets[i].ArchiveRotation,
MaxAttempts: targets[i].MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
AttemptNum: 1,
})
}
+6 -12
View File
@@ -115,13 +115,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return
}
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
targets, errMsg := h.newWebhookTargets(r.Context(), in)
if errMsg != "" {
refuse(errMsg)
@@ -147,7 +141,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
func (h *Handlers) newWebhookTargets(
ctx context.Context,
in sourceFormInput,
) ([]*database.Target, string, error) {
) ([]*database.Target, string) {
var requested []targetFormInput
if in.HTTPURL != "" {
@@ -170,15 +164,15 @@ func (h *Handlers) newWebhookTargets(
targets := make([]*database.Target, 0, len(requested))
for _, form := range requested {
target, errMsg, err := h.newTarget(ctx, "", form)
if err != nil || errMsg != "" {
return nil, errMsg, err
target, errMsg := h.newTarget(ctx, "", form)
if errMsg != "" {
return nil, errMsg
}
targets = append(targets, target)
}
return targets, "", nil
return targets, ""
}
// createWebhookWithEntrypoint creates a webhook, its default
+1 -1
View File
@@ -109,7 +109,7 @@ func (h *Handlers) renderSourceDetail(
data := map[string]any{
tmplKeyWebhook: &webhook,
// Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it
// target's URL and headers hold a credential, and they
// must never reach a template.
"Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets),
+1 -1
View File
@@ -153,7 +153,7 @@ func seedStatsHistory(
first := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
database.Target{URL: replayTargetURL},
)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
+70
View File
@@ -10,6 +10,7 @@ import (
"net/netip"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
@@ -18,6 +19,7 @@ import (
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
@@ -206,3 +208,71 @@ func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
return found[0]
}
// targetWithEverySetting returns target settings with all six set, the
// archive ones included, so a task that drops one of them differs from
// the target.
func targetWithEverySetting() database.Target {
return database.Target{
URL: replayTargetURL,
Headers: map[string]string{"X-Api-Key": "k"},
TimeoutSeconds: 7,
ForwardQuery: true,
ArchiveExpiry: time.Hour,
ArchiveRotation: "daily",
}
}
// assertTaskCarriesTargetSettings proves a delivery task carries every
// one of the target's settings. They are copied into the task one field
// at a time, and a field left out is sent empty without any error.
func assertTaskCarriesTargetSettings(
t *testing.T, task delivery.Task, target *database.Target,
) {
t.Helper()
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.URL, task.TargetURL)
assert.Equal(t, target.Headers, task.TargetHeaders)
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
}
// TestHandleWebhook_TaskCarriesEveryTargetSetting proves the task a
// received event hands the delivery engine carries every one of the
// target's settings.
func TestHandleWebhook_TaskCarriesEveryTargetSetting(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
notif *recordingNotifier
)
app := newTestApp(t, &h, &db, &notif)
app.RequireStart()
t.Cleanup(app.RequireStop)
webhook := seedWebhook(t, db)
entrypoint := seedEntrypoint(t, db, webhook.ID)
target := seedConfiguredTarget(
t, db, webhook.ID, database.TargetTypeHTTP,
targetWithEverySetting(),
)
w := httptest.NewRecorder()
h.HandleWebhook().ServeHTTP(w, postRequest(
"/h/"+entrypoint.Path, nil,
map[string]string{"uuid": entrypoint.Path},
))
require.Equal(t, http.StatusOK, w.Code)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assertTaskCarriesTargetSettings(t, tasks[0], target)
}