From 46399d7d7bbb4b677229edd1c6003b57cdd5ffe6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 20:41:09 +0000 Subject: [PATCH] New-webhook page: optional HTTP target URL and archive with pruning (closes #373) The new-webhook page keeps name, description and retention and adds an HTTP target URL field and an archive checkbox. A filled-in URL creates an http target named HTTP; a checked archive shows a pruning choice (never, 1h, 12h, 24h, 30d, 90d, 365d) and creates a database target named Archive with that expiry. Each target goes through the add target form's validation, and the webhook, its entrypoint and its targets are created in one transaction. A refused form comes back with every value entered, retention included. 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 66359f2..f8b384a 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, `