New-webhook page: optional HTTP target URL and archive with pruning (closes #373)
check / check (push) Successful in 3m23s
check / check (push) Successful in 3m23s
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
This commit is contained in:
@@ -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
|
destinations) into a logical unit. A user creates a webhook to set up
|
||||||
event routing.
|
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 |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | ----------- |
|
| ---------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
|
|||||||
@@ -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</textarea>`)
|
||||||
|
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, `<option value="2160h" selected>`)
|
||||||
|
|
||||||
|
assertNothingCreated(t, env.db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errInjectedTargetCreate is the failure a test makes the insert of a
|
||||||
|
// target report.
|
||||||
|
var errInjectedTargetCreate = errors.New("injected target create failure")
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing makes
|
||||||
|
// inserting a target fail after the webhook and its entrypoint were
|
||||||
|
// inserted, and checks that neither is left behind.
|
||||||
|
func TestHandleSourceCreateSubmit_FailedTargetInsertCreatesNothing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Callback().Create().
|
||||||
|
Before("gorm:create").
|
||||||
|
Register("test:fail_target_create", func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == "targets" {
|
||||||
|
_ = tx.AddError(errInjectedTargetCreate)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "rolled back")
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", "never")
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
assertNothingCreated(t, env.db)
|
||||||
|
}
|
||||||
@@ -276,27 +276,41 @@ func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderTemplate(
|
h.renderTemplate(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData("", "", ""),
|
newSourceFormData("", sourceFormInput{
|
||||||
|
RetentionDays: strconv.Itoa(
|
||||||
|
database.DefaultRetentionDays,
|
||||||
|
),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceFormInput carries the raw values of the new webhook form. A
|
||||||
|
// refused submission is shown again from it, so every value entered
|
||||||
|
// comes back, retention included.
|
||||||
|
type sourceFormInput struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
RetentionDays string
|
||||||
|
// HTTPURL, when not empty, asks for an HTTP target with this
|
||||||
|
// destination.
|
||||||
|
HTTPURL string
|
||||||
|
// Archive asks for a database (archive) target, whose rows expire
|
||||||
|
// after ArchiveExpiry.
|
||||||
|
Archive bool
|
||||||
|
ArchiveExpiry string
|
||||||
|
}
|
||||||
|
|
||||||
// newSourceFormData builds the template data for the webhook creation
|
// newSourceFormData builds the template data for the webhook creation
|
||||||
// form.
|
// form. It carries the retention default, which the form's help text
|
||||||
//
|
// names, from database.DefaultRetentionDays rather than a hardcoded
|
||||||
// It carries the retention default so the pre-filled value comes from
|
// copy of the same policy.
|
||||||
// database.DefaultRetentionDays rather than being a third hardcoded
|
|
||||||
// copy of the same policy, and it carries the submitted name and
|
|
||||||
// description so that re-rendering the form after a validation failure
|
|
||||||
// gives the user their input back instead of a blank form. The edit
|
|
||||||
// form already behaves that way; create now matches it.
|
|
||||||
func newSourceFormData(
|
func newSourceFormData(
|
||||||
errMsg, name, description string,
|
errMsg string, in sourceFormInput,
|
||||||
) map[string]any {
|
) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
tmplKeyError: errMsg,
|
tmplKeyError: errMsg,
|
||||||
"Name": name,
|
"Form": in,
|
||||||
"Description": description,
|
|
||||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,57 +337,112 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := r.PostFormValue("name")
|
in := sourceFormInput{
|
||||||
description := r.PostFormValue("description")
|
Name: r.PostFormValue("name"),
|
||||||
retentionStr := r.PostFormValue("retention_days")
|
Description: r.PostFormValue("description"),
|
||||||
|
RetentionDays: r.PostFormValue("retention_days"),
|
||||||
|
HTTPURL: r.PostFormValue("http_url"),
|
||||||
|
Archive: r.PostFormValue("archive") != "",
|
||||||
|
ArchiveExpiry: r.PostFormValue("archive_expiry"),
|
||||||
|
}
|
||||||
|
|
||||||
if name == "" {
|
refuse := func(errMsg string) {
|
||||||
h.renderTemplateStatus(
|
h.renderTemplateStatus(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData(
|
newSourceFormData(errMsg, in),
|
||||||
"Name is required", name, description,
|
|
||||||
),
|
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Name == "" {
|
||||||
|
refuse("Name is required")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
retentionDays, errMsg := parseRetentionDays(
|
retentionDays, errMsg := parseRetentionDays(
|
||||||
retentionStr, database.DefaultRetentionDays,
|
in.RetentionDays, database.DefaultRetentionDays,
|
||||||
)
|
)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
h.renderTemplateStatus(
|
refuse(errMsg)
|
||||||
w, r, "sources_new.html",
|
|
||||||
newSourceFormData(errMsg, name, description),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.createWebhookWithEntrypoint(
|
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
|
||||||
w, r, userID, name, description, retentionDays,
|
if err != nil {
|
||||||
)
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
refuse(errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.createWebhookWithEntrypoint(w, r, &database.Webhook{
|
||||||
|
UserID: userID,
|
||||||
|
Name: in.Name,
|
||||||
|
Description: in.Description,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}, targets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWebhookWithEntrypoint creates a webhook and its default
|
// newWebhookTargets validates the targets the new webhook form asks
|
||||||
// entrypoint in a transaction.
|
// for and returns the rows to create with the webhook, or the message
|
||||||
|
// the form shows for the first one it refuses. A filled-in HTTP URL
|
||||||
|
// asks for an HTTP target named "HTTP", and the archive checkbox for a
|
||||||
|
// database target named "Archive". Each goes through newTarget, as on
|
||||||
|
// the webhook page's add target form. The rows have no WebhookID yet:
|
||||||
|
// the webhook has no ID until it is created.
|
||||||
|
func (h *Handlers) newWebhookTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
in sourceFormInput,
|
||||||
|
) ([]*database.Target, string, error) {
|
||||||
|
var requested []targetFormInput
|
||||||
|
|
||||||
|
if in.HTTPURL != "" {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "HTTP",
|
||||||
|
Type: database.TargetTypeHTTP,
|
||||||
|
URL: in.HTTPURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Archive {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "Archive",
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Expiry: in.ArchiveExpiry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhookWithEntrypoint creates a webhook, its default
|
||||||
|
// entrypoint and the given targets in a transaction.
|
||||||
func (h *Handlers) createWebhookWithEntrypoint(
|
func (h *Handlers) createWebhookWithEntrypoint(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
userID, name, description string,
|
webhook *database.Webhook,
|
||||||
retentionDays int,
|
targets []*database.Target,
|
||||||
) {
|
) {
|
||||||
webhook := &database.Webhook{
|
err := h.commitWebhook(webhook, targets)
|
||||||
UserID: userID,
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
RetentionDays: retentionDays,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create webhook", err)
|
h.serverError(w, r, "failed to create webhook", err)
|
||||||
|
|
||||||
@@ -390,7 +459,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
h.log.Info("webhook created",
|
h.log.Info("webhook created",
|
||||||
"webhook_id", webhook.ID,
|
"webhook_id", webhook.ID,
|
||||||
"name", name, "user_id", userID,
|
"name", webhook.Name, "user_id", webhook.UserID,
|
||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
@@ -399,10 +468,12 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhook creates a webhook and default entrypoint in
|
// commitWebhook creates a webhook, its default entrypoint and the
|
||||||
// a transaction. Returns an error on failure (rolls back).
|
// given targets in a transaction. Returns an error on failure (rolls
|
||||||
|
// back).
|
||||||
func (h *Handlers) commitWebhook(
|
func (h *Handlers) commitWebhook(
|
||||||
webhook *database.Webhook,
|
webhook *database.Webhook,
|
||||||
|
targets []*database.Target,
|
||||||
) error {
|
) error {
|
||||||
tx := h.db.DB().Begin()
|
tx := h.db.DB().Begin()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
@@ -430,6 +501,17 @@ func (h *Handlers) commitWebhook(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
target.WebhookID = webhook.ID
|
||||||
|
|
||||||
|
err = tx.Create(target).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,8 +196,6 @@ func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||||
"Name": "",
|
|
||||||
"Description": "",
|
|
||||||
"DefaultRetentionDays": database.DefaultRetentionDays,
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
dataKeyError: "",
|
dataKeyError: "",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkRecentEvents(ctx, t, page)
|
checkRecentEvents(ctx, t, page)
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
||||||
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
||||||
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
@@ -642,6 +645,199 @@ func checkEventLog(
|
|||||||
"clicking the event again does not collapse it")
|
"clicking the event again does not collapse it")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The parts of the new webhook page the checks below find and click.
|
||||||
|
const (
|
||||||
|
archiveBox = `//input[@name="archive"]`
|
||||||
|
archiveIsOn = `document.querySelector('input[name="archive"]').checked`
|
||||||
|
pruningChoice = `//select[@name="archive_expiry"]`
|
||||||
|
createButton = `//button[text()="Create Webhook"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkArchiveChoice loads the new webhook page and checks that the
|
||||||
|
// archive pruning choice stays hidden until the archive box is checked
|
||||||
|
// and hides again when it is unchecked; and that after checking it,
|
||||||
|
// opening the page at elsewhere and going back, the page again shows
|
||||||
|
// the box unchecked and the choice hidden.
|
||||||
|
func checkArchiveChoice(
|
||||||
|
ctx context.Context, t *testing.T, url, elsewhere string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"the pruning choice shows before archive is checked")
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
assert.True(t, shown(ctx, pruningChoice),
|
||||||
|
"checking archive does not show the pruning choice")
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"unchecking archive does not hide the pruning choice")
|
||||||
|
|
||||||
|
var (
|
||||||
|
loaded string
|
||||||
|
checked bool
|
||||||
|
)
|
||||||
|
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(elsewhere),
|
||||||
|
chromedp.NavigateBack(),
|
||||||
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(
|
||||||
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
||||||
|
),
|
||||||
|
chromedp.Evaluate(archiveIsOn, &checked),
|
||||||
|
))
|
||||||
|
require.Equal(
|
||||||
|
t, "back_forward", loaded,
|
||||||
|
"going back, the browser did not load the page again",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.False(t, checked, "going back leaves archive checked")
|
||||||
|
assert.True(t, hidden(ctx, pruningChoice),
|
||||||
|
"going back shows the pruning choice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNewWebhookTargets submits the new webhook page with the HTTP
|
||||||
|
// target URL filled in or empty, and with archive left off or checked
|
||||||
|
// with each pruning choice, and checks that each webhook is created
|
||||||
|
// with exactly the targets asked for.
|
||||||
|
func checkNewWebhookTargets(
|
||||||
|
ctx context.Context, t *testing.T, env *testEnv, url string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Each value the pruning choice submits, after an empty one that
|
||||||
|
// stands for archive left off.
|
||||||
|
expiries := []string{
|
||||||
|
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, httpURL := range []string{"", publicTargetURL} {
|
||||||
|
for _, expiry := range expiries {
|
||||||
|
name := "url=" + httpURL + " archive=" + expiry
|
||||||
|
want := map[database.TargetType]string{}
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(url),
|
||||||
|
chromedp.SetValue("#name", name, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
if httpURL != "" {
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
"#http_url", httpURL, chromedp.ByQuery,
|
||||||
|
)))
|
||||||
|
|
||||||
|
want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiry != "" {
|
||||||
|
// The choice showing moves Create down, so it is
|
||||||
|
// waited for before Create is clicked.
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.Truef(t, shown(ctx, pruningChoice),
|
||||||
|
"%s: checking archive does not show the pruning choice",
|
||||||
|
name)
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
pruningChoice, expiry, chromedp.BySearch,
|
||||||
|
)))
|
||||||
|
|
||||||
|
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
click(ctx, t, createButton)
|
||||||
|
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
|
||||||
|
"%s: the new webhook's page does not open", name)
|
||||||
|
|
||||||
|
assert.Equalf(t, want, targetConfigs(t, env, name),
|
||||||
|
"%s: the webhook does not have the targets asked for", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetConfigs reads the targets of the webhook named name, and
|
||||||
|
// returns each one's stored configuration by its type.
|
||||||
|
func targetConfigs(
|
||||||
|
t *testing.T, env *testEnv, name string,
|
||||||
|
) map[database.TargetType]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
webhook database.Webhook
|
||||||
|
targets []database.Target
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("name = ?", name).First(&webhook).Error)
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
|
||||||
|
|
||||||
|
configs := map[database.TargetType]string{}
|
||||||
|
for _, target := range targets {
|
||||||
|
configs[target.Type] = target.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefusedNewWebhook submits the new webhook page with archive
|
||||||
|
// checked and an HTTP target URL the server refuses, a loopback
|
||||||
|
// destination, and checks that the page comes back with the reason and
|
||||||
|
// every value entered, archive still checked and its pruning choice
|
||||||
|
// showing.
|
||||||
|
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const refusedURL = "http://127.0.0.1/hook"
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(url),
|
||||||
|
chromedp.SetValue("#name", "refused", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#description", "kept", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#retention_days", "7", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue("#http_url", refusedURL, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
click(ctx, t, archiveBox)
|
||||||
|
require.True(t, shown(ctx, pruningChoice),
|
||||||
|
"checking archive does not show the pruning choice")
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
pruningChoice, "2160h", chromedp.BySearch,
|
||||||
|
)))
|
||||||
|
click(ctx, t, createButton)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
||||||
|
"a refused webhook does not show the reason")
|
||||||
|
|
||||||
|
var (
|
||||||
|
name, description, retention, typed, expiry string
|
||||||
|
checked bool
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.Value("#name", &name, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#description", &description, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(archiveIsOn, &checked),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(t, "refused", name, "the name entered is lost")
|
||||||
|
assert.Equal(t, "kept", description, "the description entered is lost")
|
||||||
|
assert.Equal(t, "7", retention, "the retention entered is lost")
|
||||||
|
assert.Equal(t, refusedURL, typed, "the url entered is lost")
|
||||||
|
assert.True(t, checked, "archive is no longer checked")
|
||||||
|
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
||||||
|
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
||||||
|
}
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
// the menu button opens and closes the mobile menu.
|
// the menu button opens and closes the mobile menu.
|
||||||
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
||||||
|
|||||||
+3
-2
@@ -71,8 +71,9 @@ document.addEventListener("alpine:init", function () {
|
|||||||
|
|
||||||
// Something a click shows and hides: the mobile menu, an add form,
|
// Something a click shows and hides: the mobile menu, an add form,
|
||||||
// an entrypoint's edit form, an event in the event log or in the
|
// an entrypoint's edit form, an event in the event log or in the
|
||||||
// recent events, a delivery's attempts. It starts hidden, or shown
|
// recent events, a delivery's attempts, the new webhook page's
|
||||||
// when its element has the data-open attribute.
|
// archive pruning choice. It starts hidden, or shown when its
|
||||||
|
// element has the data-open attribute.
|
||||||
window.Alpine.data("collapsible", function () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
|||||||
@@ -18,20 +18,50 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required autofocus placeholder="My Webhook" class="input">
|
<input type="text" id="name" name="name" value="{{.Form.Name}}" required autofocus placeholder="My Webhook" class="input">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description" class="label">Description</label>
|
<label for="description" class="label">Description</label>
|
||||||
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Description}}</textarea>
|
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input">{{.Form.Description}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="retention_days" class="label">Retention (days)</label>
|
<label for="retention_days" class="label">Retention (days)</label>
|
||||||
<input type="number" id="retention_days" name="retention_days" value="{{.DefaultRetentionDays}}" min="0" class="input">
|
<input type="number" id="retention_days" name="retention_days" value="{{.Form.RetentionDays}}" min="0" class="input">
|
||||||
<p class="text-xs text-gray-500 mt-1">A periodic cleanup permanently deletes events older than this, along with their delivery records. Enter 0 to retain events forever; leave blank to use the default of {{.DefaultRetentionDays}} days.</p>
|
<p class="text-xs text-gray-500 mt-1">A periodic cleanup permanently deletes events older than this, along with their delivery records. Enter 0 to retain events forever; leave blank to use the default of {{.DefaultRetentionDays}} days.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="http_url" class="label">HTTP target URL</label>
|
||||||
|
<input type="url" id="http_url" name="http_url" value="{{.Form.HTTPURL}}" placeholder="https://example.com/webhook" class="input">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Optional. When filled in, the webhook is created with an HTTP target that delivers each event to this URL.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The checkbox shows the pruning choice while checked. With
|
||||||
|
autocomplete="off", going back to the page does not
|
||||||
|
check the box again with the choice hidden. -->
|
||||||
|
<div class="form-group" x-data="collapsible"{{if .Form.Archive}} data-open{{end}}>
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||||
|
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||||
|
Archive
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p>
|
||||||
|
<div x-show="open" x-cloak class="mt-3">
|
||||||
|
<label for="archive_expiry" class="label">Archive pruning</label>
|
||||||
|
<select id="archive_expiry" name="archive_expiry" class="input">
|
||||||
|
<option value="never"{{if eq .Form.ArchiveExpiry "never"}} selected{{end}}>never</option>
|
||||||
|
<option value="1h"{{if eq .Form.ArchiveExpiry "1h"}} selected{{end}}>1h</option>
|
||||||
|
<option value="12h"{{if eq .Form.ArchiveExpiry "12h"}} selected{{end}}>12h</option>
|
||||||
|
<option value="24h"{{if eq .Form.ArchiveExpiry "24h"}} selected{{end}}>24h</option>
|
||||||
|
<option value="720h"{{if eq .Form.ArchiveExpiry "720h"}} selected{{end}}>30d</option>
|
||||||
|
<option value="2160h"{{if eq .Form.ArchiveExpiry "2160h"}} selected{{end}}>90d</option>
|
||||||
|
<option value="8760h"{{if eq .Form.ArchiveExpiry "8760h"}} selected{{end}}>365d</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
<a href="/hooks" class="btn-secondary">Cancel</a>
|
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user