check / check (push) Waiting to run
Two comments named the wrong mechanism: loadResubmitSource credited soft-delete for refusing a reaped event, though the retention reaper deletes event rows outright, and createAndFanOut claimed to be the only path that creates deliveries, though per-delivery replay creates one without an event. Both now say what the code does. The resubmit route's middleware had no tests through the router; new tests drive the production router to pin the refusal without a valid CSRF token, the rate limit, signed-out requests never spending it, and another webhook's event refused by the event lookup while the user's own event is accepted. Each fails with its check removed. Model: opus-5-5
216 lines
6.2 KiB
Go
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 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, 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 source 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",
|
|
)
|
|
}
|