Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestHook_DownloadArchive follows the Download link the webhook page
|
||||
// shows for a database target, and only for it, and gets the archive
|
||||
// as a gzipped JSON file. Signed out, the link leads to the login page.
|
||||
func TestHook_DownloadArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "archivist", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "archivist")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
env.seedTarget(t, wh.ID)
|
||||
|
||||
archive := &database.Target{
|
||||
WebhookID: wh.ID,
|
||||
Name: "kept",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(t,
|
||||
env.db.DB().Omit(clause.Associations).Create(archive).Error,
|
||||
)
|
||||
|
||||
page := env.get("/hook/"+wh.ID, cookies)
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
links := regexp.MustCompile(
|
||||
`href="(/hook/[^/"]+/targets/[^/"]+/download)"`,
|
||||
).FindAllStringSubmatch(page.Body.String(), -1)
|
||||
require.Len(t, links, 1, "only the database target has a Download")
|
||||
|
||||
link := links[0][1]
|
||||
assert.Equal(t,
|
||||
"/hook/"+wh.ID+"/targets/"+archive.ID+"/download", link,
|
||||
)
|
||||
|
||||
w := env.get(link, cookies)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
||||
|
||||
zr, err := gzip.NewReader(w.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
|
||||
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
||||
assert.JSONEq(t,
|
||||
`{"id":"`+wh.ID+`","name":"routed"}`, string(got["webhook"]),
|
||||
)
|
||||
|
||||
w = env.get(link, nil)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Location"), "/pages/login")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +26,11 @@ const (
|
||||
// its network namespace even with a published port.
|
||||
wildcardV4 = "0.0.0.0"
|
||||
|
||||
// loopbackV6 and wildcardV6 are the IPv6 counterparts of the two
|
||||
// above.
|
||||
loopbackV6 = "::1"
|
||||
wildcardV6 = "::"
|
||||
|
||||
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
||||
// well-formed literal that no host is assigned, so binding it
|
||||
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
||||
@@ -116,6 +122,24 @@ func otherLocalAddr(t *testing.T) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// skipWithoutIPv6 skips the test when this host cannot bind
|
||||
// loopbackV6. Without IPv6 there is no second family for a bind to
|
||||
// claim or leave alone.
|
||||
func skipWithoutIPv6(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
var listenCfg net.ListenConfig
|
||||
|
||||
l, err := listenCfg.Listen(
|
||||
t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skip("host has no IPv6 loopback to bind")
|
||||
}
|
||||
|
||||
require.NoError(t, l.Close())
|
||||
}
|
||||
|
||||
// startBoundServer starts the wired app with the given bind address
|
||||
// on a free port and returns that port. The app is stopped on
|
||||
// cleanup.
|
||||
@@ -292,6 +316,59 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) {
|
||||
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
||||
}
|
||||
|
||||
// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on
|
||||
// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or
|
||||
// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a
|
||||
// socket that answers IPv6 too unless told otherwise, which put the
|
||||
// admin UI and the receiver on every IPv6 address of a host whose
|
||||
// operator had chosen an IPv4 bind.
|
||||
func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
skipWithoutIPv6(t)
|
||||
|
||||
for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} {
|
||||
t.Run(bindAddress, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
port := startBoundServer(t, bindAddress)
|
||||
|
||||
// Positive control: the service really is up, on IPv4.
|
||||
requireDialable(
|
||||
t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)),
|
||||
)
|
||||
|
||||
dialer := net.Dialer{Timeout: dialTimeout}
|
||||
|
||||
conn, err := dialer.DialContext(
|
||||
t.Context(), "tcp",
|
||||
net.JoinHostPort(loopbackV6, strconv.Itoa(port)),
|
||||
)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
require.ErrorIs(
|
||||
t, err, syscall.ECONNREFUSED,
|
||||
"a %s bind answered on %s", bindAddress, loopbackV6,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart:
|
||||
// :: is the value that binds IPv6, and it answers IPv4 as well.
|
||||
func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
skipWithoutIPv6(t)
|
||||
|
||||
port := startBoundServer(t, wildcardV6)
|
||||
|
||||
requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port)))
|
||||
requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)))
|
||||
}
|
||||
|
||||
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
||||
// listener serves the application rather than merely accepting TCP,
|
||||
// so a bind address that is honoured cannot be mistaken for one that
|
||||
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// earlyStopIterations is how many start/stop cycles the race test
|
||||
// earlyStopIterations is how many start/stop cycles the test below
|
||||
// runs. The window it aims at is the gap between the OnStart hook
|
||||
// returning and the serving goroutine reaching its first field
|
||||
// access, which is microseconds wide. The race detector reports an
|
||||
// unsynchronised pair whenever it observes one, but it has to observe
|
||||
// one, so a single cycle can miss purely on scheduling. Repetition
|
||||
// makes the observation reliable; the collaborators are built once,
|
||||
// so the cycles themselves are cheap.
|
||||
// access, which is microseconds wide, so a single cycle can miss it
|
||||
// purely on scheduling. Repetition makes hitting it reliable; the
|
||||
// collaborators are built once, so the cycles themselves are cheap.
|
||||
const earlyStopIterations = 25
|
||||
|
||||
// TestEarlyShutdown_NoPanicAndNoRace stops the application
|
||||
@@ -38,8 +36,9 @@ const earlyStopIterations = 25
|
||||
//
|
||||
// httpServer is now built in New, on the constructing goroutine, so
|
||||
// it is written before any hook exists and can never be nil.
|
||||
// sentryEnabled is atomic. This test is what catches either one
|
||||
// coming back — under -race, which is how the suite runs.
|
||||
// sentryEnabled is atomic. This test catches the nil dereference
|
||||
// coming back, as a panic. The suite runs without Go's race
|
||||
// detector, so the data race coming back would go unreported here.
|
||||
func TestEarlyShutdown_NoPanicAndNoRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// The link back the error page offers: to the webhook list for a
|
||||
// signed-in user, to sign-in for anyone else.
|
||||
const (
|
||||
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
|
||||
`Back to webhooks</a>`
|
||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
||||
`Sign in</a>`
|
||||
)
|
||||
|
||||
// assertErrorPage checks that w is the error page for status, in the
|
||||
// normal layout, offering link.
|
||||
func assertErrorPage(
|
||||
t *testing.T,
|
||||
w *httptest.ResponseRecorder,
|
||||
status int,
|
||||
link string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Equal(t, status, w.Code)
|
||||
assert.Equal(
|
||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
||||
)
|
||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
||||
assert.Contains(
|
||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
||||
)
|
||||
assert.Contains(t, body, link)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
||||
|
||||
w := env.get("/hook/"+wh.ID, cookies)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
tgt := env.seedTarget(t, wh.ID)
|
||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
||||
|
||||
w := env.get(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
||||
)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
|
||||
// page that fails is not shown above the error.
|
||||
func TestErrorPage_ShowsNoNotice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.NotContains(t, w.Body.String(), "Webhook saved.")
|
||||
}
|
||||
|
||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.get("/no-such-page", nil),
|
||||
http.StatusNotFound, backToSignIn,
|
||||
)
|
||||
|
||||
// Outside every route group there is no form token, so the
|
||||
// page leaves out the logout form rather than offer one that
|
||||
// would be refused.
|
||||
w := env.get("/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
|
||||
// Inside a route group the page has a token, and logout works.
|
||||
wh := env.seedWebhook(t, userID)
|
||||
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
}
|
||||
|
||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("username", "someone")
|
||||
form.Set("password", "irrelevant")
|
||||
form.Set("csrf_token", "not-a-token")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/pages/login", form, nil),
|
||||
http.StatusForbidden, backToSignIn,
|
||||
)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
|
||||
edit := url.Values{}
|
||||
edit.Set("name", "renamed")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
|
||||
http.StatusForbidden, backToWebhooks,
|
||||
)
|
||||
}
|
||||
|
||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
||||
// admin page route group through the real router, with error
|
||||
// tracking on: the client gets the 500 error page, and the tracker
|
||||
// still gets the panic, once. The same panic outside the admin page
|
||||
// route groups keeps the plain 500.
|
||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
transport := &captureTransport{}
|
||||
|
||||
opts := server.SentryClientOptionsForTest(
|
||||
"https://public@sentry.invalid/1", "webhooker-test",
|
||||
)
|
||||
opts.Transport = transport
|
||||
|
||||
client, err := sentry.NewClient(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
sentry.SetHubOnContext(
|
||||
context.Background(),
|
||||
sentry.NewHub(client, sentry.NewScope()),
|
||||
),
|
||||
http.MethodGet, path, nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
w := serve(
|
||||
server.NewRouterWithPageProbeForTest(
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.PageProbePattern,
|
||||
)
|
||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
||||
|
||||
w = serve(
|
||||
server.NewRouterWithProbeForTest(
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.ProbePattern,
|
||||
)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
|
||||
require.Len(t, transport.events, 2)
|
||||
|
||||
for _, event := range transport.events {
|
||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
||||
// the web UI only: a sender posting to an entrypoint that does not
|
||||
// exist still gets the plain-text answer.
|
||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// newTestEnv leaves the receiver rate limit at zero, which
|
||||
// refuses every request before it reaches the receiver.
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: 10,
|
||||
})
|
||||
|
||||
w := env.post(
|
||||
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
||||
}
|
||||
+104
-18
@@ -1,12 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -33,23 +39,71 @@ func SentryClientOptionsForTest(
|
||||
return sentryClientOptions(dsn, release)
|
||||
}
|
||||
|
||||
// CleanShutdownForTest runs the server's stop hook, cleanShutdown,
|
||||
// against hs: a server the test started itself, so it can hold a
|
||||
// request open across the drain. Sentry is off.
|
||||
func CleanShutdownForTest(ctx context.Context, hs *http.Server) {
|
||||
s := &Server{log: slog.New(slog.DiscardHandler), httpServer: hs}
|
||||
s.cleanShutdown(ctx)
|
||||
}
|
||||
|
||||
// newServerForTest builds a Server through New, as the application
|
||||
// does, on a lifecycle that is never started: the hooks New adds to
|
||||
// it never run, so nothing listens.
|
||||
func newServerForTest(
|
||||
t *testing.T,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
) *Server {
|
||||
t.Helper()
|
||||
|
||||
s, err := New(fxtest.NewLifecycle(t), ServerParams{
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
Middleware: mw,
|
||||
Handlers: h,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// NewRouterForTest builds the real route tree via SetupRoutes with
|
||||
// the supplied middleware and handlers, bypassing the fx lifecycle
|
||||
// and the HTTP listener. Tests use it so that route-group middleware
|
||||
// registration order is exercised exactly as it ships, rather than
|
||||
// against a hand-rebuilt chain that could drift from routes.go.
|
||||
// the supplied middleware and handlers, on a Server from New whose
|
||||
// lifecycle is never started, so no HTTP listener runs. Tests use it
|
||||
// so that route-group middleware registration order is exercised
|
||||
// exactly as it ships, rather than against a hand-rebuilt chain that
|
||||
// could drift from routes.go.
|
||||
func NewRouterForTest(
|
||||
log *slog.Logger,
|
||||
t *testing.T,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
) http.Handler {
|
||||
s := &Server{
|
||||
log: log,
|
||||
mw: mw,
|
||||
h: h,
|
||||
params: ServerParams{Config: cfg},
|
||||
}
|
||||
t.Helper()
|
||||
|
||||
s := newServerForTest(t, log, cfg, mw, h)
|
||||
s.SetupRoutes()
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
// NewRouterWithSentryForTest is NewRouterForTest with the sentryhttp
|
||||
// handler registered, as a set SENTRY_DSN does in production.
|
||||
func NewRouterWithSentryForTest(
|
||||
t *testing.T,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
s := newServerForTest(t, log, cfg, mw, h)
|
||||
s.sentryEnabled.Store(true)
|
||||
s.SetupRoutes()
|
||||
|
||||
return s.router
|
||||
@@ -82,22 +136,54 @@ const ProbePattern = "/probe"
|
||||
// option and the recoverer registered outside it is the thing a test
|
||||
// has to be able to pin.
|
||||
func NewRouterWithProbeForTest(
|
||||
log *slog.Logger,
|
||||
t *testing.T,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
sentryEnabled bool,
|
||||
probe http.HandlerFunc,
|
||||
) http.Handler {
|
||||
s := &Server{
|
||||
log: log,
|
||||
mw: mw,
|
||||
h: h,
|
||||
params: ServerParams{Config: cfg},
|
||||
}
|
||||
t.Helper()
|
||||
|
||||
s := newServerForTest(t, log, cfg, mw, h)
|
||||
s.sentryEnabled.Store(sentryEnabled)
|
||||
s.SetupRoutes()
|
||||
s.router.Handle(ProbePattern, probe)
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
||||
// probe: inside the /pages route group, the admin page group a
|
||||
// request reaches without signing in.
|
||||
const PageProbePattern = "/pages/probe"
|
||||
|
||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
||||
// probe added to the /pages route group once SetupRoutes has built
|
||||
// it, so the probe runs behind that group's own middleware exactly as
|
||||
// the group's real routes do.
|
||||
func NewRouterWithPageProbeForTest(
|
||||
t *testing.T,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
sentryEnabled bool,
|
||||
probe http.HandlerFunc,
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
s := newServerForTest(t, log, cfg, mw, h)
|
||||
s.sentryEnabled.Store(sentryEnabled)
|
||||
s.SetupRoutes()
|
||||
|
||||
for _, route := range s.router.Routes() {
|
||||
pages, ok := route.SubRoutes.(chi.Router)
|
||||
if ok && route.Pattern == "/pages/*" {
|
||||
pages.Get("/probe", probe)
|
||||
}
|
||||
}
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
+30
-1
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
@@ -39,6 +41,23 @@ func (s *Server) listenAddr() string {
|
||||
)
|
||||
}
|
||||
|
||||
// listenNetwork returns the network the HTTP listener binds on, "tcp4"
|
||||
// for an IPv4 BindAddress and "tcp" for any other.
|
||||
//
|
||||
// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well,
|
||||
// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what
|
||||
// the README says it means. :: stays on "tcp" and answers both. An
|
||||
// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it
|
||||
// carries to Go's listener, so it gets "tcp4" too.
|
||||
func (s *Server) listenNetwork() string {
|
||||
addr, err := netip.ParseAddr(s.params.Config.BindAddress)
|
||||
if err == nil && addr.Unmap().Is4() {
|
||||
return "tcp4"
|
||||
}
|
||||
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
// newHTTPServer builds the HTTP server for this Server's
|
||||
// configuration.
|
||||
//
|
||||
@@ -67,7 +86,17 @@ func (s *Server) serveUntilShutdown() {
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
// ListenAndServe always listens on "tcp", which would undo
|
||||
// listenNetwork.
|
||||
var listenCfg net.ListenConfig
|
||||
|
||||
listener, err := listenCfg.Listen(
|
||||
context.Background(), s.listenNetwork(), s.httpServer.Addr,
|
||||
)
|
||||
if err == nil {
|
||||
err = s.httpServer.Serve(listener)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("listen error", "error", err)
|
||||
s.shutdownWithFailure()
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestPanicProbeChild(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
router := server.NewRouterWithProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
false, panicProbeHandler,
|
||||
)
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestSentryStillSeesAPanic(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
router := server.NewRouterWithProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// TestResponseControllerThroughProductionRouter sets a write deadline
|
||||
// and flushes through http.ResponseController, behind the shipped
|
||||
// router and over a real connection, and checks that both reach
|
||||
// net/http's own writer.
|
||||
//
|
||||
// Every middleware that wraps the writer has to let them through with
|
||||
// an Unwrap method. One that does not makes the call return
|
||||
// http.ErrNotSupported, or, if it has a Flush of its own that cannot
|
||||
// reach further in, makes the flush silently do nothing; either way
|
||||
// the handler that trips over it is far from the cause.
|
||||
//
|
||||
// It runs once with the defaults and once with metrics and Sentry on,
|
||||
// because those two add middleware to the chain, and through both the
|
||||
// global middleware and an admin page route group, which adds its own.
|
||||
func TestResponseControllerThroughProductionRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Without /metrics credentials, metricsConfig is the default
|
||||
// Config.
|
||||
cases := []struct {
|
||||
name string
|
||||
username, password string
|
||||
sentryEnabled bool
|
||||
}{
|
||||
{name: "defaults"},
|
||||
{
|
||||
name: "metrics and Sentry on",
|
||||
username: metricsUser, password: metricsAuthValue,
|
||||
sentryEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// The probe answers with what each call returned.
|
||||
probe := func(w http.ResponseWriter, _ *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
|
||||
deadlineErr := rc.SetWriteDeadline(time.Now().Add(time.Minute))
|
||||
flushErr := rc.Flush()
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
w, "deadline: %v, flush: %v", deadlineErr, flushErr,
|
||||
)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithConfig(
|
||||
t, metricsConfig(t, tc.username, tc.password),
|
||||
)
|
||||
|
||||
routers := map[string]http.Handler{
|
||||
server.ProbePattern: server.NewRouterWithProbeForTest(
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
tc.sentryEnabled, probe,
|
||||
),
|
||||
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
tc.sentryEnabled, probe,
|
||||
),
|
||||
}
|
||||
|
||||
for path, router := range routers {
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, srv.URL+path, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := srv.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.Equal(
|
||||
t, "deadline: <nil>, flush: <nil>", string(body), path,
|
||||
)
|
||||
|
||||
// A response the server holds until the handler returns
|
||||
// goes out with a Content-Length; one flushed while the
|
||||
// handler is still running goes out in chunks.
|
||||
assert.Equal(
|
||||
t, []string{"chunked"}, resp.TransferEncoding,
|
||||
"%s: the flush must reach the client", path,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
+107
-40
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||
// submission while preventing abuse from oversized payloads.
|
||||
//
|
||||
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||
// orderings are deliberate.
|
||||
// The five admin page route groups below (/pages, /user/{username},
|
||||
// /settings, /hooks and /hook/{sourceID}) install
|
||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
||||
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
||||
// deliberate.
|
||||
//
|
||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||
// be installed before anything reads the body, or the parse runs
|
||||
@@ -46,12 +48,20 @@ const requestTimeout = 60 * time.Second
|
||||
// server's router.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// An unknown path gets the error page. Registered before the
|
||||
// global middleware, because chi wraps a not-found handler in the
|
||||
// middleware already on its router, which would then run twice.
|
||||
// The route groups below wrap it in their own middleware the same
|
||||
// way; running theirs twice is harmless.
|
||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||
|
||||
s.setupGlobalMiddleware()
|
||||
s.setupRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupGlobalMiddleware() {
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(chimw.RequestID)
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
@@ -64,28 +74,43 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
s.router.Use(s.mw.Timeout(requestTimeout))
|
||||
|
||||
// Panic recovery, deliberately here rather than first. It has to
|
||||
// run inside every middleware that observes the response, so the
|
||||
// 500 it writes is the status the access log records and the
|
||||
// metrics count, and outside the sentryhttp handler below, whose
|
||||
// metrics count, and outside the sentryhttp handler, whose
|
||||
// Repanic option needs something further out to catch what it
|
||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||
// until it was measured: on a current Go release it crashes
|
||||
// inside its stack pretty-printer instead of recovering, so the
|
||||
// connection dropped and the original panic was never reported.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||
s.router.Use(s.mw.Recoverer())
|
||||
s.recoverPanics(s.router, nil)
|
||||
}
|
||||
|
||||
// recoverPanics installs on r the recoverer, answering a panic with
|
||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||
// reporting and transaction naming (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the recoverer.
|
||||
//
|
||||
// Each admin page route group installs its own, with the error page,
|
||||
// as its first middleware. A panic there is logged, reported and
|
||||
// answered inside the group and never reaches the global recoverer,
|
||||
// which keeps the plain 500 for every other route.
|
||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
||||
r.Use(s.mw.Recoverer(page))
|
||||
|
||||
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the Recoverer middleware
|
||||
// registered immediately above.
|
||||
if s.sentryEnabled.Load() {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
r.Use(sentryHandler.Handle)
|
||||
// Installed with every sentryhttp handler rather than once
|
||||
// globally: in an admin page route group, the group's own
|
||||
// handler finishes the transaction as the group returns, so
|
||||
// a rename further out would come too late.
|
||||
r.Use(nameSentryTransaction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,35 +155,35 @@ func (s *Server) setupRoutes() {
|
||||
if s.params.Config.MetricsAuthEnabled() {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get(
|
||||
"/metrics",
|
||||
http.HandlerFunc(
|
||||
promhttp.Handler().ServeHTTP,
|
||||
),
|
||||
)
|
||||
r.Get("/metrics", s.h.HandleMetrics())
|
||||
})
|
||||
}
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSettingsRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupPageRoutes() {
|
||||
s.router.Route("/pages", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||
// unset, every client shares one bucket, so a limiter spent
|
||||
// on arrival lets any stranger deny the operator the only
|
||||
// administrative path. The handler verifies credentials first
|
||||
// and charges only failures; see Handlers.authenticateUser.
|
||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
||||
// does not cover it, every client shares one bucket, so a
|
||||
// limiter spent on arrival lets any stranger deny the operator
|
||||
// the only administrative path. The handler verifies
|
||||
// credentials first and charges only failures; see
|
||||
// Handlers.authenticateUser.
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
|
||||
@@ -168,10 +193,13 @@ func (s *Server) setupPageRoutes() {
|
||||
|
||||
func (s *Server) setupUserRoutes() {
|
||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
@@ -181,12 +209,33 @@ func (s *Server) setupUserRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/sources", func(r chi.Router) {
|
||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||
// configuration comes from the environment and nothing here changes
|
||||
// it.
|
||||
func (s *Server) setupSettingsRoutes() {
|
||||
s.router.Route("/settings", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/hooks", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceList())
|
||||
@@ -194,25 +243,29 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||
})
|
||||
|
||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceDetail())
|
||||
r.Get("/edit", s.h.HandleSourceEdit())
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
r.Post("/delete", s.h.HandleSourceDelete())
|
||||
r.Get("/logs", s.h.HandleSourceLogs())
|
||||
// The log page renders each body only up to its cap, so
|
||||
// this is the only route that serves a whole one. It
|
||||
// belongs to this group for its RequireAuth and
|
||||
// NoCache; see HandleEventBodyDownload for the headers
|
||||
// that keep the bytes it returns inert.
|
||||
r.Get("/events", s.h.HandleSourceLogs())
|
||||
r.Get("/events/{eventID}", s.h.HandleEventDetail())
|
||||
// The pages show a body as escaped text and leave a
|
||||
// binary one out, so this is the only route that serves
|
||||
// the stored bytes. It belongs to this group for its
|
||||
// RequireAuth and NoCache; see HandleEventBodyDownload for
|
||||
// the headers that keep the bytes it returns inert.
|
||||
r.Get(
|
||||
"/logs/{eventID}/body",
|
||||
"/events/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
)
|
||||
// Replay is the one page action that queues outbound work:
|
||||
@@ -243,6 +296,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/entrypoints",
|
||||
s.h.HandleEntrypointCreate(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/edit",
|
||||
s.h.HandleEntrypointEdit(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/delete",
|
||||
s.h.HandleEntrypointDelete(),
|
||||
@@ -254,7 +311,7 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/targets", s.h.HandleTargetCreate())
|
||||
// The edit form is the one page that renders a target's
|
||||
// destination URL and header values in full; see
|
||||
// delivery.TargetConfigForm. It belongs to this group for
|
||||
// Handlers.HandleTargetEdit. It belongs to this group for
|
||||
// its RequireAuth and NoCache, which are what keep that
|
||||
// exception from reaching an unauthenticated request or a
|
||||
// shared cache.
|
||||
@@ -266,6 +323,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/targets/{targetID}/edit",
|
||||
s.h.HandleTargetEditSubmit(),
|
||||
)
|
||||
r.Get(
|
||||
"/targets/{targetID}/download",
|
||||
s.h.HandleTargetDownload(),
|
||||
)
|
||||
r.Post(
|
||||
"/targets/{targetID}/delete",
|
||||
s.h.HandleTargetDelete(),
|
||||
@@ -278,8 +339,14 @@ func (s *Server) setupSourceRoutes() {
|
||||
}
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
// No MaxBodySize here, unlike the page groups. The receiver's 1 MB
|
||||
// body cap is in Handlers.readWebhookBody, because the handler
|
||||
// owns the response a sender gets for an oversized body and
|
||||
// MaxBodySize would change it. That cap is the only bound on this
|
||||
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
||||
// pins it.
|
||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
middleware.ReceiverRoute,
|
||||
s.h.HandleWebhook(),
|
||||
)
|
||||
}
|
||||
|
||||
+965
-32
File diff suppressed because it is too large
Load Diff
+76
-11
@@ -1,12 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/referer"
|
||||
)
|
||||
|
||||
// sentryRedacted stands in for a withheld field on every event shipped
|
||||
@@ -55,7 +57,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
||||
//
|
||||
// URL is the third such field. NewRequest builds it as
|
||||
// scheme://host/path (interfaces.go:183), and on the receiver route
|
||||
// that path is /webhook/<uuid> in full — a write capability, not an
|
||||
// that path is /h/<uuid> in full — a write capability, not an
|
||||
// identifier. It is rebuilt here from the chi route pattern, on every
|
||||
// route, keeping the scheme and the host.
|
||||
//
|
||||
@@ -71,10 +73,15 @@ func scrubSentryRequest(
|
||||
|
||||
pattern := sentryRoutePattern(hint)
|
||||
|
||||
// Only transaction events carry a Transaction name, and the SDK
|
||||
// Only transaction events carry a Transaction name. sentryhttp
|
||||
// builds it from the concrete path too (sentryhttp.go:105 via
|
||||
// tracing.go:553). Rewritten on the same terms.
|
||||
if event.Transaction != "" {
|
||||
// tracing.go:553) and marks it SourceURL, so it is rewritten on
|
||||
// the same terms. Only a name nameSentryTransaction set from the
|
||||
// route pattern, which it marks SourceRoute, is kept.
|
||||
namedByRoute := event.TransactionInfo != nil &&
|
||||
event.TransactionInfo.Source == sentry.SourceRoute
|
||||
|
||||
if event.Transaction != "" && !namedByRoute {
|
||||
event.Transaction = sentryTransactionName(
|
||||
event.Transaction, pattern,
|
||||
)
|
||||
@@ -120,7 +127,8 @@ func scrubSentryRequest(
|
||||
// The transaction dispatch has no such request: Span.doFinish calls
|
||||
// hub.CaptureEvent (tracing.go:356), which passes a nil hint that the
|
||||
// client replaces with an empty one (client.go:620-622). The pattern
|
||||
// is therefore always "" there, and the callers fall back.
|
||||
// is therefore always "" there, and the callers fall back. A routed
|
||||
// transaction is named by nameSentryTransaction instead.
|
||||
func sentryRoutePattern(hint *sentry.EventHint) string {
|
||||
if hint == nil || hint.Context == nil {
|
||||
return ""
|
||||
@@ -142,6 +150,52 @@ func sentryRoutePattern(hint *sentry.EventHint) string {
|
||||
return rctx.RoutePattern()
|
||||
}
|
||||
|
||||
// nameSentryTransaction renames the request's Sentry transaction to
|
||||
// "METHOD <route pattern>", such as "POST /h/{uuid}". sentryhttp names
|
||||
// it after the concrete path, which scrubSentryRequest withholds;
|
||||
// since Sentry groups transactions by name, every transaction in the
|
||||
// service would otherwise land in one group per method, such as
|
||||
// "POST /(redacted)".
|
||||
//
|
||||
// It has to run inside the sentryhttp handler, where the transaction
|
||||
// is on the request context. It renames on the way out, once chi has
|
||||
// routed the request, and does so in a defer so that a request whose
|
||||
// handler panics is named too. The context is taken when the defer is
|
||||
// set up, before routing, but the chi routing context on it is a
|
||||
// pointer that chi fills in as it routes.
|
||||
func nameSentryTransaction(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
defer setSentryTransactionName(r.Context(), r.Method)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// setSentryTransactionName does the rename for nameSentryTransaction.
|
||||
// A path that matches nothing at the top level has no pattern; its
|
||||
// name is left as the SDK set it, for scrubSentryRequest to withhold.
|
||||
// Inside a route group, a path that matches no route in the group, or
|
||||
// a request the group's own middleware turns away before routing, has
|
||||
// the group's pattern, such as "/pages/*", and is named after it.
|
||||
func setSentryTransactionName(ctx context.Context, method string) {
|
||||
transaction := sentry.TransactionFromContext(ctx)
|
||||
rctx := chi.RouteContext(ctx)
|
||||
|
||||
if transaction == nil || rctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pattern := rctx.RoutePattern()
|
||||
if pattern == "" {
|
||||
return
|
||||
}
|
||||
|
||||
transaction.Name = method + " " + pattern
|
||||
transaction.Source = sentry.SourceRoute
|
||||
}
|
||||
|
||||
// sentryRouteURL rebuilds an event's request URL with the route
|
||||
// pattern in place of the concrete path.
|
||||
//
|
||||
@@ -200,6 +254,7 @@ func sentryTransactionName(name, pattern string) string {
|
||||
// gorilla/csrf accepts in place of the form field — and the shared
|
||||
// secrets senders put on the receiver route (X-Gitlab-Token and the
|
||||
// per-provider signature headers) would otherwise ship verbatim.
|
||||
// The Referer it keeps is cut down to its scheme and host.
|
||||
func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return headers
|
||||
@@ -208,9 +263,15 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||
kept := make(map[string]string, len(headers))
|
||||
|
||||
for name, value := range headers {
|
||||
if sentryKeepsHeader(name) {
|
||||
kept[name] = value
|
||||
if !sentryKeepsHeader(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
if http.CanonicalHeaderKey(name) == "Referer" {
|
||||
value = referer.SchemeAndHost(value)
|
||||
}
|
||||
|
||||
kept[name] = value
|
||||
}
|
||||
|
||||
return kept
|
||||
@@ -218,10 +279,14 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||
|
||||
// sentryKeepsHeader reports whether a request header is routing or
|
||||
// content metadata rather than client-chosen payload. Referer is kept
|
||||
// on the reasoning that it is browser-set, that this service emits
|
||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||
// local access log line, which holds the rest of the detail.
|
||||
// only as its scheme and host, through referer.SchemeAndHost, which
|
||||
// the access log uses too: its path can be an entrypoint URL, which
|
||||
// carries the UUID, as when a sender follows a redirect from http://
|
||||
// to https:// or a browser fetches the 404 page's stylesheet from
|
||||
// under /h/. The scheme and host are all gorilla/csrf compares a
|
||||
// Referer by, so a CSRF rejection is still readable from the event.
|
||||
// X-Request-Id ties the event to the local access log line, which
|
||||
// holds the rest of the detail.
|
||||
func sentryKeepsHeader(name string) bool {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case "Accept",
|
||||
|
||||
+128
-14
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
@@ -130,11 +131,15 @@ func (c sentryCase) capture(t *testing.T) []*sentry.Event {
|
||||
// router mirrors the one ordering these tests depend on, over the two
|
||||
// route patterns they need: a recovering middleware outside, then the
|
||||
// sentryhttp handler registered with Use and Repanic set, exactly as
|
||||
// routes.go orders the two. The bare recover stands in for
|
||||
// Middleware.Recoverer, which holds that outer slot in production; it
|
||||
// is here only to keep panic stacks out of the test output. That the
|
||||
// production one really does catch what sentryhttp re-raises is
|
||||
// pinned separately, by TestSentryStillSeesAPanic.
|
||||
// routes.go orders the two. It leaves out nameSentryTransaction,
|
||||
// which routes.go installs inside the sentryhttp handler, so a
|
||||
// transaction captured here keeps the SDK's name and shows what the
|
||||
// hook does with a transaction that missed the rename. The bare
|
||||
// recover stands in for Middleware.Recoverer, which holds that outer
|
||||
// slot in production; it is here only to keep panic stacks out of the
|
||||
// test output. That the production one really does catch what
|
||||
// sentryhttp re-raises is pinned separately, by
|
||||
// TestSentryStillSeesAPanic.
|
||||
func (c sentryCase) router() http.Handler {
|
||||
handler := func(_ http.ResponseWriter, r *http.Request) {
|
||||
// This call is what drains the body tee and fills the
|
||||
@@ -153,7 +158,7 @@ func (c sentryCase) router() http.Handler {
|
||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||
)
|
||||
router.HandleFunc("/pages/login", handler)
|
||||
router.HandleFunc("/webhook/{uuid}", handler)
|
||||
router.HandleFunc("/h/{uuid}", handler)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -191,7 +196,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
||||
// concrete path carries the entrypoint capability.
|
||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||
return sentryRequest(
|
||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
||||
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,7 +321,38 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||
)
|
||||
assert.Equal(
|
||||
t, "http://example.com/webhook/{uuid}", event.Request.URL,
|
||||
t, "http://example.com/h/{uuid}", event.Request.URL,
|
||||
)
|
||||
}
|
||||
|
||||
// TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost sends a receiver
|
||||
// request whose Referer is the entrypoint URL, as a sender's is after
|
||||
// it follows the README's redirect from http:// to https://. The event
|
||||
// keeps the Referer's scheme and host, and carries the UUID nowhere.
|
||||
func TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := onlyEvent(t, sentryCase{
|
||||
scrub: true,
|
||||
panics: true,
|
||||
request: func(client *sentry.Client) *http.Request {
|
||||
req := sentryReceiverRequest(client)
|
||||
req.Header.Set(
|
||||
"Referer",
|
||||
"http://webhooker.example/h/"+sentryReceiverUUID,
|
||||
)
|
||||
|
||||
return req
|
||||
},
|
||||
}.capture(t))
|
||||
|
||||
assert.NotContains(
|
||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
"http://webhooker.example",
|
||||
event.Request.Headers["Referer"],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,8 +389,9 @@ func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
|
||||
// Span.doFinish captures with a nil hint, so BeforeSendTransaction
|
||||
// gets one with no context and no request: the route pattern is out of
|
||||
// reach and both the URL and the SDK-built transaction name have to
|
||||
// fall back. Tracing is off in this service, so no transaction event
|
||||
// is produced today; the hook is a floor against that changing.
|
||||
// fall back. This router has no nameSentryTransaction, so this is the
|
||||
// floor under a transaction that missed the rename;
|
||||
// TestSentryTransaction_NamedByRoute covers the routes as they ship.
|
||||
func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -376,6 +413,83 @@ func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) {
|
||||
assert.Equal(t, "POST /(redacted)", event.Transaction)
|
||||
}
|
||||
|
||||
// TestSentryTransaction_NamedByRoute serves requests through the real
|
||||
// route tree with tracing on, which the service leaves off. A routed
|
||||
// request's transaction is named after its route pattern, so Sentry
|
||||
// can group transactions by route, and the entrypoint UUID in the
|
||||
// receiver path appears nowhere in the event. A path that matches
|
||||
// nothing at the top level has no pattern, and its name is still
|
||||
// withheld; one that matches no route inside a route group is named
|
||||
// after the group's pattern.
|
||||
func TestSentryTransaction_NamedByRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// newTestEnv leaves the receiver rate limit at zero, which
|
||||
// refuses every request before it reaches the receiver.
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: 10,
|
||||
})
|
||||
|
||||
router := server.NewRouterWithSentryForTest(
|
||||
t, env.log, env.cfg, env.mw, env.hnd,
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method, path, want string
|
||||
}{
|
||||
{
|
||||
http.MethodPost,
|
||||
"/h/" + sentryReceiverUUID,
|
||||
"POST /h/{uuid}",
|
||||
},
|
||||
// An admin page route group, which installs its own
|
||||
// sentryhttp handler.
|
||||
{http.MethodGet, "/pages/login", "GET /pages/login"},
|
||||
{http.MethodGet, "/pages/unrouted", "GET /pages/*"},
|
||||
{
|
||||
http.MethodPost,
|
||||
"/h/" + sentryReceiverUUID + "/unrouted",
|
||||
"POST /(redacted)",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &captureTransport{}
|
||||
|
||||
opts := server.SentryClientOptionsForTest(
|
||||
"https://public@sentry.invalid/1",
|
||||
"webhooker-test",
|
||||
)
|
||||
opts.Transport = transport
|
||||
opts.EnableTracing = true
|
||||
opts.TracesSampleRate = 1.0
|
||||
|
||||
client, err := sentry.NewClient(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
sentry.SetHubOnContext(
|
||||
context.Background(),
|
||||
sentry.NewHub(client, sentry.NewScope()),
|
||||
),
|
||||
tc.method, tc.path, nil,
|
||||
)
|
||||
router.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
event := onlyEvent(t, transport.events)
|
||||
require.Equal(t, "transaction", event.Type)
|
||||
|
||||
assert.Equal(t, tc.want, event.Transaction)
|
||||
assert.NotContains(
|
||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook pins
|
||||
// that dispatch's premise the same way, since it is the one the
|
||||
// service does not exercise today.
|
||||
@@ -401,7 +515,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
|
||||
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
concrete := "https://example.com/webhook/" + sentryReceiverUUID
|
||||
concrete := "https://example.com/h/" + sentryReceiverUUID
|
||||
|
||||
// A request with no chi routing context on it at all, which is
|
||||
// what an event captured outside the router would carry.
|
||||
@@ -426,7 +540,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{URL: concrete}
|
||||
event.Transaction = "POST /webhook/" +
|
||||
event.Transaction = "POST /h/" +
|
||||
sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(
|
||||
@@ -459,9 +573,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{
|
||||
URL: "/webhook/" + sentryReceiverUUID,
|
||||
URL: "/h/" + sentryReceiverUUID,
|
||||
}
|
||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
||||
event.Transaction = "/h/" + sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||
require.NotNil(t, scrubbed)
|
||||
|
||||
@@ -39,6 +39,12 @@ const (
|
||||
// refuses to spend, leaving it for the hooks that run after the
|
||||
// server: the delivery engine, the healthcheck, the webhook DB
|
||||
// manager and the database close.
|
||||
//
|
||||
// Its value is not tuned to those hooks, which take about a
|
||||
// millisecond between them. It is what the 5s fx stop timeout in
|
||||
// cmd/webhooker leaves after a full ShutdownTimeout drain, so a
|
||||
// drain that starts on a full budget still gets all of
|
||||
// ShutdownTimeout.
|
||||
TailHookReserve = 2 * time.Second
|
||||
|
||||
// sentryFlushTimeout is the longest wait for Sentry to flush
|
||||
@@ -59,6 +65,16 @@ const (
|
||||
// key off it, and a zero exit would read as a deliberate stop.
|
||||
const StartupFailureExitCode = 1
|
||||
|
||||
// DrainBudget reports how long the HTTP drain may wait for in-flight
|
||||
// requests when remaining is the time left on the fx stop context as
|
||||
// the server's stop hook starts. The hooks before the server can
|
||||
// already have spent part of the budget, so the drain takes its time
|
||||
// out of what they left, never out of TailHookReserve. Zero or less
|
||||
// means no wait at all.
|
||||
func DrainBudget(remaining time.Duration) time.Duration {
|
||||
return min(ShutdownTimeout, remaining-TailHookReserve)
|
||||
}
|
||||
|
||||
// SentryFlushBudget reports how long the Sentry flush may run when
|
||||
// remaining is the time left on the fx stop context after the HTTP
|
||||
// drain. sentry.Flush takes a bare duration and honours no context,
|
||||
@@ -119,6 +135,8 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
||||
s.httpServer = s.newHTTPServer()
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
//nolint:contextcheck // The listener outlives this hook, whose
|
||||
// context fx cancels once the app has started.
|
||||
OnStart: func(_ context.Context) error {
|
||||
s.startupTime = time.Now()
|
||||
go s.Run()
|
||||
@@ -159,12 +177,6 @@ func (s *Server) Run() {
|
||||
s.serve()
|
||||
}
|
||||
|
||||
// MaintenanceMode returns whether the server is in maintenance
|
||||
// mode.
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
// enableSentry initialises the Sentry SDK when error reporting is
|
||||
// configured, and reports the failure when it is configured and cannot
|
||||
// be initialised. A DSN that is not set is not a failure: reporting
|
||||
@@ -267,10 +279,17 @@ func (s *Server) cleanupForExit() {
|
||||
s.log.Info("cleaning up")
|
||||
}
|
||||
|
||||
// cleanShutdown drains the HTTP server and flushes Sentry inside what
|
||||
// is left of the fx stop budget. A context carrying no deadline — a
|
||||
// caller outside the fx lifecycle — gets the full ShutdownTimeout.
|
||||
func (s *Server) cleanShutdown(ctx context.Context) {
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||
ctx, ShutdownTimeout,
|
||||
)
|
||||
drain := ShutdownTimeout
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
drain = DrainBudget(time.Until(deadline))
|
||||
}
|
||||
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, drain)
|
||||
defer shutdownCancel()
|
||||
|
||||
err := s.httpServer.Shutdown(ctxShutdown)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
w := env.get("/settings", nil)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
||||
}
|
||||
@@ -1,13 +1,148 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// TestDrainBudget covers the clamp that keeps the HTTP drain from
|
||||
// spending the tail hooks' share of the fx stop budget when the hooks
|
||||
// before the server have already used part of it.
|
||||
func TestDrainBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remaining time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "only the reserve is left",
|
||||
remaining: server.TailHookReserve,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "earlier hooks spent part of the budget",
|
||||
remaining: server.TailHookReserve + time.Second,
|
||||
want: time.Second,
|
||||
},
|
||||
{
|
||||
name: "capped at the nominal timeout",
|
||||
remaining: time.Hour,
|
||||
want: server.ShutdownTimeout,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.want, server.DrainBudget(tt.remaining))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanShutdown_LeavesTailHookReserve stops the server with a
|
||||
// request still in flight, after the hooks before it have spent all
|
||||
// of the stop budget but TailHookReserve. The drain must give up at
|
||||
// once rather than wait for the request: what is left belongs to the
|
||||
// hooks after the server, the database close among them. A drain
|
||||
// bounded only by ShutdownTimeout waits until the stop context
|
||||
// expires, and fx then skips those hooks.
|
||||
//
|
||||
// The test runs in a synctest bubble, whose clock moves only while
|
||||
// every goroutine in it is blocked, so a drain that gives up at once
|
||||
// leaves the stop context unexpired however slow the host is. The
|
||||
// request travels over net.Pipe because a goroutine waiting on a
|
||||
// real socket would stop that clock from moving at all.
|
||||
func TestCleanShutdown_LeavesTailHookReserve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
hs := &http.Server{
|
||||
Handler: http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
close(entered)
|
||||
<-release
|
||||
},
|
||||
),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
}
|
||||
|
||||
srvConn, cliConn := net.Pipe()
|
||||
|
||||
listener := pipeListener{
|
||||
conns: make(chan net.Conn, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
listener.conns <- srvConn
|
||||
|
||||
go func() { _ = hs.Serve(listener) }()
|
||||
|
||||
// Cleanups run last first: the handler returns, then closing
|
||||
// the client end ends the server's write of the response.
|
||||
t.Cleanup(func() { _ = cliConn.Close() })
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
_, err := cliConn.Write(
|
||||
[]byte("GET / HTTP/1.1\r\nHost: webhooker.test\r\n\r\n"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
<-entered
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(
|
||||
t.Context(), server.TailHookReserve,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
server.CleanShutdownForTest(stopCtx, hs)
|
||||
|
||||
require.NoError(
|
||||
t, stopCtx.Err(), "the drain spent the tail hooks' reserve",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// pipeListener is the net.Listener http.Server.Serve needs to serve
|
||||
// the server end of a net.Pipe: Accept returns that one connection,
|
||||
// then waits until Close, as a real listener with no more clients
|
||||
// does.
|
||||
type pipeListener struct {
|
||||
conns chan net.Conn
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (l pipeListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case conn := <-l.conns:
|
||||
return conn, nil
|
||||
case <-l.closed:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l pipeListener) Close() error {
|
||||
close(l.closed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr is never called by http.Server.Serve.
|
||||
func (pipeListener) Addr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSentryFlushBudget covers the clamp that keeps the Sentry flush
|
||||
// from spending the tail hooks' share of the fx stop budget.
|
||||
// sentry.Flush ignores the stop context, so without the clamp a
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||
// template loads on each page and fetches it through the real router.
|
||||
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||
// in the repo guarantees it is present: this is the check that the page
|
||||
// still gets the JavaScript it asks for.
|
||||
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
||||
// file is not in the tree: this is the check that the page still gets
|
||||
// the JavaScript it asks for.
|
||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user