check / check (push) Waiting to run
loadResubmitSource credited GORM's soft-delete scope for refusing a reaped event; the reaper deletes the row outright. createAndFanOut claimed to be the only path that creates deliveries; per-delivery replay creates one too. New tests drive the resubmit route through the production router: CSRF refuses a missing, malformed or foreign token; another user's webhook and another webhook's event are 404; the rate limit refuses once spent; and signed-out requests never reach that rate limit. The handler refuses a signed-out request with the same redirect itself, so keeping such requests off the budget is what RequireAuth adds. Model: opus-5-5
203 lines
5.9 KiB
Go
203 lines
5.9 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 source 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 source has no active targets", cookies,
|
|
)
|
|
}
|
|
|
|
// TestEventResubmit_AnotherWebhooksEvent404s pins the ownership check
|
|
// the resubmit handler makes, on the route as registered: a signed-in
|
|
// user gets 404, and nothing is stored, for an event of a webhook
|
|
// another user owns, and for another webhook's event posted under a
|
|
// webhook the user does own.
|
|
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)
|
|
// An event of its own gives the intruder's webhook its database,
|
|
// so the second request below gets as far as the event lookup.
|
|
env.seedEvent(t, intruders.ID, `{"intruder":"own"}`)
|
|
|
|
token, cookies := env.csrfFrom(
|
|
t, "/hook/"+intruders.ID+"/events",
|
|
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))
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|