From 19a6705c63dd439d447efa5e634d42293f84b708 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 00:21:56 +0200 Subject: [PATCH] New-webhook page: optional HTTP target URL and archive with pruning (closes #373) The new-webhook page gains an optional HTTP target URL, which creates an http target named HTTP, and an archive checkbox whose pruning choice (never, 1h, 12h, 24h, 30d, 90d, 365d) creates a database target named Archive with that expiry. Both are validated by the add target form's own validation, and the webhook, its entrypoint and its targets are created in one transaction or not at all. A refused form comes back with the reason and every value entered, retention included. The targets can be renamed on the webhook page like any other. Model: opus-5-5 --- README.md | 7 + .../handlers/source_create_targets_test.go | 207 ++++++++++++++++++ internal/handlers/source_management.go | 168 ++++++++++---- internal/handlers/ui_copy_test.go | 2 - internal/server/alpine_browser_test.go | 196 +++++++++++++++++ static/js/app.js | 5 +- templates/sources_new.html | 36 ++- 7 files changed, 571 insertions(+), 50 deletions(-) create mode 100644 internal/handlers/source_create_targets_test.go diff --git a/README.md b/README.md index 8069395..ab6f0d4 100644 --- a/README.md +++ b/README.md @@ -1580,6 +1580,13 @@ more entrypoints (receiver URLs) and one or more targets (delivery destinations) into a logical unit. A user creates a webhook to set up event routing. +The new webhook form can also give the webhook its first targets: an +optional HTTP target URL creates an `http` target named `HTTP`, and the +archive checkbox creates a `database` target named `Archive` whose +`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d +or 365d). Both are validated as on the add target form, and the webhook +and its targets are created together or not at all. + | Field | Type | Description | | ---------------- | ------- | ----------- | | `id` | UUID | Primary key | diff --git a/internal/handlers/source_create_targets_test.go b/internal/handlers/source_create_targets_test.go new file mode 100644 index 0000000..7031cc8 --- /dev/null +++ b/internal/handlers/source_create_targets_test.go @@ -0,0 +1,207 @@ +package handlers_test + +import ( + "errors" + "html" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// submitCreateForm posts the new webhook form and returns the +// recorder. +func submitCreateForm( + env *sourceTestEnv, form url.Values, +) *httptest.ResponseRecorder { + req := formRequest("/hooks/new", env.cookies, form, nil) + w := httptest.NewRecorder() + + env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req) + + return w +} + +// assertNothingCreated checks that the main database holds no webhook, +// entrypoint or target. +func assertNothingCreated(t *testing.T, db *database.Database) { + t.Helper() + + for _, model := range []any{ + &database.Webhook{}, &database.Entrypoint{}, &database.Target{}, + } { + var count int64 + + require.NoError(t, db.DB().Model(model).Count(&count).Error) + assert.Zerof(t, count, "%T rows were created", model) + } +} + +// TestHandleSourceCreateSubmit_CreatesRequestedTargets submits the new +// webhook form with the HTTP target URL filled in or empty, and with +// the archive checkbox off or on with each pruning choice. The webhook +// gets an HTTP target only for a URL and a database target only for a +// checked archive. The pruning choice is always submitted, as the +// browser submits it while it is hidden, and is ignored when archive +// is off. +func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) { + t.Parallel() + + 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", + } + + for _, httpURL := range []string{"", editOriginalURL} { + for _, expiry := range expiries { + name := "url=" + httpURL + " archive=" + expiry + + t.Run(name, func(t *testing.T) { + t.Parallel() + + form := url.Values{} + form.Set("name", name) + form.Set("http_url", httpURL) + form.Set("archive_expiry", "720h") + + if expiry != "" { + form.Set("archive", "on") + form.Set("archive_expiry", expiry) + } + + w := submitCreateForm(env, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + var webhook database.Webhook + + require.NoError(t, env.db.DB(). + Where("name = ?", name).First(&webhook).Error) + + byType := map[database.TargetType]database.Target{} + for _, target := range targetsForWebhook(t, env.db, webhook.ID) { + byType[target.Type] = target + } + + wantCount := 0 + + if httpURL != "" { + wantCount++ + + assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name) + assert.JSONEq(t, `{"url":"`+httpURL+`"}`, + byType[database.TargetTypeHTTP].Config) + } + + if expiry != "" { + wantCount++ + + assert.Equal(t, "Archive", + byType[database.TargetTypeDatabase].Name) + assert.JSONEq(t, `{"expiry":"`+expiry+`"}`, + byType[database.TargetTypeDatabase].Config) + } + + assert.Len(t, byType, wantCount) + }) + } + } +} + +// TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue refuses the +// new webhook form for an invalid HTTP target URL and for an invalid +// retention, each with archive on. Nothing is created, and the form +// comes back with the reason and every value entered: name, +// description, retention, URL, the checked archive box and the pruning +// choice. +func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue( + t *testing.T, +) { + t.Parallel() + + const badURL = "Invalid target URL" + + cases := []struct { + name string + retention string + httpURL string + reason string + }{ + {"blocked url", "7", editBlockedURL, badURL}, + {"unsupported scheme", "7", "ftp://93.184.216.34/hook", badURL}, + {"bad retention", "-5", editOriginalURL, "Retention must be"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + form := url.Values{} + form.Set("name", "kept name") + form.Set("description", "kept description") + form.Set("retention_days", tc.retention) + form.Set("http_url", tc.httpURL) + form.Set("archive", "on") + form.Set("archive_expiry", "2160h") + + w := submitCreateForm(env, form) + require.Equal(t, http.StatusBadRequest, w.Code) + + page := w.Body.String() + assert.Contains(t, page, tc.reason) + assert.Contains(t, page, `value="kept name"`) + assert.Contains(t, page, `>kept description`) + assert.Contains(t, page, `value="`+tc.retention+`"`) + assert.Contains(t, page, + `value="`+html.EscapeString(tc.httpURL)+`"`) + assert.Contains(t, page, `name="archive" value="on" checked`) + assert.Contains(t, page, `x-data="collapsible" data-open`) + assert.Contains(t, page, `