Let an entrypoint's description be edited in place (closes #392) #464
+12
-8
@@ -1327,15 +1327,18 @@ 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
|
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
|
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
|
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
|
"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, as does leaving the page and going back to it, 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
|
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,
|
mobile menu. It also fails if the browser reports a console warning or error, an
|
||||||
an uncaught exception, or anything the policy refused. `make check` and the
|
uncaught exception, or anything the policy refused. `make check` and the image
|
||||||
image build lint it but do not run it, and `make test` leaves it out (its file
|
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||||
is built only with the `browser` build tag). Run it with `make test-browser`
|
built only with the `browser` build tag). Run it with `make test-browser` after
|
||||||
after changing `templates/` or `static/js/`: that builds `Dockerfile.browser`,
|
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
||||||
which runs the test in a digest-pinned headless browser image, so the host
|
runs the test in a digest-pinned headless browser image, so the host needs no
|
||||||
needs no browser.
|
browser.
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
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
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -2917,6 +2920,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}/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}/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` | 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}/delete` | Delete an entrypoint |
|
||||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||||
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
webhookSaved noticeCode = "webhook-saved"
|
webhookSaved noticeCode = "webhook-saved"
|
||||||
webhookDeleted noticeCode = "webhook-deleted"
|
webhookDeleted noticeCode = "webhook-deleted"
|
||||||
entrypointAdded noticeCode = "entrypoint-added"
|
entrypointAdded noticeCode = "entrypoint-added"
|
||||||
|
entrypointSaved noticeCode = "entrypoint-saved"
|
||||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||||
entrypointActivated noticeCode = "entrypoint-activated"
|
entrypointActivated noticeCode = "entrypoint-activated"
|
||||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||||
@@ -48,6 +49,7 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
webhookSaved: {Text: "Webhook saved."},
|
webhookSaved: {Text: "Webhook saved."},
|
||||||
webhookDeleted: {Text: "Webhook deleted."},
|
webhookDeleted: {Text: "Webhook deleted."},
|
||||||
entrypointAdded: {Text: "Entrypoint added."},
|
entrypointAdded: {Text: "Entrypoint added."},
|
||||||
|
entrypointSaved: {Text: "Entrypoint description saved."},
|
||||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||||
entrypointActivated: {Text: "Entrypoint activated."},
|
entrypointActivated: {Text: "Entrypoint activated."},
|
||||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ var errInjectedDelete = errors.New("injected delete failure")
|
|||||||
// save of an existing row.
|
// save of an existing row.
|
||||||
var errInjectedSave = errors.New("injected save failure")
|
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(
|
func seedEntrypoint(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.Database,
|
db *database.Database,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) {
|
) *database.Entrypoint {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
ep := &database.Entrypoint{
|
||||||
@@ -104,6 +105,8 @@ func seedEntrypoint(
|
|||||||
t,
|
t,
|
||||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return ep
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRows counts the live (not soft-deleted) rows of a model
|
// countRows counts the live (not soft-deleted) rows of a model
|
||||||
|
|||||||
@@ -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.
|
// HandleTargetCreate handles adding a new target to a webhook.
|
||||||
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1877,9 +1941,13 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
return false, err
|
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",
|
"failed to toggle entrypoint",
|
||||||
entrypointActivated, entrypointDeactivated,
|
entrypointActivated, entrypointDeactivated,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkAddForms(ctx, t, page)
|
checkAddForms(ctx, t, page)
|
||||||
checkTargetType(ctx, t, page+"/events")
|
checkTargetType(ctx, t, page+"/events")
|
||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
@@ -363,6 +364,89 @@ func checkCopy(ctx context.Context, t *testing.T, url string) {
|
|||||||
`clicking Copy does not show "Copied"`)
|
`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; that after typing, opening the page at elsewhere and going
|
||||||
|
// back, Edit again opens the form on the saved description; and that
|
||||||
|
// Save changes the description the page shows.
|
||||||
|
func checkEntrypointEdit(
|
||||||
|
ctx context.Context, t *testing.T, url, elsewhere 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")
|
||||||
|
|
||||||
|
var loaded string
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
||||||
|
loadPage(elsewhere),
|
||||||
|
chromedp.NavigateBack(),
|
||||||
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(
|
||||||
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
require.Equal(
|
||||||
|
t, "back_forward", loaded,
|
||||||
|
"going back, the browser did not load the page again",
|
||||||
|
)
|
||||||
|
|
||||||
|
click(ctx, t, edit)
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
assert.Empty(t, typed, "going back puts what was typed back in the form")
|
||||||
|
|
||||||
|
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
|
// checkEventLog loads the event log and checks that clicking an event's
|
||||||
// row expands it, that in there clicking its delivery shows the
|
// row expands it, that in there clicking its delivery shows the
|
||||||
// delivery's attempts and clicking again hides them, and that clicking
|
// delivery's attempts and clicking again hides them, and that clicking
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
"/entrypoints",
|
"/entrypoints",
|
||||||
s.h.HandleEntrypointCreate(),
|
s.h.HandleEntrypointCreate(),
|
||||||
)
|
)
|
||||||
|
r.Post(
|
||||||
|
"/entrypoints/{entrypointID}/edit",
|
||||||
|
s.h.HandleEntrypointEdit(),
|
||||||
|
)
|
||||||
r.Post(
|
r.Post(
|
||||||
"/entrypoints/{entrypointID}/delete",
|
"/entrypoints/{entrypointID}/delete",
|
||||||
s.h.HandleEntrypointDelete(),
|
s.h.HandleEntrypointDelete(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -398,6 +399,44 @@ func (e *testEnv) seedTarget(
|
|||||||
return tgt
|
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
|
// seedFailedDelivery records a terminally failed delivery of an event
|
||||||
// to a target in the webhook's own database.
|
// to a target in the webhook's own database.
|
||||||
func (e *testEnv) seedFailedDelivery(
|
func (e *testEnv) seedFailedDelivery(
|
||||||
@@ -1087,6 +1126,119 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
assert.Zero(t, left, "the delete should remove the entrypoint")
|
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</span>")
|
||||||
|
assert.Contains(t, body, "/h/"+ep.Path+"</code>")
|
||||||
|
|
||||||
|
edit("")
|
||||||
|
|
||||||
|
assert.Empty(t, env.storedEntrypoint(t, ep.ID).Description)
|
||||||
|
assert.Contains(t, env.get(page, cookies).Body.String(),
|
||||||
|
">Entrypoint</span>", "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
|
// TestHook_TargetActions adds a target with the form on the webhook
|
||||||
// page, follows its Edit link to the target edit form and submits
|
// page, follows its Edit link to the target edit form and submits
|
||||||
// it, then deactivates, activates and deletes it, every URL and token
|
// it, then deactivates, activates and deletes it, every URL and token
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ document.addEventListener("alpine:init", function () {
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// 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 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 () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
|||||||
@@ -54,15 +54,29 @@
|
|||||||
|
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Entrypoints}}
|
{{range .Entrypoints}}
|
||||||
<div class="p-4">
|
<div class="p-4" x-data="collapsible">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||||
|
<!-- Edit shows this form in place of the
|
||||||
|
description and hides until it closes, and
|
||||||
|
Cancel resets what was typed. With
|
||||||
|
autocomplete="off", going back to the page
|
||||||
|
does not put unsaved text back either, so
|
||||||
|
the form always opens on the saved
|
||||||
|
description. -->
|
||||||
|
<form x-show="open" x-cloak method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/edit" class="flex w-full gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<input type="text" name="description" value="{{.Description}}" autocomplete="off" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||||
|
<button type="reset" @click="toggle" class="btn-secondary text-sm">Cancel</button>
|
||||||
|
</form>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<span class="badge-success">Active</span>
|
<span class="badge-success">Active</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<button type="button" x-show="closed" @click="toggle" class="btn-small" title="Edit">Edit</button>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
|
|||||||
Atsaukties uz šo jaunā problēmā
Block a user