Files
webhooker/internal/server/resubmit_test.go
T
clawbot f61b608b1c
check / check (push) Successful in 3m15s
Use one name for each thing the UI shows (closes #399)
The database target type is called an archive on its badge, in the add target form's type list and on its edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere, the new webhook page included. The retry field is labelled "Delivery attempts", with its help text and error messages to match, on both target forms and in the target list. The navbar says "Sign out" and the sign-in page "Sign in". The resubmit notice says "webhook". The stored values (`database`, `max_retries`) and their meaning are unchanged.

Model: opus-5-5
2026-10-03 02:31:05 +00:00

216 lines
6.2 KiB
Go

package server_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// maxResubmits bounds the requests the tests below send to the
// resubmit route. The route's rate limit belongs to the middleware;
// this only has to sit well above it, so that a route without the
// limiter fails its test instead of looping.
const maxResubmits = 100
// resubmitPath is the resubmit route for one stored event.
func resubmitPath(webhookID, eventID string) string {
return "/hook/" + webhookID + "/events/" + eventID + "/resubmit"
}
// csrfForm is a resubmit form carrying the given CSRF token.
func csrfForm(token string) url.Values {
form := url.Values{}
form.Set("csrf_token", token)
return form
}
// TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit pins
// RequireAuth on the resubmit route. The handler also turns away a
// request without a session, with the same redirect, so a refusal
// alone would pass without RequireAuth. What RequireAuth adds is that
// it refuses such a request before the route's rate limit, so a
// signed-out client cannot spend the budget a signed-in user
// resubmits from. Each request carries a CSRF token valid for its own
// cookie, so CSRF lets it through to RequireAuth.
func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
t *testing.T,
) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, signedOut := env.csrfFrom(t, "/pages/login", nil)
for i := range maxResubmits {
w := env.post(path, csrfForm(token), signedOut)
require.Equal(t, http.StatusSeeOther, w.Code, "request %d", i)
require.Equal(
t, "/pages/login", w.Header().Get("Location"),
"request %d", i,
)
}
require.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a signed-out request must store nothing",
)
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this webhook has no active targets", cookies,
)
}
// TestEventResubmit_RefusedWithoutAValidCSRFToken pins CSRF on the
// resubmit route: a signed-in user's POST is refused with 403, and
// stores nothing, unless it carries the token issued to that user's
// own browser.
func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
otherBrowsers, _ := env.csrfFrom(t, "/pages/login", nil)
for name, form := range map[string]url.Values{
"no token": {},
"a malformed token": csrfForm("not-a-token"),
"another browser's token": csrfForm(otherBrowsers),
} {
assert.Equal(
t, http.StatusForbidden,
env.post(path, form, cookies).Code, name,
)
}
assert.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a refused request must store nothing",
)
// The same request with the user's own token goes through, so the
// refusals above were the token's doing.
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this webhook has no active targets", cookies,
)
}
// TestEventResubmit_AnotherWebhooksEvent404s pins, on the route as
// registered, that a signed-in user gets 404, and nothing is stored,
// for an event of a webhook another user owns, which the handler's
// ownership check refuses, and for another webhook's event posted
// under a webhook the user does own, which the event lookup refuses.
// The user's own event, posted the same way, is accepted, so the
// second 404 comes from the lookup and not from a route that never
// passed the event ID to the handler.
func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ownerID, _ := env.seedUser(t, "owner", "somepassword")
owners := env.seedWebhook(t, ownerID)
ownersEvent := env.seedEvent(t, owners.ID, `{"owner":"only"}`)
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruders := env.seedWebhook(t, intruderID)
intrudersEvent := env.seedEvent(
t, intruders.ID, `{"intruder":"own"}`,
)
intrudersLogs := "/hook/" + intruders.ID + "/events"
token, cookies := env.csrfFrom(
t, intrudersLogs, env.authCookies(t, intruderID, "intruder"),
)
for name, path := range map[string]string{
"another user's webhook": resubmitPath(
owners.ID, ownersEvent.ID,
),
"another webhook's event": resubmitPath(
intruders.ID, ownersEvent.ID,
),
} {
w := env.post(path, csrfForm(token), cookies)
assert.Equal(t, http.StatusNotFound, w.Code, name)
}
assert.Equal(t, int64(1), env.countEvents(t, owners.ID))
assert.Equal(t, int64(1), env.countEvents(t, intruders.ID))
env.requireNotice(
t,
env.post(
resubmitPath(intruders.ID, intrudersEvent.ID),
csrfForm(token), cookies,
),
intrudersLogs, "resubmit-no-targets",
"this webhook has no active targets", cookies,
)
}
// TestEventResubmit_RateLimited pins the rate limit on the resubmit
// route: a signed-in user's resubmits are accepted until the budget
// is spent, and then refused with 429.
func TestEventResubmit_RateLimited(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
token, cookies := env.csrfFrom(
t, "/hook/"+wh.ID+"/events",
env.authCookies(t, userID, "resubmitter"),
)
limited := false
for range maxResubmits {
code := env.post(path, csrfForm(token), cookies).Code
if code == http.StatusTooManyRequests {
limited = true
break
}
require.Equal(
t, http.StatusSeeOther, code,
"a resubmit within the budget must be accepted",
)
}
assert.True(
t, limited, "repeated resubmits must eventually be refused",
)
}