Move webhook pages to /hook/ID and inbound URLs to /h/UUID (closes #367)
check / check (push) Successful in 3m41s

The webhook page and everything under it move from /source/ID to
/hook/ID, the list and new-webhook form to /hooks and /hooks/new, and
the event log from .../logs to /hook/ID/events, body download
included. Entrypoint URLs move from /webhook/UUID to /h/UUID, and the
webhook page shows only that form. The old paths are gone.

Links, redirects, form actions, tests, comments and the README follow.
Both links to the event log page, and its title and heading, now read
"Full Event Log". Go identifiers and template file names are
unchanged. Route tests follow every link and submit every form the
templates render to a moved page, through the production router, with
the link, form action and token taken from the rendered page.

Model: opus-5-5
This commit is contained in:
2026-10-02 02:07:20 +00:00
committed by sneak
parent bfdbc937c6
commit d9cff5e662
47 changed files with 678 additions and 223 deletions
+420 -10
View File
@@ -47,8 +47,8 @@ type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {}
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
// dependency. These tests never delete a webhook, so there is
// nothing to record.
// dependency. No test here checks what gets evicted, so it records
// nothing.
type noopEvictor struct{}
func (e *noopEvictor) EvictWebhook(string) {}
@@ -240,6 +240,26 @@ func (e *testEnv) csrfFrom(
return token, combined
}
// urlFrom renders the page at path and returns the link or form
// action that pattern's one group captures, so a test requests the
// URL the template emitted rather than one it wrote itself.
func (e *testEnv) urlFrom(
t *testing.T,
path, pattern string,
cookies []*http.Cookie,
) string {
t.Helper()
w := e.get(path, cookies)
require.Equal(t, http.StatusOK, w.Code)
match := regexp.MustCompile(pattern).
FindStringSubmatch(w.Body.String())
require.Len(t, match, 2, "%s should render %s", path, pattern)
return html.UnescapeString(match[1])
}
// authCookies forges an authenticated session for the given user.
func (e *testEnv) authCookies(
t *testing.T,
@@ -674,7 +694,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
require.NotNil(t, fresh, "login must set a session cookie")
assert.Equal(
t, "/sources",
t, "/hooks",
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
"the new session cookie must authenticate",
)
@@ -741,7 +761,344 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
)
}
// --- /source/{sourceID} group ---
// --- /hooks group ---
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
// production router, follows both of its links to the new-webhook
// form, then submits the form to the action and with the token the
// page rendered. A mistyped route, link or form action fails here;
// the handler tests cannot catch any of them, because they never
// route a request.
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "lister", "somepassword")
cookies := env.authCookies(t, userID, "lister")
// The list shows its "Create Webhook" link only while it is empty.
createLink := env.urlFrom(
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
)
existing := env.seedWebhook(t, userID)
list := env.get("/hooks", cookies)
require.Equal(t, http.StatusOK, list.Code)
assert.Contains(
t, list.Body.String(), `href="/hook/`+existing.ID+`"`,
"the list should link the user's webhook",
)
// The "New Webhook" link has an icon between its href and its text.
newLink := env.urlFrom(
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
cookies,
)
token, cookies := env.csrfFrom(t, newLink, cookies)
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
assert.Equal(
t, action,
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
"both links should open the new-webhook form",
)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("name", "created")
w := env.post(action, form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
var created database.Webhook
require.NoError(t,
env.db.DB().Where("name = ?", "created").First(&created).Error,
)
assert.Equal(
t, "/hook/"+created.ID, w.Header().Get("Location"),
"creating a webhook should redirect to its page",
)
}
// --- /hook/{sourceID} group ---
// TestHook_EditFormAndDelete follows the webhook page's Edit link to
// the edit form and submits it, then deletes the webhook with the
// form on its page, every URL and token taken from the rendered
// pages.
func TestHook_EditFormAndDelete(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "editor", "somepassword")
cookies := env.authCookies(t, userID, "editor")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
editPage := env.urlFrom(t, page, `href="(/hook/[^/"]+/edit)"`, cookies)
token, cookies := env.csrfFrom(t, editPage, cookies)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("name", "renamed")
w := env.post(
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, page, w.Header().Get("Location"))
var edited database.Webhook
require.NoError(t, env.db.DB().First(&edited, "id = ?", wh.ID).Error)
assert.Equal(t, "renamed", edited.Name)
form = url.Values{}
form.Set("csrf_token", token)
w = env.post(
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location"))
assert.Equal(
t, http.StatusNotFound, env.get(page, cookies).Code,
"a deleted webhook's page should be gone",
)
}
// TestHook_EntrypointActions adds, deactivates and deletes an
// entrypoint with the forms on the webhook page, each submitted to
// the action and with the token the page rendered.
func TestHook_EntrypointActions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "epuser", "somepassword")
cookies := env.authCookies(t, userID, "epuser")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
token, cookies := env.csrfFrom(t, page, cookies)
form := url.Values{}
form.Set("csrf_token", token)
// submit posts the webhook page's form whose action pattern
// captures, and requires the redirect back to that page.
submit := func(pattern string) {
t.Helper()
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
}
submit(`action="(/hook/[^/"]+/entrypoints)"`)
var added database.Entrypoint
require.NoError(t,
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
)
require.True(t, added.Active)
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
var toggled database.Entrypoint
require.NoError(t,
env.db.DB().First(&toggled, "id = ?", added.ID).Error,
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
var left int64
require.NoError(t, env.db.DB().Model(&database.Entrypoint{}).
Where("webhook_id = ?", wh.ID).Count(&left).Error)
assert.Zero(t, left, "the delete should remove the entrypoint")
}
// 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 and deletes it, every URL and token taken from
// the rendered pages.
func TestHook_TargetActions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "tgtuser", "somepassword")
cookies := env.authCookies(t, userID, "tgtuser")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
token, cookies := env.csrfFrom(t, page, cookies)
// submit posts form, with the token, to the action pattern
// captures on the page at from, and requires the redirect back to
// the webhook page.
submit := func(from, pattern string, form url.Values) {
t.Helper()
form.Set("csrf_token", token)
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
}
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
"name": {"added"},
"type": {string(database.TargetTypeLog)},
})
editPage := env.urlFrom(
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
)
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
url.Values{"name": {"renamed"}})
var edited database.Target
require.NoError(t,
env.db.DB().First(&edited, "webhook_id = ?", wh.ID).Error,
)
assert.Equal(t, "renamed", edited.Name)
require.True(t, edited.Active)
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
url.Values{})
var toggled database.Target
require.NoError(t,
env.db.DB().First(&toggled, "id = ?", edited.ID).Error,
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
url.Values{})
var left int64
require.NoError(t, env.db.DB().Model(&database.Target{}).
Where("webhook_id = ?", wh.ID).Count(&left).Error)
assert.Zero(t, left, "the delete should remove the target")
}
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
// Event Log" link, then resubmits a stored event with the form on
// that page, submitted to the action and with the token the page
// rendered.
func TestHook_ResubmitFromEventLog(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
cookies := env.authCookies(t, userID, "resubmitter")
wh := env.seedWebhook(t, userID)
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
logsPath := env.urlFrom(
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
)
token, cookies := env.csrfFrom(t, logsPath, cookies)
form := url.Values{}
form.Set("csrf_token", token)
w := env.post(
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
)
webhookDB, err := env.dbMgr.GetDB(wh.ID)
require.NoError(t, err)
var events int64
require.NoError(t,
webhookDB.Model(&database.Event{}).Count(&events).Error,
)
assert.Equal(t, int64(2), events, "the resubmit stores a new event")
}
// TestHook_LinksBetweenPages follows each link to a webhook page that
// the tests above do not: the navbar's "Webhooks" links, the back and
// Cancel links, the list's link to a webhook, the "Full Event Log"
// link beside the recent events, and the event log's page links. Each
// must point where it should, and that page must render.
func TestHook_LinksBetweenPages(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "navigator", "somepassword")
cookies := env.authCookies(t, userID, "navigator")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
// The event log shows 25 events a page; one more gives it a second
// page, so it renders its Next and Previous links.
for range 26 {
env.seedEvent(t, wh.ID, "paged")
}
list := "/hooks"
newForm := list + "/new"
page := "/hook/" + wh.ID
targetEdit := page + "/targets/" + tgt.ID + "/edit"
events := page + "/events"
back := `href="([^"]+)"[^>]*>&larr; Back to `
cancel := `href="([^"]+)"[^>]*>Cancel<`
for _, link := range []struct{ from, pattern, want string }{
// The navbar on the profile page: its desktop link, then its
// mobile menu link.
{
"/user/navigator/",
`href="([^"]+)" class="btn-text">Webhooks<`,
list,
},
{
"/user/navigator/",
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
list,
},
{list, `href="(/hook/[^"]+)"`, page},
{newForm, back, list},
{newForm, cancel, list},
{page, back, list},
{page, `Recent Events</h2>\s*<a href="([^"]+)"`, events},
{page + "/edit", back, page},
{page + "/edit", cancel, page},
{targetEdit, back, page},
{targetEdit, cancel, page},
{events, back, page},
{events, `href="([^"]+)"[^>]*>Next &rarr;<`, events + "?page=2"},
{events + "?page=2", `href="([^"]+)"[^>]*>&larr; Previous<`, events + "?page=1"},
} {
got := env.urlFrom(t, link.from, link.pattern, cookies)
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
assert.Equal(t, http.StatusOK, env.get(got, cookies).Code, got)
}
}
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
// feature the way a user does: render the event log page through
@@ -769,11 +1126,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
wh := env.seedWebhook(t, userID)
env.seedEvent(t, wh.ID, stored)
page := env.get("/source/"+wh.ID+"/logs", cookies)
page := env.get("/hook/"+wh.ID+"/events", cookies)
require.Equal(t, http.StatusOK, page.Code)
link := regexp.MustCompile(
`href="(/source/[^"]+/body)"`,
`href="(/hook/[^"]+/body)"`,
).FindStringSubmatch(page.Body.String())
require.Len(
t, link, 2,
@@ -819,7 +1176,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
const payload = "OWNERS-PAYLOAD-77c1"
evt := env.seedEvent(t, wh.ID, payload)
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruder := env.authCookies(t, intruderID, "intruder")
@@ -853,7 +1210,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
"/replay"
assert.Equal(
@@ -879,7 +1236,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
// The token and the action URL both come out of the rendered
// page, so a typo in either the route pattern or the template
// fails here.
logsPath := "/source/" + wh.ID + "/logs"
logsPath := "/hook/" + wh.ID + "/events"
token, cookies := env.csrfFrom(t, logsPath, cookies)
@@ -887,7 +1244,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
require.Equal(t, http.StatusOK, page.Code)
action := regexp.MustCompile(
`action="(/source/[^"]+/replay)"`,
`action="(/hook/[^"]+/replay)"`,
).FindStringSubmatch(page.Body.String())
require.Len(
t, action, 2,
@@ -912,6 +1269,59 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
)
}
// --- /h/{uuid} receiver ---
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
// the webhook page shows and posts to it through the production
// router until the receiver rate limit refuses it. The URL has to
// reach the receiver, and the limit has to apply to it.
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
t.Parallel()
const limit = 2
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: limit,
})
userID, _ := env.seedUser(t, "receiver", "somepassword")
cookies := env.authCookies(t, userID, "receiver")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
Active: true,
},
).Error)
page := env.get("/hook/"+wh.ID, cookies)
require.Equal(t, http.StatusOK, page.Code)
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
FindStringSubmatch(page.Body.String())
require.Len(
t, shown, 2, "the webhook page should show the entrypoint URL",
)
for i := range limit {
assert.Equal(
t, http.StatusOK,
env.post(shown[1], url.Values{}, nil).Code,
"request %d should reach the receiver", i,
)
}
assert.Equal(
t, http.StatusTooManyRequests,
env.post(shown[1], url.Values{}, nil).Code,
"the receiver rate limit must apply to the entrypoint URL",
)
}
// metricsConfig is a Config differing from the routing default only
// in the two /metrics credentials.
func metricsConfig(