diff --git a/README.md b/README.md index b250fde..ccde5a2 100644 --- a/README.md +++ b/README.md @@ -1327,15 +1327,17 @@ under the real policy and checks that: both add forms stay hidden until Add is clicked; choosing Slack in the add target form leaves the HTTP fields out of what it submits, also after leaving the page and going back to it, when the browser restores the choice; the Copy button beside an entrypoint URL reads -"Copied" once clicked; an event expands and collapses, and so do a delivery's -attempts inside it; and 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 (its file -is built only with the `browser` build tag). Run it with `make test-browser` -after changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, -which runs the test in a digest-pinned headless browser image, so the host -needs no browser. +"Copied" once clicked; an entrypoint's Edit button shows its edit form in place +of its description and hides until the form closes, Cancel hides the form and +drops what was typed, and Save changes the description; an event expands and +collapses, and so do a delivery's attempts inside it; and 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 (its file is built only with the `browser` build tag). +Run it with `make test-browser` after changing `templates/` or `static/js/`: +that builds `Dockerfile.browser`, which runs the test in a digest-pinned +headless browser image, so the host needs no browser. The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte as the npm registry publishes it. It is a dependency, not this repo's build @@ -2893,6 +2895,7 @@ returns to the page that was asked for. | `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same | | `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | | `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | | `POST` | `/hook/{id}/targets` | Add target to webhook | diff --git a/internal/handlers/entrypoint_toggle_test.go b/internal/handlers/entrypoint_toggle_test.go new file mode 100644 index 0000000..77a2209 --- /dev/null +++ b/internal/handlers/entrypoint_toggle_test.go @@ -0,0 +1,95 @@ +package handlers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle +// which loaded the entrypoint before an edit of its description was +// saved does not write the old description back over the edit. The +// edit is submitted from a callback on the toggle's own read of the +// entrypoint, so it is saved after that read and before the toggle +// writes. +func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + wh := seedWebhookWithRetention(t, env.db, 30) + ep := seedEntrypoint(t, env.db, wh.ID) + require.True(t, ep.Active) + + router := chi.NewRouter() + router.Post( + "/hook/{sourceID}/entrypoints/{entrypointID}/edit", + env.handlers.HandleEntrypointEdit(), + ) + router.Post( + "/hook/{sourceID}/entrypoints/{entrypointID}/toggle", + env.handlers.HandleEntrypointToggle(), + ) + + // post submits one of the entrypoint's forms as the test user and + // returns the response's status code. + post := func(action string, form url.Values) int { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action, + strings.NewReader(form.Encode()), + ) + req.Header.Set( + "Content-Type", "application/x-www-form-urlencoded", + ) + + for _, c := range env.cookies { + req.AddCookie(c) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + return w.Code + } + + var ( + edited bool + editCode int + ) + + require.NoError(t, env.db.DB().Callback().Query(). + After("gorm:query"). + Register("test:edit_after_toggle_read", func(tx *gorm.DB) { + // Only the first read of an entrypoint, the toggle's, + // submits the edit. + if tx.Statement.Table != "entrypoints" || edited { + return + } + + edited = true + editCode = post( + "edit", url.Values{"description": {"Billing sender"}}, + ) + }), + ) + + require.Equal(t, http.StatusSeeOther, post("toggle", nil)) + require.Equal(t, http.StatusSeeOther, editCode) + + var stored database.Entrypoint + + require.NoError( + t, env.db.DB().First(&stored, "id = ?", ep.ID).Error, + ) + assert.False(t, stored.Active) + assert.Equal(t, "Billing sender", stored.Description) +} diff --git a/internal/handlers/notice.go b/internal/handlers/notice.go index 72b97e5..5a20d5c 100644 --- a/internal/handlers/notice.go +++ b/internal/handlers/notice.go @@ -20,6 +20,7 @@ const ( webhookSaved noticeCode = "webhook-saved" webhookDeleted noticeCode = "webhook-deleted" entrypointAdded noticeCode = "entrypoint-added" + entrypointSaved noticeCode = "entrypoint-saved" entrypointDeleted noticeCode = "entrypoint-deleted" entrypointActivated noticeCode = "entrypoint-activated" entrypointDeactivated noticeCode = "entrypoint-deactivated" @@ -48,6 +49,7 @@ func noticeFor(r *http.Request) *notice { webhookSaved: {Text: "Webhook saved."}, webhookDeleted: {Text: "Webhook deleted."}, entrypointAdded: {Text: "Entrypoint added."}, + entrypointSaved: {Text: "Entrypoint description saved."}, entrypointDeleted: {Text: "Entrypoint deleted."}, entrypointActivated: {Text: "Entrypoint activated."}, entrypointDeactivated: {Text: "Entrypoint deactivated."}, diff --git a/internal/handlers/source_delete_test.go b/internal/handlers/source_delete_test.go index 73d9259..211e891 100644 --- a/internal/handlers/source_delete_test.go +++ b/internal/handlers/source_delete_test.go @@ -86,12 +86,13 @@ var errInjectedDelete = errors.New("injected delete failure") // save of an existing row. var errInjectedSave = errors.New("injected save failure") -// seedEntrypoint inserts an entrypoint for a webhook. +// seedEntrypoint inserts an active entrypoint for a webhook and +// returns it. func seedEntrypoint( t *testing.T, db *database.Database, webhookID string, -) { +) *database.Entrypoint { t.Helper() ep := &database.Entrypoint{ @@ -104,6 +105,8 @@ func seedEntrypoint( t, db.DB().Omit(clause.Associations).Create(ep).Error, ) + + return ep } // countRows counts the live (not soft-deleted) rows of a model diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 377eac8..9bb3330 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1396,6 +1396,70 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { } } +// HandleEntrypointEdit handles changing an entrypoint's description. +// It writes only the description column, so the entrypoint keeps its +// URL, and an activate or deactivate saved since the page was shown +// is not undone. +func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + entrypointID := chi.URLParam(r, "entrypointID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + result := h.db.DB().Model(&database.Entrypoint{}).Where( + "id = ? AND webhook_id = ?", entrypointID, webhook.ID, + ).Update("description", r.PostFormValue("description")) + if result.Error != nil { + h.serverError( + w, r, "failed to edit entrypoint", result.Error, + ) + + return + } + + // The id came from the URL and may name another webhook's + // entrypoint, which this webhook does not have. + if result.RowsAffected == 0 { + h.renderError(w, r, http.StatusNotFound) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, entrypointSaved), + http.StatusSeeOther, + ) + } +} + // HandleTargetCreate handles adding a new target to a webhook. func (h *Handlers) HandleTargetCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -1877,9 +1941,13 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { return false, err } - ep.Active = !ep.Active + // Only the active column: saving the whole row would + // write back the description read above over an edit + // saved since. + active := !ep.Active - return ep.Active, h.db.DB().Save(&ep).Error + return active, h.db.DB().Model(&ep). + Update("active", active).Error }, "failed to toggle entrypoint", entrypointActivated, entrypointDeactivated, diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 7637cf8..cc5130a 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -86,6 +86,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkAddForms(ctx, t, page) checkTargetType(ctx, t, page+"/events") checkCopy(ctx, t, page) + checkEntrypointEdit(ctx, t, page) checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkMobileMenu(ctx, t, page) @@ -363,6 +364,62 @@ func checkCopy(ctx context.Context, t *testing.T, url string) { `clicking Copy does not show "Copied"`) } +// checkEntrypointEdit loads a webhook page whose entrypoint has no +// description, and checks that Edit shows the edit form in place of +// the description and hides until the form closes, so the form always +// opens on the saved description; that Cancel hides it and drops what +// was typed; and that Save changes the description the page shows. +func checkEntrypointEdit(ctx context.Context, t *testing.T, url string) { + t.Helper() + + const ( + editForm = `form[action$="/edit"]` + input = editForm + ` input[name="description"]` + description = `//span[text()="Entrypoint"]` + edit = `//button[text()="Edit"]` + ) + + require.NoError(t, chromedp.Run(ctx, loadPage(url))) + + assert.True(t, hidden(ctx, editForm), + "the edit form shows before Edit is clicked") + + click(ctx, t, edit) + assert.True(t, shown(ctx, editForm), + "clicking Edit does not show the edit form") + assert.True(t, hidden(ctx, description), + "the description stays shown beside the edit form") + assert.True(t, hidden(ctx, edit), + "Edit stays shown while the edit form is open") + + require.NoError(t, chromedp.Run( + ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery), + )) + click(ctx, t, `//button[text()="Cancel"]`) + assert.True(t, hidden(ctx, editForm), + "clicking Cancel does not hide the edit form") + assert.True(t, shown(ctx, description), + "clicking Cancel does not show the description again") + assert.True(t, shown(ctx, edit), + "clicking Cancel does not show Edit again") + + var typed string + + click(ctx, t, edit) + require.NoError(t, chromedp.Run( + ctx, chromedp.Value(input, &typed, chromedp.ByQuery), + )) + assert.Empty(t, typed, "Cancel keeps what was typed") + + require.NoError(t, chromedp.Run( + ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery), + )) + click(ctx, t, `//button[text()="Save"]`) + + assert.True(t, shown(ctx, `//span[text()="Billing sender"]`), + "saving the edit form does not change the description") +} + // checkEventLog loads the event log and checks that clicking an event's // row expands it, that in there clicking its delivery shows the // delivery's attempts and clicking again hides them, and that clicking diff --git a/internal/server/routes.go b/internal/server/routes.go index 24c63d0..c9bfe67 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -289,6 +289,10 @@ func (s *Server) setupSourceRoutes() { "/entrypoints", s.h.HandleEntrypointCreate(), ) + r.Post( + "/entrypoints/{entrypointID}/edit", + s.h.HandleEntrypointEdit(), + ) r.Post( "/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete(), diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 4ad98bc..dd52fc5 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" @@ -398,6 +399,44 @@ func (e *testEnv) seedTarget( return tgt } +// seedEntrypoint creates an active entrypoint for a webhook. +func (e *testEnv) seedEntrypoint( + t *testing.T, + webhookID string, +) *database.Entrypoint { + t.Helper() + + ep := &database.Entrypoint{ + WebhookID: webhookID, + Path: uuid.New().String(), + Description: "Default entrypoint", + Active: true, + } + + require.NoError( + t, + e.db.DB().Omit(clause.Associations).Create(ep).Error, + ) + + return ep +} + +// storedEntrypoint reloads an entrypoint row. +func (e *testEnv) storedEntrypoint( + t *testing.T, + entrypointID string, +) database.Entrypoint { + t.Helper() + + var ep database.Entrypoint + + require.NoError( + t, e.db.DB().First(&ep, "id = ?", entrypointID).Error, + ) + + return ep +} + // seedFailedDelivery records a terminally failed delivery of an event // to a target in the webhook's own database. func (e *testEnv) seedFailedDelivery( @@ -1087,6 +1126,119 @@ func TestHook_EntrypointActions(t *testing.T) { assert.Zero(t, left, "the delete should remove the entrypoint") } +// TestHook_EntrypointEdit changes an entrypoint's description with the +// edit form on the webhook page, then empties it. The entrypoint keeps +// its URL, and with no description it shows as "Entrypoint". Without +// the CSRF token, or without a session, the edit is refused. +func TestHook_EntrypointEdit(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + userID, _ := env.seedUser(t, "epeditor", "somepassword") + cookies := env.authCookies(t, userID, "epeditor") + wh := env.seedWebhook(t, userID) + ep := env.seedEntrypoint(t, wh.ID) + page := "/hook/" + wh.ID + + token, cookies := env.csrfFrom(t, page, cookies) + action := env.urlFrom( + t, page, `action="(/hook/[^/"]+/entrypoints/[^/"]+/edit)"`, + cookies, + ) + + assert.Equal( + t, http.StatusForbidden, + env.post(action, entrypointEditForm("", "no token"), cookies).Code, + "an edit without a CSRF token must be refused", + ) + + // The request without a session carries a valid CSRF token from + // the login page, so only the session check can refuse it. + anonToken, anon := env.csrfFrom(t, "/pages/login", nil) + refused := env.post( + action, entrypointEditForm(anonToken, "no session"), anon, + ) + assert.Equal(t, http.StatusSeeOther, refused.Code) + assert.Equal( + t, "/pages/login", refused.Header().Get("Location"), + "an edit without a session must be refused", + ) + + assert.Equal( + t, ep.Description, env.storedEntrypoint(t, ep.ID).Description, + "a refused edit must not change the description", + ) + + // edit submits the form with description and requires the + // redirect back to the webhook page with the notice. + edit := func(description string) { + t.Helper() + + w := env.post( + action, entrypointEditForm(token, description), cookies, + ) + env.requireNotice(t, w, page, "entrypoint-saved", + "Entrypoint description saved.", cookies) + } + + edit("Billing sender") + + stored := env.storedEntrypoint(t, ep.ID) + assert.Equal(t, "Billing sender", stored.Description) + assert.Equal(t, ep.Path, stored.Path, + "the edit must keep the entrypoint's URL") + + body := env.get(page, cookies).Body.String() + assert.Contains(t, body, ">Billing sender") + assert.Contains(t, body, "/h/"+ep.Path+"") + + edit("") + + assert.Empty(t, env.storedEntrypoint(t, ep.ID).Description) + assert.Contains(t, env.get(page, cookies).Body.String(), + ">Entrypoint", "no description shows as Entrypoint") +} + +// TestHook_EntrypointEdit_OtherUser404s has another logged-in user, with +// a CSRF token of their own, try to edit an entrypoint: through the +// owner's webhook, and through a webhook of their own. Both are 404s +// and the description stays as it was. +func TestHook_EntrypointEdit_OtherUser404s(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + ownerID, _ := env.seedUser(t, "epowner", "somepassword") + wh := env.seedWebhook(t, ownerID) + ep := env.seedEntrypoint(t, wh.ID) + + otherID, _ := env.seedUser(t, "epother", "somepassword") + other := env.authCookies(t, otherID, "epother") + theirs := env.seedWebhook(t, otherID) + token, other := env.csrfFrom(t, "/hook/"+theirs.ID, other) + + for _, webhookID := range []string{wh.ID, theirs.ID} { + path := "/hook/" + webhookID + "/entrypoints/" + ep.ID + "/edit" + + w := env.post(path, entrypointEditForm(token, "not theirs"), other) + assert.Equal(t, http.StatusNotFound, w.Code, path) + } + + assert.Equal( + t, ep.Description, env.storedEntrypoint(t, ep.ID).Description, + "another user must not change the description", + ) +} + +// entrypointEditForm fills in the webhook page's entrypoint edit form. +func entrypointEditForm(token, description string) url.Values { + return url.Values{ + "csrf_token": {token}, + "description": {description}, + } +} + // TestHook_TargetActions adds a target with the form on the webhook // page, follows its Edit link to the target edit form and submits // it, then deactivates, activates and deletes it, every URL and token diff --git a/static/js/app.js b/static/js/app.js index 4f76da9..7268703 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -70,7 +70,8 @@ document.addEventListener("alpine:init", function () { "use strict"; // Something a click shows and hides: the mobile menu, an add form, - // an event in the event log, a delivery's attempts. + // an entrypoint's edit form, an event in the event log, a delivery's + // attempts. window.Alpine.data("collapsible", function () { return { open: false, diff --git a/templates/source_detail.html b/templates/source_detail.html index 0cb0a66..ad57bdd 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -54,15 +54,26 @@