Keep what was typed when a target or webhook edit is refused (closes #381) #474

Merged
clawbot merged 1 commits from issue-381-edit-pages-keep-input into next 2026-10-03 00:51:56 +02:00
11 changed files with 467 additions and 269 deletions
+19 -17
View File
@@ -1350,23 +1350,25 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never `static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`. `x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page and the event log A browser test in `internal/server` loads the webhook page, its edit pages and
under the real policy and checks that: the add entrypoint form stays hidden the event log under the real policy and checks that: the add entrypoint form
until Add is clicked; for every target type, the targets section's Add shows stays hidden until Add is clicked; for every target type, the targets section's
only a choice of type with Next and Cancel, Next shows only that type's fields Add shows only a choice of type with Next and Cancel, Next shows only that
(no url field for `database` or `log`), Cancel at either step closes the form, type's fields (no url field for `database` or `log`), Cancel at either step
and saving adds the target; a refused target comes back with its form open, the closes the form, and saving adds the target; a refused target comes back with
values entered and the reason, and after Cancel the next Add starts with an its form open, the values entered and the reason, and after Cancel the next Add
empty form and no reason; the Copy button beside an entrypoint URL reads starts with an empty form and no reason; a refused save on the target edit page
"Copied" once clicked; an entrypoint's Edit button shows its edit form in place and on the webhook edit page comes back with the reason and every value
of its description and hides until the form closes, Cancel hides the form and entered; the Copy button beside an entrypoint URL reads "Copied" once clicked;
drops what was typed, as does leaving the page and going back to it, and Save an entrypoint's Edit button shows its edit form in place of its description and
changes the description; of the recent events on the webhook page only the hides until the form closes, Cancel hides the form and drops what was typed, as
newest starts expanded, each expands and collapses, and Open leads to the does leaving the page and going back to it, and Save changes the description;
event's own page; an event in the event log expands and collapses, and so do a of the recent events on the webhook page only the newest starts expanded, each
delivery's attempts inside it; and at phone width the menu button opens and expands and collapses, and Open leads to the event's own page; an event in the
closes the mobile menu. It also fails if the browser reports a console warning event log expands and collapses, and so do a delivery's attempts inside it; and
or error, an uncaught exception, or anything the policy refused. `make check` at phone width the menu button opens and closes the mobile menu. It also fails
if the browser reports a console warning or error, an uncaught exception, or
anything the policy refused. `make check`
and the image build lint it but do not run it, and `make test` leaves it out and the image build lint it but do not run it, and `make test` leaves it out
(its file is built only with the `browser` build tag). Run it with (its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds `make test-browser` after changing `templates/` or `static/js/`: that builds
+104 -74
View File
@@ -621,7 +621,7 @@ func (h *Handlers) renderSourceDetail(
"Events": events, "Events": events,
"BaseURL": baseURL, "BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets), "Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
"TargetForm": targetForm, tmplKeyTargetForm: targetForm,
"TargetError": targetErr, "TargetError": targetErr,
} }
@@ -658,12 +658,12 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return return
} }
data := map[string]any{ h.renderWebhookEdit(
tmplKeyWebhook: &webhook, w, r, &webhook,
tmplKeyError: "", webhook.Name, webhook.Description,
} strconv.Itoa(webhook.RetentionDays),
"", http.StatusOK,
h.renderTemplate(w, r, "source_edit.html", data) )
} }
} }
@@ -709,7 +709,8 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
} }
} }
// applyWebhookEdit validates and saves webhook edits. // applyWebhookEdit validates and saves webhook edits. A refused save
// shows the edit form again with the values submitted and the reason.
func (h *Handlers) applyWebhookEdit( func (h *Handlers) applyWebhookEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -718,52 +719,52 @@ func (h *Handlers) applyWebhookEdit(
// The body size cap is enforced by the MaxBodySize middleware, // The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form. // which runs before CSRF parses the form.
name := r.PostFormValue("name") name := r.PostFormValue("name")
if name == "" { description := r.PostFormValue("description")
data := map[string]any{ retention := r.PostFormValue("retention_days")
tmplKeyWebhook: webhook,
tmplKeyError: "Name is required",
}
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest) if name == "" {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
"Name is required", http.StatusBadRequest,
)
return return
} }
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
// An empty field falls back to the stored value, so submitting the // An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone. // form without touching retention leaves the policy alone.
retentionDays, errMsg := parseRetentionDays( retentionDays, errMsg := parseRetentionDays(
r.PostFormValue("retention_days"), webhook.RetentionDays, retention, webhook.RetentionDays,
) )
if errMsg != "" { if errMsg != "" {
data := map[string]any{ h.renderWebhookEdit(
tmplKeyWebhook: webhook, w, r, webhook, name, description, retention,
tmplKeyError: errMsg, errMsg, http.StatusBadRequest,
} )
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
return return
} }
webhook.RetentionDays = retentionDays // edited is the webhook as the submission leaves it; webhook stays
// as stored, for the page shown again when the save is refused.
edited := *webhook
edited.Name = name
edited.Description = description
edited.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see // A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, the same targets' // delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without // archives go back to the name that is still stored, without
// reading the main database again. // reading the main database again.
targets, err := h.renameWebhookArchives( targets, err := h.renameWebhookArchives(
webhook.ID, oldName, webhook.Name, webhook.ID, webhook.Name, edited.Name,
) )
if err == nil { if err == nil {
err = h.db.DB().Save(webhook).Error err = h.db.DB().Save(&edited).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameArchives(targets, oldName) restoreErr := h.renameArchives(targets, webhook.Name)
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
"failed to rename archives back", "failed to rename archives back",
@@ -773,15 +774,14 @@ func (h *Handlers) applyWebhookEdit(
} }
if errors.Is(err, delivery.ErrArchiveNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
data := map[string]any{ h.renderWebhookEdit(
tmplKeyWebhook: webhook, w, r, webhook, name, description, retention,
tmplKeyError: "Not saved: " + err.Error() + "Not saved: "+err.Error()+
". Move that archive out of the data directory, " + ". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside " + "its .db together with any -wal and -shm beside "+
"it, then save again.", "it, then save again.",
} http.StatusConflict,
)
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
return return
} }
@@ -797,6 +797,27 @@ func (h *Handlers) applyWebhookEdit(
) )
} }
// renderWebhookEdit renders the webhook edit page for the webhook as
// stored, its form showing name, description and retentionDays, with
// an optional error message above it.
func (h *Handlers) renderWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
name, description, retentionDays, errMsg string,
status int,
) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: errMsg,
"Name": name,
"Description": description,
"RetentionDays": retentionDays,
}
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
}
// HandleSourceDelete handles webhook deletion. // HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc { func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -1668,49 +1689,57 @@ func (h *Handlers) newTarget(
webhookID string, webhookID string,
in targetFormInput, in targetFormInput,
) (*database.Target, string, error) { ) (*database.Target, string, error) {
if in.Name == "" { target := &database.Target{
return nil, "Name is required", nil WebhookID: webhookID,
Type: in.Type,
Active: true,
} }
if !isValidTargetType(in.Type) { errMsg, err := h.setTargetFromForm(ctx, target, in)
return nil, "Invalid target type", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
if err != nil || errMsg != "" { if err != nil || errMsg != "" {
return nil, errMsg, err return nil, errMsg, err
} }
// A new target has no stored retry count, so an absent field return target, "", nil
// takes the fire-and-forget default. A field the operator filled
// in with something invalid is refused rather than becoming
// that default.
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
if err != nil {
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
}
return &database.Target{
WebhookID: webhookID,
Name: in.Name,
Type: in.Type,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}, "", nil
} }
// isValidTargetType checks whether the target type is supported. // setTargetFromForm validates a target form against the target's type
func isValidTargetType(tt database.TargetType) bool { // and, when it accepts it, sets the target's name, configuration and
switch tt { // retry count from it. It returns the message the form shows for
case database.TargetTypeHTTP, // anything it refuses, an unknown type among them, and then leaves the
database.TargetTypeDatabase, // target unchanged; an error is the server's fault, as for newTarget.
database.TargetTypeLog, // The add target form and the target edit form both go through here,
database.TargetTypeSlack: // so the two cannot come to disagree about what a target may be.
return true func (h *Handlers) setTargetFromForm(
default: ctx context.Context,
return false target *database.Target,
in targetFormInput,
) (string, error) {
if in.Name == "" {
return "Name is required", nil
} }
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if err != nil || errMsg != "" {
return errMsg, err
}
// An empty max_retries keeps the target's count: the
// fire-and-forget default of 0 for a new target, and the stored
// count for an edited one, since the forms for target types that
// do not retry have no such field. A value that is filled in but
// invalid is refused rather than becoming that count, so a typo
// cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil {
return "Invalid max retries: " + retriesErrorMessage(err), nil
}
target.Name = in.Name
target.Config = configJSON
target.MaxRetries = maxRetries
return "", nil
} }
// pageOrFirst parses a paginated page number, answering 1 for // pageOrFirst parses a paginated page number, answering 1 for
@@ -1732,9 +1761,10 @@ func pageOrFirst(s string) int {
} }
// targetFormInput carries the raw values of a target form. Both the // targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to buildTargetConfig, // create and the edit path fill one and hand it to setTargetFromForm,
// so neither can come to validate a destination differently from the // so neither can come to validate a target differently from the
// other. A refused add target form is shown again from it. // other. Both forms are filled from one: the edit form with the
// stored values, and a refused form with the values submitted.
type targetFormInput struct { type targetFormInput struct {
// Name is the target's name. // Name is the target's name.
Name string Name string
@@ -509,6 +509,51 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
) )
} }
// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for
// each reason the form can give and checks that the form comes back
// with the reason and the name, description and retention submitted,
// that the page still reports the stored retention, and that nothing
// is saved.
func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
refused := func(name, retention, reason string) {
t.Helper()
wh := seedWebhookWithRetention(t, env.db, 30)
submitted := wh
submitted.Name = name
submitted.Description = "a description worth keeping"
w := submitEdit(t, env, submitted, retention)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+reason)
assert.Contains(t, page, `name="name" value="`+name+`"`)
assert.Contains(t, page, ">a description worth keeping</textarea>")
assert.Contains(
t, page, `name="retention_days" value="`+retention+`"`,
)
assert.Contains(t, page, "Currently 30 days.")
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, wh.Name, stored.Name)
assert.Empty(t, stored.Description)
assert.Equal(t, 30, stored.RetentionDays)
}
refused("", "45", "Name is required")
refused("kept-name", "nonsense", "Retention must be")
}
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged( func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
t *testing.T, t *testing.T,
) { ) {
@@ -809,6 +854,10 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
w := submitEdit(t, env, wh, "") w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`,
"the form comes back with the name submitted",
)
var stored database.Webhook var stored database.Webhook
+52 -58
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -13,10 +14,13 @@ import (
const targetEditTemplate = "target_edit.html" const targetEditTemplate = "target_edit.html"
// tmplKeyTarget is the template data key for the target being // tmplKeyTarget is the template data key for the target being
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form // edited, tmplKeyTargetForm for the values its form shows, and
// tells the user about. // tmplKeyMaxTimeout for the timeout ceiling the form tells the user
// about. The add target form on the webhook page takes its values
// under the same key as the edit form.
const ( const (
tmplKeyTarget = "Target" tmplKeyTarget = "Target"
tmplKeyTargetForm = "TargetForm"
tmplKeyMaxTimeout = "MaxTimeout" tmplKeyMaxTimeout = "MaxTimeout"
) )
@@ -28,20 +32,19 @@ const configUnreadableMessage = "The stored configuration for this " +
"target could not be read. Enter the values below; saving " + "target could not be read. Enter the values below; saving " +
"replaces the stored configuration." "replaces the stored configuration."
// targetEditView is the display model for the target edit page. // 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
// targetFormInput.
// //
// It carries the target's row fields alongside its UNMASKED // It deliberately omits database.Target's raw Config blob: the form
// configuration, and deliberately omits database.Target's raw // renders named fields, and giving the template the blob as well
// Config blob: the form renders named fields, and giving the // would put an unreviewed second path to the credential on the page.
// template the blob as well would put an unreviewed second path to
// the credential on the page.
type targetEditView struct { type targetEditView struct {
ID string ID string
Name string Name string
Type database.TargetType Type database.TargetType
Active bool Active bool
MaxRetries int
Config delivery.TargetConfigForm
} }
// HandleTargetEdit shows the form to edit a target. // HandleTargetEdit shows the form to edit a target.
@@ -73,7 +76,18 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
msg = configUnreadableMessage msg = configUnreadableMessage
} }
h.renderTargetEdit(w, r, webhook, target, cfg, msg) form := targetFormInput{
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
}
h.renderTargetEdit(
w, r, webhook, target, form, msg, http.StatusOK,
)
} }
} }
@@ -101,11 +115,12 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
} }
} }
// applyTargetEdit validates and saves target edits. // applyTargetEdit validates and saves target edits. A refused save
// shows the edit form again with the values submitted and the reason.
// //
// The submitted configuration goes through buildTargetConfig, the // The submission goes through setTargetFromForm, as a new target
// same builder the create path uses, so an edited destination is // does, so an edited destination is SSRF-validated exactly as a new
// SSRF-validated exactly as a new one is. // one is.
// //
// The target's type is not editable. Each type stores a different // The target's type is not editable. Each type stores a different
// configuration shape and its delivery history is recorded against // configuration shape and its delivery history is recorded against
@@ -118,16 +133,13 @@ func (h *Handlers) applyTargetEdit(
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
) { ) {
name := r.PostFormValue("name") in := targetFormInputFrom(r)
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return // edited is the target as the submission leaves it; target stays
} // as stored, for the page shown again when the save is refused.
edited := *target
configJSON, errMsg, err := h.buildTargetConfig( errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
r.Context(), target.Type, targetFormInputFrom(r),
)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err) h.serverError(w, r, "failed to encode target config", err)
@@ -135,45 +147,26 @@ func (h *Handlers) applyTargetEdit(
} }
if errMsg != "" { if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest) h.renderTargetEdit(
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
)
return return
} }
// Retries are offered only by the forms for target types that
// retry, so an absent field means "this form does not edit
// retries" rather than "set them to zero". Reading it
// unconditionally would silently disable retries on any target
// saved from a form that does not render the input.
//
// A field that IS submitted but does not parse is a 400, through
// the same validator the create path uses. It is rejected before
// anything is written, so a typo cannot destroy the retry count
// the target is already delivering with.
if r.PostForm.Has("max_retries") {
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
if !ok {
return
}
target.MaxRetries = retries
}
oldName := target.Name
target.Name = name
target.Config = configJSON
// A new name renames the archive file before it is saved (see // A new name renames the archive file before it is saved (see
// delivery.Engine.Rename). If either step fails, it goes back to // delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored. // the name that is still stored.
err = h.renameTargetArchive(target, webhook.Name, oldName, name) err = h.renameTargetArchive(
target, webhook.Name, target.Name, edited.Name,
)
if err == nil { if err == nil {
err = h.db.DB().Save(target).Error err = h.db.DB().Save(&edited).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameTargetArchive( restoreErr := h.renameTargetArchive(
target, webhook.Name, name, oldName, target, webhook.Name, edited.Name, target.Name,
) )
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
@@ -184,8 +177,8 @@ func (h *Handlers) applyTargetEdit(
} }
if errors.Is(err, delivery.ErrArchiveNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
http.Error( h.renderTargetEdit(
w, w, r, webhook, target, in,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
". Move that archive out of the data directory, "+ ". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+ "its .db together with any -wal and -shm beside "+
@@ -222,15 +215,17 @@ func (h *Handlers) renameTargetArchive(
return h.archives.Rename(target.ID, webhookName, newName) return h.archives.Rename(target.ID, webhookName, newName)
} }
// renderTargetEdit renders the target edit page with an optional // renderTargetEdit renders the target edit page for the target as
// error message. // stored, its form showing form's values, with an optional error
// message above it.
func (h *Handlers) renderTargetEdit( func (h *Handlers) renderTargetEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
cfg delivery.TargetConfigForm, form targetFormInput,
errMsg string, errMsg string,
status int,
) { ) {
// The template calls Webhook methods, which take pointer // The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a // receivers; html/template cannot address a value stored in a
@@ -242,14 +237,13 @@ func (h *Handlers) renderTargetEdit(
Name: target.Name, Name: target.Name,
Type: target.Type, Type: target.Type,
Active: target.Active, Active: target.Active,
MaxRetries: target.MaxRetries,
Config: cfg,
}, },
tmplKeyTargetForm: form,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds, tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg, tmplKeyError: errMsg,
} }
h.renderTemplate(w, r, targetEditTemplate, data) h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
} }
// ownedTarget resolves the request's sourceID and targetID // ownedTarget resolves the request's sourceID and targetID
+71
View File
@@ -565,6 +565,73 @@ func assertEditRejectsTimeout(
) )
} }
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
// a target of each type and checks that the edit form comes back with
// the reason and every value submitted, and that nothing is saved.
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator submitted, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
"Invalid target URL",
},
{
database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid max retries",
},
{
database.TargetTypeDatabase, "name=edited&expiry=7d",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
w := submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea; every other field is
// an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
if field == "headers" {
shown = ">" + form.Get(field) + "</textarea>"
}
assert.Contains(t, page, shown)
}
assert.Equal(
t, target.Name, storedTarget(t, env, target.ID).Name,
"a refused edit must save nothing",
)
})
}
}
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way // TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
// the delete and toggle routes are: ownership is decided by the // the delete and toggle routes are: ownership is decided by the
// webhook, and the target is then scoped to it. // webhook, and the target is then scoped to it.
@@ -700,6 +767,10 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
w = submitTargetEdit(env, wh.ID, archive.ID, again) w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="Again"`,
"the form comes back with the name submitted",
)
assert.Equal( assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name, t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
) )
@@ -44,9 +44,9 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
form.Set("type", string(targetType)) form.Set("type", string(targetType))
form.Set("url", editBlockedURL) form.Set("url", editBlockedURL)
// A refused add shows the webhook page again, where // A refused add shows the webhook page again, and a
// the hint is HTML-escaped; a refused edit answers in // refused edit the edit page, where the hint is
// plain text. // HTML-escaped.
added := serveTarget( added := serveTarget(
env, http.MethodPost, targetsPath, form, env, http.MethodPost, targetsPath, form,
) )
@@ -76,7 +76,8 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
) )
assert.Equal(t, http.StatusBadRequest, edited.Code) assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains( assert.Contains(
t, edited.Body.String(), privateRefusalHint, t, edited.Body.String(),
html.EscapeString(privateRefusalHint),
) )
}) })
} }
-30
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"errors" "errors"
"net/http"
"strconv" "strconv"
"strings" "strings"
) )
@@ -89,32 +88,3 @@ func retriesErrorMessage(err error) string {
return errRetriesInvalid.Error() + return errRetriesInvalid.Error() +
", or 0 for fire-and-forget" ", or 0 for fire-and-forget"
} }
// targetMaxRetries reads and validates max_retries from a target edit
// submission, answering the request with a 400 and reporting false
// when the value is set but invalid.
//
// It and the create path (newTarget) both use parseMaxRetries and
// retriesErrorMessage, so the two cannot come to disagree about what a
// valid retry count is. The wording matches the timeout control on
// the same submission.
func targetMaxRetries(
w http.ResponseWriter,
r *http.Request,
fallback int,
) (int, bool) {
retries, err := parseMaxRetries(
r.PostFormValue("max_retries"), fallback,
)
if err != nil {
http.Error(
w,
"Invalid max retries: "+retriesErrorMessage(err),
http.StatusBadRequest,
)
return 0, false
}
return retries, true
}
+6 -6
View File
@@ -396,9 +396,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
) )
// A slack target exercises the same max_retries field while needing // A slack target exercises the same max_retries field while needing
// only Config.URL from the edit template, so the test data stays // only a URL from the edit template, so the test data stays
// minimal. The Target key mirrors the field names the template reads // minimal. The Target and TargetForm keys mirror the field names
// off the handler's view value. // the template reads off the handler's values.
editBody := renderPage( editBody := renderPage(
t, h, sess, "target_edit.html", map[string]any{ t, h, sess, "target_edit.html", map[string]any{
dataKeyWebhook: webhook, dataKeyWebhook: webhook,
@@ -407,10 +407,10 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
"Name": "t", "Name": "t",
"Type": "slack", "Type": "slack",
"Active": true, "Active": true,
"MaxRetries": 3,
"Config": map[string]any{
"URL": "https://hooks.slack.com/services/x",
}, },
"TargetForm": map[string]any{
"URL": "https://hooks.slack.com/services/x",
"MaxRetries": "3",
}, },
dataKeyError: "", dataKeyError: "",
}, },
+131 -50
View File
@@ -59,6 +59,44 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook, event, target := seedBrowserWebhook(t, env, userID)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
// The checks share one browser tab, so they run one at a time, in
// this order. A new check is one more line here.
checkAddEntrypoint(ctx, t, page)
checkAddEachTargetType(ctx, t, page)
checkRefusedTarget(ctx, t, page)
checkTargetDeliveries(ctx, t, page, target.Name,
"0 in total, 0 in the last 24 hours",
"1 in total, 1 in the last 24 hours")
checkRefusedEdits(ctx, t, page, target.ID)
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
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)
assert.Empty(t, problems(), "the browser reported problems")
}
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. It returns the webhook, the newer
// event and the target.
func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Target) {
t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
@@ -82,56 +120,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
}, },
).Error) ).Error)
require.NoError(t, chromedp.Run( return webhook, event, target
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddEntrypoint(ctx, t, page)
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
)
}
checkRefusedTarget(ctx, t, page)
checkTargetDeliveries(ctx, t, page, target.Name,
"0 in total, 0 in the last 24 hours",
"1 in total, 1 in the last 24 hours")
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
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)
assert.Empty(t, problems(), "the browser reported problems")
} }
// startBrowser starts a headless browser for one test. It returns the // startBrowser starts a headless browser for one test. It returns the
@@ -310,6 +299,40 @@ const (
document.querySelector('form[action$="/targets"]')).keys()]` document.querySelector('form[action$="/targets"]')).keys()]`
) )
// checkAddEachTargetType runs checkAddTarget on a webhook page for each
// target type, in page order.
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper()
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values,
)
}
}
// checkAddTarget loads a webhook page and walks the add target form for // checkAddTarget loads a webhook page and walks the add target form for
// one target type. The form shows nothing until Add is clicked; Add // one target type. The form shows nothing until Add is clicked; Add
// shows only the type choice; Cancel there closes it; Next shows the // shows only the type choice; Cancel there closes it; Next shows the
@@ -481,6 +504,64 @@ func checkTargetDeliveries(
"the row of %s does not show %q failed", name, failed) "the row of %s does not show %q failed", name, failed)
} }
// checkRefusedEdits fills in the target edit page and the webhook edit
// page of a webhook page with values the server refuses, a loopback
// destination and a retention above the longest finite one, which the
// browser lets through. It saves each and checks that the page comes
// back with the reason and every value still in its field. The values
// are keyed by the id of their field.
func checkRefusedEdits(
ctx context.Context, t *testing.T, page, targetID string,
) {
t.Helper()
const reason = `//div[@class="alert-error"]`
edits := []struct {
url string
values map[string]string
}{
{page + "/targets/" + targetID + "/edit", map[string]string{
"#name": "edited-target",
"#url": "http://127.0.0.1/hook",
"#headers": "X-Edited: kept",
"#timeout": "12",
"#max_retries": "3",
}},
{page + "/edit", map[string]string{
"#name": "edited-webhook",
"#description": "kept description",
"#retention_days": "200000",
}},
}
for _, edit := range edits {
require.NoError(t, chromedp.Run(ctx, loadPage(edit.url)))
for field, value := range edit.values {
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(field, value, chromedp.ByQuery),
))
}
click(ctx, t, `//button[text()="Save Changes"]`)
assert.Truef(t, shown(ctx, reason),
"%s: a refused save does not show the reason", edit.url)
for field, value := range edit.values {
var kept string
require.NoError(t, chromedp.Run(
ctx, chromedp.Value(field, &kept, chromedp.ByQuery),
))
assert.Equalf(t, value, kept,
"%s: a refused save does not keep the %s entered",
edit.url, field)
}
}
}
// checkCopy loads a webhook page and checks that the Copy control beside // checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL // its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded. // and says so: the button reads "Copied" only once the copy succeeded.
+3 -3
View File
@@ -18,17 +18,17 @@
<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="{{.Webhook.Name}}" required class="input"> <input type="text" id="name" name="name" value="{{.Name}}" required 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" class="input">{{.Webhook.Description}}</textarea> <textarea id="description" name="description" rows="3" class="input">{{.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="{{.Webhook.RetentionDays}}" min="0" class="input"> <input type="number" id="retention_days" name="retention_days" value="{{.RetentionDays}}" min="0" class="input">
<p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p> <p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p>
</div> </div>
+8 -8
View File
@@ -17,7 +17,7 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700"> <div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700">
This form shows the target's stored destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked. This form shows the target's destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
</div> </div>
{{end}} {{end}}
@@ -26,25 +26,25 @@
<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="{{.Target.Name}}" required class="input"> <input type="text" id="name" name="name" value="{{.TargetForm.Name}}" required class="input">
</div> </div>
{{if eq .Target.Type "http"}} {{if eq .Target.Type "http"}}
<div class="form-group"> <div class="form-group">
<label for="url" class="label">Destination URL</label> <label for="url" class="label">Destination URL</label>
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input"> <input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
<p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p> <p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="headers" class="label">Headers</label> <label for="headers" class="label">Headers</label>
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.Target.Config.Headers}}</textarea> <textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.TargetForm.Headers}}</textarea>
<p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p> <p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="timeout" class="label">Timeout (seconds)</label> <label for="timeout" class="label">Timeout (seconds)</label>
<input type="number" id="timeout" name="timeout" value="{{.Target.Config.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input"> <input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p> <p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
</div> </div>
{{end}} {{end}}
@@ -52,7 +52,7 @@
{{if eq .Target.Type "slack"}} {{if eq .Target.Type "slack"}}
<div class="form-group"> <div class="form-group">
<label for="url" class="label">Webhook URL</label> <label for="url" class="label">Webhook URL</label>
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input"> <input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p> <p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p>
</div> </div>
{{end}} {{end}}
@@ -60,7 +60,7 @@
{{if eq .Target.Type "database"}} {{if eq .Target.Type "database"}}
<div class="form-group"> <div class="form-group">
<label for="expiry" class="label">Archive Expiry</label> <label for="expiry" class="label">Archive Expiry</label>
<input type="text" id="expiry" name="expiry" value="{{.Target.Config.Expiry}}" placeholder="never" class="input"> <input type="text" id="expiry" name="expiry" value="{{.TargetForm.Expiry}}" placeholder="never" class="input">
<p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p> <p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p>
</div> </div>
{{end}} {{end}}
@@ -68,7 +68,7 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_retries" class="label">Max retries</label> <label for="max_retries" class="label">Max retries</label>
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input"> <input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
{{end}} {{end}}