Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4cf45c96 | ||
|
|
5b1d283d06 | ||
|
|
b78abdc9da | ||
|
|
c23ffbac65 | ||
|
|
2ac4d4d793 |
@@ -40,12 +40,6 @@ duplicate. That is deliberate — the alternative is a silent lost
|
||||
delivery — and the README says so under Rationale. It is not a defect
|
||||
to re-file.
|
||||
|
||||
One caveat on reading a green check: a docs-only commit deliberately
|
||||
replays from the layer cache
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||
such a commit evidences a replay rather than an executed run. A code
|
||||
commit invalidates the `COPY` layer and genuinely executes.
|
||||
|
||||
# Next Step
|
||||
|
||||
Clear the rest of the open 1.0.0 milestone
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
@@ -146,6 +147,7 @@ type EngineParams struct {
|
||||
|
||||
DB *database.Database
|
||||
DBManager *database.WebhookDBManager
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
SSRFGuard *Guard
|
||||
Metrics *metrics.Set
|
||||
@@ -168,6 +170,10 @@ type Engine struct {
|
||||
retryCh chan Task
|
||||
workers int
|
||||
|
||||
// version is the running build's version, the one the web UI
|
||||
// footer shows. userAgent puts it on every outbound request.
|
||||
version string
|
||||
|
||||
// mtr is the delivery metric set. Production wires the one
|
||||
// registered on the registry /metrics serves; a test can
|
||||
// substitute a set registered on a registry it holds, so it can
|
||||
@@ -205,6 +211,7 @@ func New(
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: defaultWorkers,
|
||||
version: params.Globals.Version,
|
||||
mtr: params.Metrics,
|
||||
}
|
||||
|
||||
@@ -301,6 +308,13 @@ func (e *Engine) ScheduleRetry(
|
||||
})
|
||||
}
|
||||
|
||||
// userAgent is the User-Agent header of every http and slack
|
||||
// delivery request: the program name and the running build's
|
||||
// version.
|
||||
func (e *Engine) userAgent() string {
|
||||
return "webhooker/" + e.version
|
||||
}
|
||||
|
||||
// registerHooks wires the engine's start and stop into the fx
|
||||
// lifecycle. The start hook's context is deliberately ignored
|
||||
// (see start for why the worker pool must not inherit it); the
|
||||
|
||||
@@ -1247,11 +1247,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
testContentType,
|
||||
receivedHeaders.Get("Content-Type"),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
"webhooker/1.0",
|
||||
receivedHeaders.Get("User-Agent"),
|
||||
)
|
||||
}
|
||||
|
||||
// The event's stored inbound headers carry the same Content-Type the
|
||||
@@ -1320,6 +1315,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||
ContentType: tc.event,
|
||||
},
|
||||
cfg,
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
|
||||
@@ -83,8 +83,9 @@ func ExportApplyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
userAgent string,
|
||||
) []string {
|
||||
return applyRequestHeaders(req, event, cfg)
|
||||
return applyRequestHeaders(req, event, cfg, userAgent)
|
||||
}
|
||||
|
||||
// ExportTruncate exposes truncate for testing.
|
||||
|
||||
@@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
||||
"Content-Type": testContentType,
|
||||
},
|
||||
},
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
|
||||
@@ -442,7 +442,9 @@ func (t *httpTarget) doHTTPRequest(
|
||||
)
|
||||
}
|
||||
|
||||
originScoped := applyRequestHeaders(req, event, cfg)
|
||||
originScoped := applyRequestHeaders(
|
||||
req, event, cfg, t.eng.userAgent(),
|
||||
)
|
||||
|
||||
client := t.clientForRequest(cfg, originScoped)
|
||||
|
||||
@@ -562,10 +564,13 @@ func isForwardableHeader(name string) bool {
|
||||
// Content-Type goes out once: a Content-Type configured on the target
|
||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||
// Content-Type in the event's headers is never forwarded.
|
||||
//
|
||||
// userAgent is set last, over any configured or inbound User-Agent.
|
||||
func applyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
userAgent string,
|
||||
) []string {
|
||||
if event.ContentType != "" {
|
||||
req.Header.Set(
|
||||
@@ -580,7 +585,7 @@ func applyRequestHeaders(
|
||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
// A Content-Type configured on the target describes the body
|
||||
// being sent rather than the sender. A 307/308 preserves the
|
||||
|
||||
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
req.Header.Set("User-Agent", t.eng.userAgent())
|
||||
|
||||
resp, doErr := executeHTTPRequest(t.client, req)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
)
|
||||
|
||||
// Both the http and the slack target send webhooker/ and the version
|
||||
// in Globals, the value the web UI footer shows. A User-Agent
|
||||
// configured on the target or carried in by the sender does not
|
||||
// replace it.
|
||||
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const want = "webhooker/1.2.3-test"
|
||||
|
||||
userAgents := make(chan string, 1)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
userAgents <- r.Header.Get("User-Agent")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
defer ts.Close()
|
||||
|
||||
g := &globals.Globals{Version: "1.2.3-test"}
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||
require.NoError(t, err)
|
||||
|
||||
e := delivery.New(lc, delivery.EngineParams{
|
||||
Globals: g,
|
||||
Logger: log,
|
||||
// httptest listens on loopback, which the default guard
|
||||
// refuses.
|
||||
SSRFGuard: delivery.NewTestGuard(
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
),
|
||||
Metrics: metrics.New(prometheus.NewRegistry()),
|
||||
})
|
||||
|
||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||
context.Background(),
|
||||
&delivery.HTTPTargetConfig{
|
||||
URL: ts.URL,
|
||||
Headers: map[string]string{"User-Agent": "configured/1"},
|
||||
},
|
||||
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, statusCode)
|
||||
require.Len(t, userAgents, 1, "the http target sent no request")
|
||||
assert.Equal(t, want, <-userAgents, "http target")
|
||||
|
||||
db := testWebhookDB(t)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
slackCfg, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"action":"test"}`)
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID, database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
|
||||
dlv, event, targetID, "test-slack", string(slackCfg),
|
||||
))
|
||||
require.Len(t, userAgents, 1, "the slack target sent no request")
|
||||
assert.Equal(t, want, <-userAgents, "slack target")
|
||||
}
|
||||
@@ -333,7 +333,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
)
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
http.Redirect(
|
||||
w, r, withNotice("/pages/login", signedOut),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,72 +11,37 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||
// with and the event log page reads its banner from.
|
||||
const replayOutcomeParam = "replay"
|
||||
|
||||
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||
// carries one of these fixed codes rather than a message, so nothing a
|
||||
// client submits can reach the rendered page through it.
|
||||
type replayOutcomeCode string
|
||||
|
||||
// The outcomes of a replay POST, as the notice codes its redirect
|
||||
// carries. noticeFor holds the line each one shows.
|
||||
const (
|
||||
// replayQueued reports that a new delivery was created and handed
|
||||
// to the delivery engine.
|
||||
replayQueued replayOutcomeCode = "queued"
|
||||
replayQueued noticeCode = "replay-queued"
|
||||
|
||||
// replayTargetDeleted reports a target that once existed and has
|
||||
// since been deleted. Deletes are soft and deliveries carry no
|
||||
// foreign key to the target row, so the history survives its
|
||||
// target and this is the ordinary case for an old event.
|
||||
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||
|
||||
// replayTargetMissing reports a target id that names no row at
|
||||
// all, deleted or otherwise.
|
||||
replayTargetMissing replayOutcomeCode = "target-missing"
|
||||
replayTargetMissing noticeCode = "replay-target-missing"
|
||||
|
||||
// replayTargetInactive reports a target the operator has
|
||||
// deactivated. A deactivated target receives no new deliveries, so
|
||||
// a replay to it would be a delivery they switched off.
|
||||
replayTargetInactive replayOutcomeCode = "target-inactive"
|
||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
||||
|
||||
// replayNotTerminal reports a delivery the engine has not finished
|
||||
// with.
|
||||
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
||||
|
||||
// replayInFlight reports that an earlier replay of this event to
|
||||
// this target is still running.
|
||||
replayInFlight replayOutcomeCode = "in-flight"
|
||||
replayInFlight noticeCode = "replay-in-flight"
|
||||
)
|
||||
|
||||
// replayOutcome returns the banner the event log page shows for an
|
||||
// outcome code, and whether the replay was queued. An unrecognised
|
||||
// code yields no banner.
|
||||
func replayOutcome(code string) (string, bool) {
|
||||
switch replayOutcomeCode(code) {
|
||||
case replayQueued:
|
||||
return "Replay queued: a new delivery was created against " +
|
||||
"the target's current configuration.", true
|
||||
case replayTargetDeleted:
|
||||
return "Not replayed: the target this delivery was for has " +
|
||||
"been deleted. Recreate the target, then replay.", false
|
||||
case replayTargetMissing:
|
||||
return "Not replayed: the target this delivery was for no " +
|
||||
"longer exists.", false
|
||||
case replayTargetInactive:
|
||||
return "Not replayed: the target this delivery was for is " +
|
||||
"deactivated. Activate it, then replay.", false
|
||||
case replayNotTerminal:
|
||||
return "Not replayed: this delivery has not finished yet.",
|
||||
false
|
||||
case replayInFlight:
|
||||
return "Not replayed: a delivery of this event to this " +
|
||||
"target is already in flight.", false
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||
// target.
|
||||
//
|
||||
@@ -140,14 +105,14 @@ func (h *Handlers) replayDelivery(
|
||||
}
|
||||
|
||||
if !original.Status.Terminal() {
|
||||
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||
if target == nil {
|
||||
h.finishReplay(w, r, webhook, code)
|
||||
redirectToEventLog(w, r, webhook, code)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -200,7 +165,7 @@ func (h *Handlers) queueReplay(
|
||||
}
|
||||
|
||||
if inFlight > 0 {
|
||||
h.finishReplay(w, r, webhook, replayInFlight)
|
||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -238,7 +203,7 @@ func (h *Handlers) queueReplay(
|
||||
"delivery_id", task.DeliveryID,
|
||||
)
|
||||
|
||||
h.finishReplay(w, r, webhook, replayQueued)
|
||||
redirectToEventLog(w, r, webhook, replayQueued)
|
||||
}
|
||||
|
||||
// replayTarget loads the delivery's target as it stands now.
|
||||
@@ -251,7 +216,7 @@ func (h *Handlers) queueReplay(
|
||||
// with the returned code saying why.
|
||||
func (h *Handlers) replayTarget(
|
||||
webhookID, targetID string,
|
||||
) (*database.Target, replayOutcomeCode) {
|
||||
) (*database.Target, noticeCode) {
|
||||
var target database.Target
|
||||
|
||||
err := h.db.DB().Unscoped().Where(
|
||||
@@ -361,17 +326,16 @@ func replayBody(body string) *string {
|
||||
return &body
|
||||
}
|
||||
|
||||
// finishReplay redirects back to the event log the replay was
|
||||
// triggered from, carrying the outcome code the page turns into a
|
||||
// banner and the page number the form submitted.
|
||||
func (h *Handlers) finishReplay(
|
||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||
// log it was triggered from, carrying the outcome as its notice and
|
||||
// the page number the form submitted.
|
||||
func redirectToEventLog(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code replayOutcomeCode,
|
||||
code noticeCode,
|
||||
) {
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
replayOutcomeParam + "=" + string(code)
|
||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
|
||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
||||
missing.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||
require.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
||||
first.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
||||
second.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
||||
pending.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
assert.Contains(t, body, ">Replay<")
|
||||
|
||||
refused := renderSourceLogsPageWithQuery(
|
||||
t, h, sess, wh.ID, "?replay=target-deleted",
|
||||
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
||||
)
|
||||
|
||||
assert.Contains(t, refused, "alert-error")
|
||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
|
||||
// An outcome code nobody issued renders no banner at all.
|
||||
unknown := renderSourceLogsPageWithQuery(
|
||||
t, h, sess, wh.ID, "?replay=made-up",
|
||||
t, h, sess, wh.ID, "?notice=made-up",
|
||||
)
|
||||
|
||||
assert.NotContains(t, unknown, "alert-error")
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
@@ -11,43 +10,19 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// resubmitOutcomeParam is the query parameter the resubmit POST
|
||||
// redirects with and the event log page reads its banner from.
|
||||
const resubmitOutcomeParam = "resubmit"
|
||||
|
||||
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
||||
// carries one of these fixed codes rather than a message, so nothing a
|
||||
// client submits can reach the rendered page through it.
|
||||
type resubmitOutcomeCode string
|
||||
|
||||
// The outcomes of a resubmit POST, as the notice codes its redirect
|
||||
// carries. noticeFor holds the line each one shows.
|
||||
const (
|
||||
// resubmitQueued reports that a new event was stored and its
|
||||
// deliveries handed to the delivery engine.
|
||||
resubmitQueued resubmitOutcomeCode = "queued"
|
||||
resubmitQueued noticeCode = "resubmit-queued"
|
||||
|
||||
// resubmitNoTargets reports a source with no active targets. The
|
||||
// new event is stored either way, exactly as a received event
|
||||
// with no targets is.
|
||||
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
||||
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
||||
)
|
||||
|
||||
// resubmitOutcome returns the banner the event log page shows for an
|
||||
// outcome code, and whether the resubmit was queued. An unrecognised
|
||||
// code yields no banner.
|
||||
func resubmitOutcome(code string) (string, bool) {
|
||||
switch resubmitOutcomeCode(code) {
|
||||
case resubmitQueued:
|
||||
return "Resubmitted: a new event was created from the stored " +
|
||||
"one and queued to every active target.", true
|
||||
case resubmitNoTargets:
|
||||
return "Resubmitted: a new event was created, but this " +
|
||||
"source has no active targets, so nothing was queued.",
|
||||
true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||
// read as bytes rather than as a string so the copy is byte-identical
|
||||
// to what was received, whatever the payload's encoding.
|
||||
@@ -245,29 +220,5 @@ func (h *Handlers) queueResubmit(
|
||||
code = resubmitNoTargets
|
||||
}
|
||||
|
||||
h.finishResubmit(w, r, webhook, code)
|
||||
}
|
||||
|
||||
// finishResubmit redirects back to the event log the resubmit was
|
||||
// triggered from, carrying the outcome code the page turns into a
|
||||
// banner and the page number the form submitted.
|
||||
func (h *Handlers) finishResubmit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code resubmitOutcomeCode,
|
||||
) {
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
resubmitOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
// headers record.
|
||||
if page := pageOrFirst(
|
||||
r.PostFormValue("page"),
|
||||
); page > 1 {
|
||||
dest += "&page=" + strconv.Itoa(page)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
redirectToEventLog(w, r, webhook, code)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
w.Header().Get("Location"),
|
||||
"a resubmit must not be refused while an earlier "+
|
||||
"one is in flight",
|
||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
||||
w.Header().Get("Location"),
|
||||
"an inactive target is skipped, not an error",
|
||||
)
|
||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,10 +97,10 @@ type Handlers struct {
|
||||
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, and navbar templates, and with any further files
|
||||
// the page includes. The page file must be listed first so that its
|
||||
// root action ({{template "base" .}}) becomes the template set's entry
|
||||
// point.
|
||||
// base, htmlheader, navbar and notice templates, and with any further
|
||||
// files the page includes. The page file must be listed first so that
|
||||
// its root action ({{template "base" .}}) becomes the template set's
|
||||
// entry point.
|
||||
func parsePageTemplate(
|
||||
pageFile string, included ...string,
|
||||
) *template.Template {
|
||||
@@ -109,6 +109,7 @@ func parsePageTemplate(
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
"notice.html",
|
||||
}, included...)
|
||||
|
||||
return template.Must(
|
||||
@@ -209,11 +210,13 @@ func (s *Handlers) renderError(
|
||||
// served outside the routes where NoCache runs.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// No notice: one would say an action worked above a page saying
|
||||
// the request failed.
|
||||
data := s.pageData(r, map[string]any{
|
||||
"Status": status,
|
||||
"StatusText": http.StatusText(status),
|
||||
"Message": errorPageText(status),
|
||||
})
|
||||
}, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -267,6 +270,7 @@ type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Version string
|
||||
Notice *notice
|
||||
Data any
|
||||
}
|
||||
|
||||
@@ -311,12 +315,15 @@ func (s *Handlers) renderTemplate(
|
||||
return
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
|
||||
}
|
||||
|
||||
// pageData adds the fields the shared layout renders to a page's own
|
||||
// data.
|
||||
func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||
// data. The layout shows the notice, when there is one, above the
|
||||
// page.
|
||||
func (s *Handlers) pageData(
|
||||
r *http.Request, data any, pageNotice *notice,
|
||||
) any {
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
@@ -330,6 +337,7 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
m["Notice"] = pageNotice
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -338,6 +346,7 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Notice: pageNotice,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// noticeParam is the query parameter an action's redirect carries its
|
||||
// notice code in.
|
||||
const noticeParam = "notice"
|
||||
|
||||
// noticeCode names one of the fixed lines noticeFor knows. An action
|
||||
// redirects with the code rather than the line, so nothing a client
|
||||
// puts in the URL reaches the page: a code noticeFor does not know
|
||||
// shows nothing.
|
||||
type noticeCode string
|
||||
|
||||
// The codes of the actions on the webhook pages and of signing out.
|
||||
// Replay's codes, with the reasons a replay can be refused, and
|
||||
// resubmit's codes are defined beside those actions.
|
||||
const (
|
||||
webhookCreated noticeCode = "webhook-created"
|
||||
webhookSaved noticeCode = "webhook-saved"
|
||||
webhookDeleted noticeCode = "webhook-deleted"
|
||||
entrypointAdded noticeCode = "entrypoint-added"
|
||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||
entrypointActivated noticeCode = "entrypoint-activated"
|
||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||
targetAdded noticeCode = "target-added"
|
||||
targetSaved noticeCode = "target-saved"
|
||||
targetDeleted noticeCode = "target-deleted"
|
||||
targetActivated noticeCode = "target-activated"
|
||||
targetDeactivated noticeCode = "target-deactivated"
|
||||
signedOut noticeCode = "signed-out"
|
||||
)
|
||||
|
||||
// notice is the line templates/notice.html shows above a page to say
|
||||
// what an action did.
|
||||
type notice struct {
|
||||
Text string
|
||||
|
||||
// Failed shows the line as an error: the action was refused.
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// noticeFor returns the notice the request's URL names, or nil when it
|
||||
// names none or an unknown code.
|
||||
func noticeFor(r *http.Request) *notice {
|
||||
n, ok := map[noticeCode]notice{
|
||||
webhookCreated: {Text: "Webhook created."},
|
||||
webhookSaved: {Text: "Webhook saved."},
|
||||
webhookDeleted: {Text: "Webhook deleted."},
|
||||
entrypointAdded: {Text: "Entrypoint added."},
|
||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||
entrypointActivated: {Text: "Entrypoint activated."},
|
||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||
targetAdded: {Text: "Target added."},
|
||||
targetSaved: {Text: "Target saved."},
|
||||
targetDeleted: {Text: "Target deleted."},
|
||||
targetActivated: {Text: "Target activated."},
|
||||
targetDeactivated: {Text: "Target deactivated."},
|
||||
signedOut: {Text: "Signed out."},
|
||||
|
||||
replayQueued: {
|
||||
Text: "Replay queued: a new delivery was created " +
|
||||
"against the target's current configuration.",
|
||||
},
|
||||
replayTargetDeleted: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"has been deleted. Recreate the target, then replay.",
|
||||
Failed: true,
|
||||
},
|
||||
replayTargetMissing: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"no longer exists.",
|
||||
Failed: true,
|
||||
},
|
||||
replayTargetInactive: {
|
||||
Text: "Not replayed: the target this delivery was for " +
|
||||
"is deactivated. Activate it, then replay.",
|
||||
Failed: true,
|
||||
},
|
||||
replayNotTerminal: {
|
||||
Text: "Not replayed: this delivery has not finished yet.",
|
||||
Failed: true,
|
||||
},
|
||||
replayInFlight: {
|
||||
Text: "Not replayed: a delivery of this event to this " +
|
||||
"target is already in flight.",
|
||||
Failed: true,
|
||||
},
|
||||
|
||||
resubmitQueued: {
|
||||
Text: "Resubmitted: a new event was created from the " +
|
||||
"stored one and queued to every active target.",
|
||||
},
|
||||
resubmitNoTargets: {
|
||||
Text: "Resubmitted: a new event was created, but this " +
|
||||
"source has no active targets, so nothing was queued.",
|
||||
},
|
||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &n
|
||||
}
|
||||
|
||||
// withNotice returns path with code added as its notice.
|
||||
func withNotice(path string, code noticeCode) string {
|
||||
return path + "?" + noticeParam + "=" + string(code)
|
||||
}
|
||||
@@ -411,7 +411,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
assert.Equal(
|
||||
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
|
||||
@@ -241,3 +241,37 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
||||
assert.Contains(t, body, "(unavailable)")
|
||||
assert.NotContains(t, body, "beak")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
|
||||
// page's maximum width at 108rem (1728 px), half again the 72rem of
|
||||
// max-w-6xl that the webhook list and the event log use, so an
|
||||
// entrypoint URL fits on one line in a 1920-pixel window; and the
|
||||
// wrapping of its title row, so the buttons beside the title do not
|
||||
// push a phone-width window into scrolling sideways.
|
||||
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
|
||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
||||
)
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,7 +322,8 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
||||
)
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -579,7 +580,8 @@ func (h *Handlers) applyWebhookEdit(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,7 +664,9 @@ func (h *Handlers) deleteWebhookResources(
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||
@@ -841,31 +845,16 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
// The banner a replay or resubmit POST redirected back
|
||||
// with. The message comes from a fixed set keyed by the
|
||||
// outcome code, never from the query string itself.
|
||||
replayMsg, replayOK := replayOutcome(
|
||||
r.URL.Query().Get(replayOutcomeParam),
|
||||
)
|
||||
|
||||
resubmitMsg, resubmitOK := resubmitOutcome(
|
||||
r.URL.Query().Get(resubmitOutcomeParam),
|
||||
)
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Events": evts,
|
||||
"ReplayMessage": replayMsg,
|
||||
"ReplayQueued": replayOK,
|
||||
"ResubmitMessage": resubmitMsg,
|
||||
"ResubmitQueued": resubmitOK,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_logs.html", data)
|
||||
@@ -1254,7 +1243,8 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1365,7 +1355,8 @@ func (h *Handlers) processTargetCreate(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1643,6 +1634,7 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
||||
"entrypointID", &database.Entrypoint{},
|
||||
"failed to delete entrypoint",
|
||||
nil,
|
||||
entrypointDeleted,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1655,18 +1647,21 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
||||
"targetID", &database.Target{},
|
||||
"failed to delete target",
|
||||
h.evictArchiveWriterIfUnused,
|
||||
targetDeleted,
|
||||
)
|
||||
}
|
||||
|
||||
// deleteChildResource returns a handler that deletes a child
|
||||
// resource (entrypoint or target) belonging to a webhook. The
|
||||
// optional afterDelete hook runs with the webhook's id once the
|
||||
// delete has succeeded, before the redirect.
|
||||
// delete has succeeded, before the redirect, which carries done as
|
||||
// its notice.
|
||||
func (h *Handlers) deleteChildResource(
|
||||
idParam string,
|
||||
model any,
|
||||
errMsg string,
|
||||
afterDelete func(webhookID string),
|
||||
done noticeCode,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.getUserID(r)
|
||||
@@ -1708,7 +1703,7 @@ func (h *Handlers) deleteChildResource(
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/hook/"+webhook.ID,
|
||||
withNotice("/hook/"+webhook.ID, done),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
@@ -1719,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
|
||||
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||
return h.toggleChildResource(
|
||||
"entrypointID",
|
||||
func(webhookID, childID string) error {
|
||||
func(webhookID, childID string) (bool, error) {
|
||||
var ep database.Entrypoint
|
||||
|
||||
err := h.db.DB().Where(
|
||||
@@ -1727,14 +1722,15 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||
childID, webhookID,
|
||||
).First(&ep).Error
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
ep.Active = !ep.Active
|
||||
|
||||
return h.db.DB().Save(&ep).Error
|
||||
return ep.Active, h.db.DB().Save(&ep).Error
|
||||
},
|
||||
"failed to toggle entrypoint",
|
||||
entrypointActivated, entrypointDeactivated,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1742,7 +1738,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||
return h.toggleChildResource(
|
||||
"targetID",
|
||||
func(webhookID, childID string) error {
|
||||
func(webhookID, childID string) (bool, error) {
|
||||
var tgt database.Target
|
||||
|
||||
err := h.db.DB().Where(
|
||||
@@ -1750,23 +1746,27 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||
childID, webhookID,
|
||||
).First(&tgt).Error
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
tgt.Active = !tgt.Active
|
||||
|
||||
return h.db.DB().Save(&tgt).Error
|
||||
return tgt.Active, h.db.DB().Save(&tgt).Error
|
||||
},
|
||||
"failed to toggle target",
|
||||
targetActivated, targetDeactivated,
|
||||
)
|
||||
}
|
||||
|
||||
// toggleChildResource returns a handler that toggles the active
|
||||
// state of a child resource belonging to a webhook.
|
||||
// state of a child resource belonging to a webhook. toggleFn returns
|
||||
// the new state, and the redirect carries activated or deactivated as
|
||||
// its notice to match.
|
||||
func (h *Handlers) toggleChildResource(
|
||||
idParam string,
|
||||
toggleFn func(webhookID, childID string) error,
|
||||
toggleFn func(webhookID, childID string) (bool, error),
|
||||
errMsg string,
|
||||
activated, deactivated noticeCode,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.getUserID(r)
|
||||
@@ -1792,16 +1792,21 @@ func (h *Handlers) toggleChildResource(
|
||||
return
|
||||
}
|
||||
|
||||
err = toggleFn(webhook.ID, childID)
|
||||
active, err := toggleFn(webhook.ID, childID)
|
||||
if err != nil {
|
||||
h.serverError(w, r, errMsg, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
done := deactivated
|
||||
if active {
|
||||
done = activated
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/hook/"+webhook.ID,
|
||||
withNotice("/hook/"+webhook.ID, done),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,8 @@ func (h *Handlers) applyTargetEdit(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ func (s *Set) initSeries() {
|
||||
s.deliveriesFailed.WithLabelValues(label)
|
||||
s.deliveryRetries.WithLabelValues(label)
|
||||
s.deliveryReplays.WithLabelValues(label)
|
||||
s.deliveryDuration.WithLabelValues(label)
|
||||
s.deliveriesPending.WithLabelValues(label)
|
||||
s.deliveriesRetrying.WithLabelValues(label)
|
||||
s.circuitBreakersOpen.WithLabelValues(label)
|
||||
|
||||
@@ -167,6 +167,7 @@ func TestKnownSeriesExistBeforeAnyDelivery(t *testing.T) {
|
||||
"webhooker_deliveries_succeeded_total",
|
||||
"webhooker_deliveries_failed_total",
|
||||
"webhooker_delivery_retries_total",
|
||||
"webhooker_delivery_duration_seconds",
|
||||
"webhooker_circuit_breakers_open",
|
||||
} {
|
||||
assert.ElementsMatch(t,
|
||||
|
||||
@@ -83,6 +83,22 @@ func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||
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()
|
||||
|
||||
|
||||
+114
-34
@@ -263,6 +263,24 @@ func (e *testEnv) urlFrom(
|
||||
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,
|
||||
@@ -741,6 +759,31 @@ func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
||||
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
|
||||
@@ -858,9 +901,9 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
require.NoError(t,
|
||||
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
||||
)
|
||||
assert.Equal(
|
||||
t, "/hook/"+created.ID, w.Header().Get("Location"),
|
||||
"creating a webhook should redirect to its page",
|
||||
env.requireNotice(
|
||||
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
|
||||
cookies,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -891,8 +934,7 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
||||
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, page, w.Header().Get("Location"))
|
||||
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
|
||||
|
||||
var edited database.Webhook
|
||||
|
||||
@@ -906,16 +948,17 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
||||
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
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 and deletes an
|
||||
// entrypoint with the forms on the webhook page, each submitted to
|
||||
// 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()
|
||||
@@ -933,16 +976,19 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
// submit posts the webhook page's form whose action pattern
|
||||
// captures, and requires the redirect back to that page.
|
||||
submit := func(pattern string) {
|
||||
// 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)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
env.requireNotice(t, w, page, code, text, cookies)
|
||||
}
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints)"`)
|
||||
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints)"`,
|
||||
"entrypoint-added", "Entrypoint added.")
|
||||
|
||||
var added database.Entrypoint
|
||||
|
||||
@@ -951,7 +997,7 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
)
|
||||
require.True(t, added.Active)
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
|
||||
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
|
||||
|
||||
var toggled database.Entrypoint
|
||||
|
||||
@@ -960,7 +1006,10 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
)
|
||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
|
||||
submit(toggle, "entrypoint-activated", "Entrypoint activated.")
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
|
||||
"entrypoint-deleted", "Entrypoint deleted.")
|
||||
|
||||
var left int64
|
||||
|
||||
@@ -971,8 +1020,8 @@ func TestHook_EntrypointActions(t *testing.T) {
|
||||
|
||||
// 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 and deletes it, every URL and token taken from
|
||||
// the rendered pages.
|
||||
// it, then deactivates, activates and deletes it, every URL and token
|
||||
// taken from the rendered pages.
|
||||
func TestHook_TargetActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -987,27 +1036,29 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
submit := func(from, pattern string, form url.Values) {
|
||||
// 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)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
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"}})
|
||||
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
|
||||
|
||||
var edited database.Target
|
||||
|
||||
@@ -1017,8 +1068,8 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
assert.Equal(t, "renamed", edited.Name)
|
||||
require.True(t, edited.Active)
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
|
||||
url.Values{})
|
||||
submit(page, toggle, url.Values{},
|
||||
"target-deactivated", "Target deactivated.")
|
||||
|
||||
var toggled database.Target
|
||||
|
||||
@@ -1027,8 +1078,11 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
)
|
||||
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{})
|
||||
url.Values{}, "target-deleted", "Target deleted.")
|
||||
|
||||
var left int64
|
||||
|
||||
@@ -1064,9 +1118,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
|
||||
env.requireNotice(
|
||||
t, w, logsPath, "resubmit-no-targets",
|
||||
"this source has no active targets", cookies,
|
||||
)
|
||||
|
||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||
@@ -1302,10 +1356,8 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
html.UnescapeString(action[1]), form, cookies,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, logsPath+"?replay=queued",
|
||||
w.Header().Get("Location"),
|
||||
env.requireNotice(
|
||||
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(2), env.countDeliveries(t, wh.ID),
|
||||
@@ -1524,3 +1576,31 @@ func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<div class="flex-grow">
|
||||
{{template "navbar" .}}
|
||||
{{template "notice" .}}
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "notice"}}
|
||||
{{with .Notice}}
|
||||
<div class="max-w-6xl mx-auto px-6 pt-4">
|
||||
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -3,10 +3,14 @@
|
||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
||||
event log, the navbar and the footer, so an entrypoint URL fits on
|
||||
one line. An inline style, because the committed tailwind.css has
|
||||
no class this wide. -->
|
||||
<div class="mx-auto px-6 py-8" style="max-width: 108rem" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||
<div class="mb-6">
|
||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||
{{if .Webhook.Description}}
|
||||
|
||||
@@ -12,14 +12,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ReplayMessage}}
|
||||
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ResubmitMessage}}
|
||||
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
|
||||
Reference in New Issue
Block a user