check / check (push) Waiting to run
webhooker_delivery_duration_seconds was absent from /metrics until the first delivery, while every other delivery series is materialised at zero when the collectors are registered. initSeries now materialises the histogram too, for the same four target types, so a scrape of an instance that has delivered nothing shows it with a zero count and sum. The registration test lists it with the other series, and a new route test scrapes /metrics on a freshly built instance and finds it for every target type. Model: opus-5-5
1607 lines
44 KiB
Go
1607 lines
44 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/metrics"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
"sneak.berlin/go/webhooker/internal/server"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
"sneak.berlin/go/webhooker/static"
|
|
)
|
|
|
|
// csrfCookieName is the cookie gorilla/csrf issues when it runs. Its
|
|
// presence or absence on a response is how these tests tell whether
|
|
// the CSRF middleware executed.
|
|
const csrfCookieName = "_gorilla_csrf"
|
|
|
|
const (
|
|
// metricsUser and metricsAuthValue are the /metrics basic-auth
|
|
// credentials the metrics routing tests below configure.
|
|
metricsUser = "metrics"
|
|
metricsAuthValue = "s3cret"
|
|
)
|
|
|
|
type noopNotifier struct{}
|
|
|
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
|
|
|
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
|
// dependency. No test here checks what gets evicted, so it records
|
|
// nothing.
|
|
type noopEvictor struct{}
|
|
|
|
func (e *noopEvictor) EvictWebhook(string) {}
|
|
|
|
// testEnv is the real router from routes.go plus the collaborators
|
|
// tests need to seed users and forge sessions.
|
|
type testEnv struct {
|
|
router http.Handler
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
|
|
// The collaborators the router was built from, kept so a test
|
|
// that needs a second router over the same graph — one carrying
|
|
// a panicking probe route, or one with Sentry registered — can
|
|
// build it without wiring the graph again.
|
|
log *logger.Logger
|
|
cfg *config.Config
|
|
mw *middleware.Middleware
|
|
hnd *handlers.Handlers
|
|
}
|
|
|
|
// newTestEnv wires the dependency graph with fx and builds the
|
|
// production route tree, so middleware registration order is
|
|
// exercised exactly as it ships.
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithConfig(t, &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
})
|
|
}
|
|
|
|
// newTestEnvWithConfig is newTestEnv over a caller-supplied Config,
|
|
// for the routes whose existence the configuration decides. The same
|
|
// pointer reaches the router and every middleware, so a test cannot
|
|
// accidentally configure one and not the other.
|
|
func newTestEnvWithConfig(
|
|
t *testing.T, cfg *config.Config,
|
|
) *testEnv {
|
|
t.Helper()
|
|
|
|
var (
|
|
log *logger.Logger
|
|
mw *middleware.Middleware
|
|
hnd *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := fxtest.New(
|
|
t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
func() *config.Config { return cfg },
|
|
database.New,
|
|
database.NewWebhookDBManager,
|
|
healthcheck.New,
|
|
session.New,
|
|
func() delivery.Notifier { return &noopNotifier{} },
|
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
|
metrics.NewRegistry,
|
|
metrics.New,
|
|
middleware.New,
|
|
delivery.NewGuard,
|
|
handlers.New,
|
|
),
|
|
fx.Populate(&log, &mw, &hnd, &sess, &db, &dbMgr),
|
|
)
|
|
app.RequireStart()
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
return &testEnv{
|
|
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
|
sess: sess,
|
|
db: db,
|
|
dbMgr: dbMgr,
|
|
log: log,
|
|
cfg: cfg,
|
|
mw: mw,
|
|
hnd: hnd,
|
|
}
|
|
}
|
|
|
|
// oversizeValue returns a form value one byte past the route-group
|
|
// body cap, so an encoded form containing it is guaranteed oversize.
|
|
func oversizeValue() string {
|
|
return strings.Repeat("a", int(server.MaxFormBodySizeForTest)+1)
|
|
}
|
|
|
|
// csrfCookieSet reports whether the response issued a gorilla/csrf
|
|
// cookie, which only happens if the CSRF middleware ran.
|
|
func csrfCookieSet(w *httptest.ResponseRecorder) bool {
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == csrfCookieName {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// get issues a GET through the router with the supplied cookies.
|
|
func (e *testEnv) get(
|
|
path string,
|
|
cookies []*http.Cookie,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, path, nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// post issues a urlencoded form POST through the router. The body is
|
|
// a strings.Reader, so the request carries an accurate
|
|
// Content-Length — the signal MaxBodySize checks up front.
|
|
func (e *testEnv) post(
|
|
path string,
|
|
form url.Values,
|
|
cookies []*http.Cookie,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, path,
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// csrfFrom renders the page at path and returns the CSRF token from
|
|
// its form together with every cookie needed for the follow-up POST.
|
|
func (e *testEnv) csrfFrom(
|
|
t *testing.T,
|
|
path string,
|
|
cookies []*http.Cookie,
|
|
) (string, []*http.Cookie) {
|
|
t.Helper()
|
|
|
|
w := e.get(path, cookies)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
pattern := regexp.MustCompile(
|
|
`name="csrf_token" value="([^"]+)"`,
|
|
)
|
|
|
|
match := pattern.FindStringSubmatch(w.Body.String())
|
|
require.Len(t, match, 2, "form must embed a CSRF token")
|
|
|
|
// html/template escapes "+" and "=" in attribute values, and
|
|
// gorilla/csrf tokens are standard base64, so the value read
|
|
// out of the markup has to be unescaped before it is submitted.
|
|
token := html.UnescapeString(match[1])
|
|
|
|
// A cookie the page sets replaces the one of the same name, as in
|
|
// a browser. Sent both, the server would read the first, older one.
|
|
set := w.Result().Cookies()
|
|
combined := make([]*http.Cookie, 0, len(cookies)+len(set))
|
|
|
|
for _, c := range cookies {
|
|
replaced := slices.ContainsFunc(set, func(n *http.Cookie) bool {
|
|
return n.Name == c.Name
|
|
})
|
|
if !replaced {
|
|
combined = append(combined, c)
|
|
}
|
|
}
|
|
|
|
combined = append(combined, set...)
|
|
|
|
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])
|
|
}
|
|
|
|
// requireNotice requires w to redirect to dest carrying the notice
|
|
// code, then renders that page and requires it to show text.
|
|
func (e *testEnv) requireNotice(
|
|
t *testing.T,
|
|
w *httptest.ResponseRecorder,
|
|
dest, code, text string,
|
|
cookies []*http.Cookie,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
|
|
|
|
page := e.get(w.Header().Get("Location"), cookies)
|
|
require.Equal(t, http.StatusOK, page.Code)
|
|
assert.Contains(t, page.Body.String(), text)
|
|
}
|
|
|
|
// authCookies forges an authenticated session for the given user.
|
|
func (e *testEnv) authCookies(
|
|
t *testing.T,
|
|
userID, username string,
|
|
) []*http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/setup", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
s, err := e.sess.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
e.sess.SetUser(s, userID, username)
|
|
require.NoError(t, e.sess.Save(req, w, s))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
return cookies
|
|
}
|
|
|
|
// seedUser creates a user with the given password and returns the
|
|
// stored hash so tests can assert whether it later changed.
|
|
func (e *testEnv) seedUser(
|
|
t *testing.T,
|
|
username, password string,
|
|
) (string, string) {
|
|
t.Helper()
|
|
|
|
hash, err := database.HashPassword(password)
|
|
require.NoError(t, err)
|
|
|
|
user := &database.User{Username: username, Password: hash}
|
|
require.NoError(t, e.db.DB().Create(user).Error)
|
|
|
|
return user.ID, hash
|
|
}
|
|
|
|
// seedWebhook creates a webhook owned by the given user.
|
|
func (e *testEnv) seedWebhook(
|
|
t *testing.T,
|
|
userID string,
|
|
) *database.Webhook {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{UserID: userID, Name: "routed"}
|
|
|
|
require.NoError(
|
|
t,
|
|
e.db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
return wh
|
|
}
|
|
|
|
// seedEvent records one event with the given body in a webhook's
|
|
// own database.
|
|
func (e *testEnv) seedEvent(
|
|
t *testing.T,
|
|
webhookID, body string,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
Method: http.MethodPost,
|
|
Body: body,
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
webhookDB.Omit(clause.Associations).Create(event).Error,
|
|
)
|
|
|
|
return event
|
|
}
|
|
|
|
// seedTarget creates an active HTTP target for a webhook.
|
|
func (e *testEnv) seedTarget(
|
|
t *testing.T,
|
|
webhookID string,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
tgt := &database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "routed-target",
|
|
Type: database.TargetTypeHTTP,
|
|
Active: true,
|
|
Config: `{"url":"http://93.184.216.34/hook"}`,
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
e.db.DB().Omit(clause.Associations).Create(tgt).Error,
|
|
)
|
|
|
|
return tgt
|
|
}
|
|
|
|
// seedFailedDelivery records a terminally failed delivery of an event
|
|
// to a target in the webhook's own database.
|
|
func (e *testEnv) seedFailedDelivery(
|
|
t *testing.T,
|
|
webhookID, eventID, targetID string,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
dlv := &database.Delivery{
|
|
EventID: eventID,
|
|
TargetID: targetID,
|
|
Status: database.DeliveryStatusFailed,
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
webhookDB.Omit(clause.Associations).Create(dlv).Error,
|
|
)
|
|
|
|
return dlv
|
|
}
|
|
|
|
// countDeliveries reports how many deliveries a webhook's database
|
|
// holds.
|
|
func (e *testEnv) countDeliveries(
|
|
t *testing.T, webhookID string,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
|
|
require.NoError(
|
|
t,
|
|
webhookDB.Model(&database.Delivery{}).
|
|
Count(&count).Error,
|
|
)
|
|
|
|
return count
|
|
}
|
|
|
|
// storedHash reads the current password hash for a username.
|
|
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
|
t.Helper()
|
|
|
|
var user database.User
|
|
|
|
require.NoError(t,
|
|
e.db.DB().Where("username = ?", username).
|
|
First(&user).Error,
|
|
)
|
|
|
|
return user.Password
|
|
}
|
|
|
|
// --- /s static group ---
|
|
|
|
// TestStaticServesOnlyGetAndHead pins the methods the static group
|
|
// answers: GET and HEAD are served the asset, and the other methods
|
|
// chi routes (POST, PUT, DELETE and the rest) are refused with 405
|
|
// and an Allow header naming those two. A method chi does not route,
|
|
// such as PROPFIND, is refused with 405 by the top-level router
|
|
// before it reaches the static group, so it gets no Allow header.
|
|
// The README documents this; the test is what keeps the two from
|
|
// drifting.
|
|
func TestStaticServesOnlyGetAndHead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
body, err := static.Static.ReadFile("js/app.js")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, body)
|
|
|
|
for _, method := range []string{
|
|
http.MethodGet,
|
|
http.MethodHead,
|
|
http.MethodPost,
|
|
http.MethodPut,
|
|
http.MethodDelete,
|
|
"PROPFIND",
|
|
} {
|
|
t.Run(method, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), method,
|
|
"/s/js/app.js", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
switch method {
|
|
case http.MethodGet:
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, body, w.Body.Bytes(),
|
|
"the asset itself is returned")
|
|
case http.MethodHead:
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Body.Bytes(),
|
|
"HEAD must not carry a body")
|
|
case "PROPFIND":
|
|
assert.Equal(
|
|
t, http.StatusMethodNotAllowed, w.Code,
|
|
)
|
|
assert.Empty(t, w.Header().Get("Allow"),
|
|
"chi refuses a method it does not route "+
|
|
"before the static group runs")
|
|
assert.NotContains(
|
|
t, w.Body.String(), string(body),
|
|
"a refused method must not get the asset",
|
|
)
|
|
default:
|
|
assert.Equal(
|
|
t, http.StatusMethodNotAllowed, w.Code,
|
|
)
|
|
assert.Equal(
|
|
t, "GET, HEAD", w.Header().Get("Allow"),
|
|
)
|
|
assert.NotContains(
|
|
t, w.Body.String(), string(body),
|
|
"a refused method must not get the asset",
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- /pages group ---
|
|
|
|
// TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs
|
|
// ahead of gorilla/csrf: the response is a clean 413 and no CSRF
|
|
// cookie was issued, so neither the CSRF middleware nor the login
|
|
// handler ran.
|
|
func TestPagesLogin_OversizeBody_RejectedBeforeCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
form := url.Values{}
|
|
form.Set("username", oversizeValue())
|
|
form.Set("password", "irrelevant")
|
|
|
|
w := env.post("/pages/login", form, nil)
|
|
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
)
|
|
assert.False(
|
|
t, csrfCookieSet(w),
|
|
"CSRF middleware must not run for an oversized body",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_UnderLimit_NoToken_CSRFRejects is the control for
|
|
// the test above: an identically shaped but under-limit POST does
|
|
// reach gorilla/csrf, which rejects it and issues its cookie. Without
|
|
// this, the missing-cookie assertion above would prove nothing.
|
|
func TestPagesLogin_UnderLimit_NoToken_CSRFRejects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
form := url.Values{}
|
|
form.Set("username", "someone")
|
|
form.Set("password", "irrelevant")
|
|
|
|
w := env.post("/pages/login", form, nil)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.True(
|
|
t, csrfCookieSet(w),
|
|
"CSRF middleware should run for an under-limit body",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_UnderLimit_ValidToken_ReachesHandler proves the
|
|
// reorder did not break CSRF token handling: a token harvested from
|
|
// the rendered login form is still accepted and the request lands in
|
|
// the handler.
|
|
func TestPagesLogin_UnderLimit_ValidToken_ReachesHandler(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", "nosuchuser")
|
|
form.Set("password", "wrongpassword")
|
|
|
|
w := env.post("/pages/login", form, cookies)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
assert.Contains(
|
|
t, w.Body.String(), "Invalid username or password",
|
|
"request should reach the login handler",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_CorrectPasswordSurvivesASpentBudget pins the
|
|
// routing half of the fix, which every other login test misses by
|
|
// driving the handler directly: no pre-emptive limiter sits in front
|
|
// of POST /pages/login on the real route tree.
|
|
//
|
|
// A limiter registered there would answer the last request 429
|
|
// however correct its password is, because the wrong passwords
|
|
// before it have already spent the bucket — which is the lockout
|
|
// this endpoint exists to not have. CSRF and the body cap still run,
|
|
// since every request here carries a harvested token.
|
|
func TestPagesLogin_CorrectPasswordSurvivesASpentBudget(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "operator"
|
|
password = "correct-horse-battery-staple"
|
|
)
|
|
|
|
env := newTestEnv(t)
|
|
env.seedUser(t, username, password)
|
|
|
|
submit := func(t *testing.T, pw string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", username)
|
|
form.Set("password", pw)
|
|
|
|
return env.post("/pages/login", form, cookies)
|
|
}
|
|
|
|
// Spend the failure budget against this username. The exact
|
|
// limit belongs to the middleware; this waits for the throttle
|
|
// to appear rather than restating it, under a ceiling well
|
|
// above it so a broken limiter fails the test instead of
|
|
// looping.
|
|
const maxAttempts = 20
|
|
|
|
spent := false
|
|
|
|
for range maxAttempts {
|
|
code := submit(t, "wrong").Code
|
|
if code == http.StatusTooManyRequests {
|
|
spent = true
|
|
|
|
break
|
|
}
|
|
|
|
require.Equal(
|
|
t, http.StatusUnauthorized, code,
|
|
"a wrong password must be rejected, not accepted",
|
|
)
|
|
}
|
|
|
|
require.True(
|
|
t, spent,
|
|
"repeated wrong passwords must eventually be throttled",
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusSeeOther, submit(t, password).Code,
|
|
"a correct password must be accepted on the routed "+
|
|
"endpoint even with the failure budget spent: the "+
|
|
"operator has no second administrative path",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_CookiesFromAnEarlierDatabase is
|
|
// https://git.eeqj.de/sneak/webhooker/issues/359. A new database
|
|
// brings a new session key, and the operator's browser still holds
|
|
// the session and CSRF cookies signed with the old one. Logging in
|
|
// must work as from a fresh browser and leave cookies the new key
|
|
// accepts.
|
|
func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "operator"
|
|
password = "correct-horse-battery-staple"
|
|
)
|
|
|
|
earlier := newTestEnv(t)
|
|
earlierID, _ := earlier.seedUser(t, username, password)
|
|
_, stale := earlier.csrfFrom(t, "/pages/login", nil)
|
|
stale = append(stale, earlier.authCookies(t, earlierID, username)...)
|
|
|
|
env := newTestEnv(t)
|
|
env.seedUser(t, username, password)
|
|
|
|
token, cookies := env.csrfFrom(t, "/pages/login", stale)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", username)
|
|
form.Set("password", password)
|
|
|
|
w := env.post("/pages/login", form, cookies)
|
|
require.Equal(
|
|
t, http.StatusSeeOther, w.Code,
|
|
"a session cookie from another key must not fail the login",
|
|
)
|
|
|
|
// The response deletes the old session cookie and then sets the
|
|
// new one; a browser keeps the last.
|
|
var fresh *http.Cookie
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == session.SessionName {
|
|
fresh = c
|
|
}
|
|
}
|
|
|
|
require.NotNil(t, fresh, "login must set a session cookie")
|
|
assert.Equal(
|
|
t, "/hooks",
|
|
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
|
"the new session cookie must authenticate",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_ReturnsToTheRequestedPage is
|
|
// https://git.eeqj.de/sneak/webhooker/issues/384: a page opened while
|
|
// logged out leads to the login page, and logging in from there lands
|
|
// on that page, query included.
|
|
func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "operator"
|
|
password = "correct-horse-battery-staple"
|
|
)
|
|
|
|
env := newTestEnv(t)
|
|
userID, _ := env.seedUser(t, username, password)
|
|
asked := "/hook/" + env.seedWebhook(t, userID).ID + "/events?page=2"
|
|
|
|
bounced := env.get(asked, nil)
|
|
require.Equal(t, http.StatusSeeOther, bounced.Code)
|
|
|
|
loginPage := bounced.Header().Get("Location")
|
|
|
|
match := regexp.MustCompile(`name="next" value="([^"]*)"`).
|
|
FindStringSubmatch(env.get(loginPage, nil).Body.String())
|
|
require.Len(t, match, 2, "the login form must carry the page")
|
|
|
|
token, cookies := env.csrfFrom(t, loginPage, nil)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", username)
|
|
form.Set("password", password)
|
|
form.Set("next", html.UnescapeString(match[1]))
|
|
|
|
w := env.post("/pages/login", form, cookies)
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, asked, w.Header().Get("Location"))
|
|
}
|
|
|
|
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
|
|
// lands on the sign-in page, which says so.
|
|
func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, _ := env.seedUser(t, "leaver", "somepassword")
|
|
token, cookies := env.csrfFrom(
|
|
t, "/hooks", env.authCookies(t, userID, "leaver"),
|
|
)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
|
|
w := env.post(
|
|
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
|
|
form, cookies,
|
|
)
|
|
|
|
// The sign-in page is requested without the session cookie, which
|
|
// the logout told the browser to delete.
|
|
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
|
}
|
|
|
|
// --- /user/{username} group ---
|
|
|
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
|
// covers the route that previously had no middleware body cap at
|
|
// all. The request carries a valid session and a valid CSRF token,
|
|
// so the only thing that can stop it is the size cap; the unchanged
|
|
// password hash is the observable proof the handler never ran.
|
|
func TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, originalHash := env.seedUser(t, "pwuser", "oldpassword")
|
|
cookies := env.authCookies(t, userID, "pwuser")
|
|
token, cookies := env.csrfFrom(t, "/user/pwuser/", cookies)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("current_password", "oldpassword")
|
|
form.Set("new_password", oversizeValue())
|
|
form.Set("confirm_password", oversizeValue())
|
|
|
|
w := env.post("/user/pwuser/password", form, cookies)
|
|
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
)
|
|
assert.Equal(
|
|
t, originalHash, env.storedHash(t, "pwuser"),
|
|
"handler must not run, so the password must be unchanged",
|
|
)
|
|
}
|
|
|
|
// TestPasswordChange_UnderLimit_Succeeds proves that adding the cap
|
|
// to the /user/{username} group did not break the route it guards.
|
|
func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, originalHash := env.seedUser(t, "okuser", "oldpassword")
|
|
cookies := env.authCookies(t, userID, "okuser")
|
|
token, cookies := env.csrfFrom(t, "/user/okuser/", cookies)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("current_password", "oldpassword")
|
|
form.Set("new_password", "brandnewpassword")
|
|
form.Set("confirm_password", "brandnewpassword")
|
|
|
|
w := env.post("/user/okuser/password", form, cookies)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.NotEqual(
|
|
t, originalHash, env.storedHash(t, "okuser"),
|
|
"an under-limit password change should still apply",
|
|
)
|
|
}
|
|
|
|
// --- /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,
|
|
)
|
|
env.requireNotice(
|
|
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
|
|
cookies,
|
|
)
|
|
}
|
|
|
|
// --- /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,
|
|
)
|
|
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
|
|
|
|
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,
|
|
)
|
|
env.requireNotice(
|
|
t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusNotFound, env.get(page, cookies).Code,
|
|
"a deleted webhook's page should be gone",
|
|
)
|
|
}
|
|
|
|
// TestHook_EntrypointActions adds, deactivates, activates 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 with the
|
|
// notice code, and the page to show text.
|
|
submit := func(pattern, code, text string) {
|
|
t.Helper()
|
|
|
|
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
|
env.requireNotice(t, w, page, code, text, cookies)
|
|
}
|
|
|
|
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
|
|
|
|
submit(`action="(/hook/[^/"]+/entrypoints)"`,
|
|
"entrypoint-added", "Entrypoint added.")
|
|
|
|
var added database.Entrypoint
|
|
|
|
require.NoError(t,
|
|
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
|
|
)
|
|
require.True(t, added.Active)
|
|
|
|
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
|
|
|
|
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(toggle, "entrypoint-activated", "Entrypoint activated.")
|
|
|
|
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
|
|
"entrypoint-deleted", "Entrypoint deleted.")
|
|
|
|
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, activates 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 with the notice code, and that page to show
|
|
// text.
|
|
submit := func(from, pattern string, form url.Values, code, text string) {
|
|
t.Helper()
|
|
|
|
form.Set("csrf_token", token)
|
|
|
|
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
|
env.requireNotice(t, w, page, code, text, cookies)
|
|
}
|
|
|
|
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
|
|
|
|
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
|
"name": {"added"},
|
|
"type": {string(database.TargetTypeLog)},
|
|
}, "target-added", "Target added.")
|
|
|
|
editPage := env.urlFrom(
|
|
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
|
)
|
|
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
|
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
|
|
|
|
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, toggle, url.Values{},
|
|
"target-deactivated", "Target deactivated.")
|
|
|
|
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, toggle, url.Values{},
|
|
"target-activated", "Target activated.")
|
|
|
|
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
|
url.Values{}, "target-deleted", "Target deleted.")
|
|
|
|
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,
|
|
)
|
|
env.requireNotice(
|
|
t, w, logsPath, "resubmit-no-targets",
|
|
"this source has no active targets", cookies,
|
|
)
|
|
|
|
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="([^"]+)"[^>]*>← 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 →<`, events + "?page=2"},
|
|
{events + "?page=2", `href="([^"]+)"[^>]*>← 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
|
|
// the production router, take the download URL out of the markup
|
|
// the template emitted, and fetch that URL through the router
|
|
// again. Nothing here is hand-written, so a typo in either the
|
|
// route pattern or the template href fails this test — the
|
|
// handler-level tests cannot catch that, because they forge
|
|
// their own route context and assert a URL string they wrote
|
|
// themselves.
|
|
func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, _ := env.seedUser(t, "loguser", "somepassword")
|
|
cookies := env.authCookies(t, userID, "loguser")
|
|
|
|
// Comfortably over the event log page's render cap, so the
|
|
// page truncates the body and renders the download link at
|
|
// all. The exact cap is the handlers package's business and
|
|
// is pinned by its own tests; this only needs to exceed it.
|
|
stored := strings.Repeat("Z", 64*1024)
|
|
|
|
wh := env.seedWebhook(t, userID)
|
|
env.seedEvent(t, wh.ID, stored)
|
|
|
|
page := env.get("/hook/"+wh.ID+"/events", cookies)
|
|
require.Equal(t, http.StatusOK, page.Code)
|
|
|
|
link := regexp.MustCompile(
|
|
`href="(/hook/[^"]+/body)"`,
|
|
).FindStringSubmatch(page.Body.String())
|
|
require.Len(
|
|
t, link, 2,
|
|
"truncated body should render a download link",
|
|
)
|
|
|
|
w := env.get(html.UnescapeString(link[1]), cookies)
|
|
|
|
require.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"the link the page emits must be a live route",
|
|
)
|
|
assert.Equal(t, stored, w.Body.String())
|
|
assert.Equal(
|
|
t, strconv.Itoa(len(stored)),
|
|
w.Header().Get("Content-Length"),
|
|
)
|
|
assert.Equal(
|
|
t, "application/octet-stream",
|
|
w.Header().Get("Content-Type"),
|
|
)
|
|
assert.Contains(
|
|
t, w.Header().Get("Content-Disposition"), "attachment",
|
|
)
|
|
assert.Equal(
|
|
t, "nosniff", w.Header().Get("X-Content-Type-Options"),
|
|
)
|
|
}
|
|
|
|
// TestSourceLogsBody_OtherUser404s pins that the download route
|
|
// as registered is behind the auth the group provides and the
|
|
// ownership check the handler applies: another logged-in user
|
|
// asking the real router for the same URL gets a 404, and an
|
|
// unauthenticated request never reaches the handler at all.
|
|
func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
|
wh := env.seedWebhook(t, ownerID)
|
|
|
|
const payload = "OWNERS-PAYLOAD-77c1"
|
|
|
|
evt := env.seedEvent(t, wh.ID, payload)
|
|
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
|
|
|
|
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
|
intruder := env.authCookies(t, intruderID, "intruder")
|
|
|
|
w := env.get(path, intruder)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
assert.NotContains(t, w.Body.String(), payload)
|
|
|
|
anon := env.get(path, nil)
|
|
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
|
assert.Equal(
|
|
t, "/pages/login?next="+url.QueryEscape(path),
|
|
anon.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
|
// through the production router rather than a forged route context,
|
|
// which is the only way to prove what the route group actually gives
|
|
// it: a GET cannot trigger a replay, an unauthenticated request never
|
|
// reaches the handler, a POST without the token is refused by CSRF,
|
|
// and the form the template emits — token and action URL both — works
|
|
// as rendered.
|
|
func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, _ := env.seedUser(t, "replayer", "somepassword")
|
|
cookies := env.authCookies(t, userID, "replayer")
|
|
|
|
wh := env.seedWebhook(t, userID)
|
|
tgt := env.seedTarget(t, wh.ID)
|
|
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
|
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
|
|
|
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
|
|
"/replay"
|
|
|
|
assert.Equal(
|
|
t, http.StatusMethodNotAllowed,
|
|
env.get(path, cookies).Code,
|
|
"a replay must not be reachable by GET",
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusForbidden,
|
|
env.post(path, url.Values{}, cookies).Code,
|
|
"a replay POST without a CSRF token must be refused",
|
|
)
|
|
|
|
anon := env.post(path, url.Values{}, nil)
|
|
assert.Equal(t, http.StatusForbidden, anon.Code)
|
|
|
|
require.Equal(
|
|
t, int64(1), env.countDeliveries(t, wh.ID),
|
|
"no refused request may have created a delivery",
|
|
)
|
|
|
|
// 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 := "/hook/" + wh.ID + "/events"
|
|
|
|
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
|
|
|
page := env.get(logsPath, cookies)
|
|
require.Equal(t, http.StatusOK, page.Code)
|
|
|
|
action := regexp.MustCompile(
|
|
`action="(/hook/[^"]+/replay)"`,
|
|
).FindStringSubmatch(page.Body.String())
|
|
require.Len(
|
|
t, action, 2,
|
|
"a finished delivery should render a replay form",
|
|
)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
|
|
w := env.post(
|
|
html.UnescapeString(action[1]), form, cookies,
|
|
)
|
|
|
|
env.requireNotice(
|
|
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
|
|
)
|
|
assert.Equal(
|
|
t, int64(2), env.countDeliveries(t, wh.ID),
|
|
"the replay appends a delivery",
|
|
)
|
|
}
|
|
|
|
// --- /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(
|
|
t *testing.T, username, password string,
|
|
) *config.Config {
|
|
t.Helper()
|
|
|
|
return &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
MetricsUsername: username,
|
|
MetricsPassword: password,
|
|
}
|
|
}
|
|
|
|
// metricsRequest asks the real router for /metrics with the given
|
|
// basic-auth credentials, or with no Authorization header when
|
|
// username is empty.
|
|
func (e *testEnv) metricsRequest(
|
|
username, password string,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/metrics", nil,
|
|
)
|
|
|
|
if username != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// TestMetricsRouteUnmountedWithoutCredentials pins that with neither
|
|
// credential configured the route does not exist, which is the
|
|
// documented behaviour and the only valid way for /metrics to be
|
|
// absent.
|
|
func TestMetricsRouteUnmountedWithoutCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithConfig(t, metricsConfig(t, "", ""))
|
|
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
env.metricsRequest("", "").Code,
|
|
)
|
|
}
|
|
|
|
// TestMetricsRouteRequiresCredentials pins that with both credentials
|
|
// configured the route exists and every request that does not carry
|
|
// the configured pair is refused — including the empty password that
|
|
// a half-set configuration used to make sufficient.
|
|
func TestMetricsRouteRequiresCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithConfig(
|
|
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest("", "").Code,
|
|
"no credentials must not reach the metrics handler",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest(metricsUser, "").Code,
|
|
"an empty password must not reach the metrics handler",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest(metricsUser, "wrong").Code,
|
|
)
|
|
|
|
ok := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
assert.Equal(t, http.StatusOK, ok.Code)
|
|
assert.Contains(t, ok.Body.String(), "go_goroutines")
|
|
}
|
|
|
|
// TestMetricsRouteUnmountedOnHalfSetConfig pins the defect from
|
|
// https://git.eeqj.de/sneak/webhooker/issues/205 at the routing
|
|
// layer. Config rejects a half-set pair at startup, so this Config
|
|
// cannot be reached from the environment; the assertion is that the
|
|
// route tree does not publish an endpoint accepting an empty
|
|
// password even when handed one anyway, because the mount and the
|
|
// startup log's hasMetricsAuth read the same value.
|
|
func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
username string
|
|
password string
|
|
}{
|
|
{name: "username only", username: metricsUser},
|
|
{name: "password only", password: metricsAuthValue},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := metricsConfig(t, tc.username, tc.password)
|
|
env := newTestEnvWithConfig(t, cfg)
|
|
|
|
assert.False(t, cfg.MetricsAuthEnabled())
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
env.metricsRequest(
|
|
tc.username, tc.password,
|
|
).Code,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTwoMetricsRoutersInOneProcess pins
|
|
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
|
|
// metrics-enabled router in one process used to panic, because the
|
|
// HTTP metrics registered on Prometheus's global default registry.
|
|
// Two routers are built over separate dependency graphs and a third
|
|
// over the first graph again, and each must still serve the HTTP,
|
|
// delivery, Go runtime and process series, and the series counting
|
|
// scrapes of /metrics itself.
|
|
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := newTestEnvWithConfig(
|
|
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
)
|
|
second := newTestEnvWithConfig(
|
|
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
)
|
|
third := &testEnv{
|
|
router: server.NewRouterForTest(
|
|
first.log.Get(), first.cfg, first.mw, first.hnd,
|
|
),
|
|
}
|
|
|
|
for _, env := range []*testEnv{first, second, third} {
|
|
env.get("/", nil)
|
|
|
|
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
require.Equal(t, http.StatusOK, scrape.Code)
|
|
|
|
for _, series := range []string{
|
|
"http_request_duration_seconds",
|
|
"http_response_size_bytes",
|
|
"http_requests_inflight",
|
|
"webhooker_events_received_total",
|
|
"go_goroutines",
|
|
"process_start_time_seconds",
|
|
"promhttp_metric_handler_requests_total",
|
|
} {
|
|
assert.Contains(t, scrape.Body.String(), series)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMetricsScrapeBeforeAnyDelivery pins
|
|
// https://git.eeqj.de/sneak/webhooker/issues/267: an instance that
|
|
// has delivered nothing must still serve the delivery duration
|
|
// histogram, at zero, for every target type.
|
|
func TestMetricsScrapeBeforeAnyDelivery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithConfig(
|
|
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
)
|
|
|
|
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
require.Equal(t, http.StatusOK, scrape.Code)
|
|
|
|
for _, targetType := range []database.TargetType{
|
|
database.TargetTypeHTTP,
|
|
database.TargetTypeDatabase,
|
|
database.TargetTypeLog,
|
|
database.TargetTypeSlack,
|
|
} {
|
|
assert.Contains(
|
|
t, scrape.Body.String(),
|
|
`webhooker_delivery_duration_seconds_count{target_type="`+
|
|
string(targetType)+`"} 0`,
|
|
)
|
|
}
|
|
}
|