1 Commits
Author SHA1 Message Date
clawbot d5bdef9b56 Break long values on the webhook page and event log at phone width (closes #391)
check / check (push) Successful in 3m38s
A name, ID or content type with no place to break ran past its row and
was cut off by the card at 390 pixels, and a long webhook name made the
webhook page scroll sideways. The elements holding such values now
carry wrap-anywhere, so a value breaks only where it must; controls,
statuses and times never do, so a row too wide for a phone still runs
past the edge. The event log's title row wraps like the webhook page's.

The browser test gains a 390-pixel check of both pages: neither scrolls
sideways and nothing shown runs past the page's or its card's edge. Its
seeded webhook name and newer event's content type are long enough to
fail without the change.

Model: opus-5-5
2026-10-03 04:39:37 +00:00
38 changed files with 518 additions and 1236 deletions
-3
View File
@@ -1,3 +0,0 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
+7 -12
View File
@@ -67,20 +67,15 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# prettier for the Markdown stages below, and stays cached until package.json,
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
# and the build stage below runs it too. COPY . . brings in the CI cache
# barrier described in the lint stage above.
#
# The image's own corepack runs the yarn that package.json's packageManager
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
# hash there. The image also ships yarn 1, which `corepack enable yarn`
# replaces.
# node:24.21.0-alpine (LTS), 2026-09-18
# prettier for the Markdown stages below, and stays cached until those two
# files change. script/lint forces only js-lint to re-run, and the build stage
# below runs it too. COPY . . brings in the CI cache barrier described in the
# lint stage above.
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --ignore-scripts
FROM js-deps AS js-lint
COPY . .
+28 -50
View File
@@ -1638,19 +1638,11 @@ URL, custom headers, timeout settings).
**`http` target configuration:**
| Key | Type | Description |
| -------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `url` | string | Destination the event is POSTed to |
| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers |
| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout |
| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not |
`forwardQuery` is off by default, and the target URL is then sent exactly as
configured. On, each delivery appends the event's query string to the target
URL, joined with `&` when the URL already has a query string of its own; a
replayed delivery and a resubmitted event's deliveries do the same. Both target
forms offer it as "Pass the query string on to this target", and the target list
shows it when it is on.
| Key | Type | Description |
| --------- | ------------- | -------------------------------------------------------------------------------------- |
| `url` | string | Destination the event is POSTed to |
| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers |
| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout |
`timeout` is capped at **300 seconds**, and the form rejects anything above it
rather than substituting the cap. A delivery attempt holds one of the bounded
@@ -1712,7 +1704,6 @@ auditing, for replay, and for resubmission.
| `webhook_id` | UUID | Foreign key → Webhook |
| `entrypoint_id` | UUID | Foreign key → Entrypoint |
| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created |
| `raw_query` | text | The query string of the captured request, as sent, without the leading `?`; empty when there was none. A resubmitted copy carries its original's |
| `headers` | JSON | Complete request headers |
| `body` | text | Raw request body |
| `content_type` | string | Content-Type header value |
@@ -1721,15 +1712,9 @@ auditing, for replay, and for resubmission.
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries.
When a request arrives at an entrypoint, the full request (method, query string,
headers, body) is captured as an Event. The event is then queued for delivery to
every active target configured on the parent webhook.
The event log and the event's own page show the query string with the rest of
the request. The event log leaves out one larger than 32 KiB, as it does request
headers, and links to the event's page, which shows it whole. The `database` and
`log` targets carry it with the rest of the event. An `http` target receives it
only when its `forwardQuery` setting is on.
When a request arrives at an entrypoint, the full request (method, headers,
body) is captured as an Event. The event is then queued for delivery to every
active target configured on the parent webhook.
#### Delivery
@@ -1775,14 +1760,14 @@ the webhook's currently active targets.
**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT.
The event log offers a per-event **Resubmit** action that stores a NEW event
copying the stored one's `method`, `raw_query`, `headers`, `body` and
`content_type` verbatim, then fans it out to the webhook's currently **active**
targets — resolved fresh by the same query the receiver uses, so a target
created long after the original event arrived receives it. That is the
difference that matters: a target added to test a backend under development has
no prior delivery, so there is nothing to replay to it, while a resubmit reaches
it like any other active target. Inactive targets are skipped, exactly as the
receiver skips them.
copying the stored one's `method`, `headers`, `body` and `content_type`
verbatim, then fans it out to the webhook's currently **active** targets —
resolved fresh by the same query the receiver uses, so a target created long
after the original event arrived receives it. That is the difference that
matters: a target added to test a backend under development has no prior
delivery, so there is nothing to replay to it, while a resubmit reaches it like
any other active target. Inactive targets are skipped, exactly as the receiver
skips them.
The new event is a first-class event in the log with its own deliveries, not a
marker on the one it came from, and the original's deliveries are left
@@ -2453,8 +2438,7 @@ in front of them, so a query on a fixed 200 URL would otherwise buy the same
amplification as an invented path. Nothing debuggable is lost: the only query
parameters this service reads are the sign-in page's `next`, the page to return
to, `notice`, which names the line a page shows after an action, and the event
log's `show`, which picks the events it lists. A query string sent to an
entrypoint is not lost either: the event stores it, and the event log shows it.
log's `show`, which picks the events it lists.
Client-supplied request content does not leave the host by the other route
either. The Sentry SDK attaches the request to every event it captures,
@@ -2917,7 +2901,7 @@ page that was asked for.
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its query string, its request headers, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
@@ -3080,8 +3064,7 @@ webhooker/
├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 13 of 19 targets shim script/; 6 are inline
├── go.mod / go.sum
├── package.json / yarn.lock # ESLint, prettier and yarn, pinned, for the JavaScript lint and Markdown stages
├── .yarnrc.yml # yarn settings: install into node_modules/
├── package.json / yarn.lock # ESLint and prettier, pinned, for the JavaScript lint and Markdown stages
├── eslint.config.mjs # ESLint configuration for static/js/
├── .prettierrc # prettier settings for the Markdown
└── .golangci.yml # golangci-lint configuration
@@ -3369,17 +3352,13 @@ ESLint never runs on the host either. It lints `static/js/` (not the extracted
Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after
`Dockerfile.lint` and the image build runs before the builder stage. Its version
is pinned in `package.json` and every package's hash in `yarn.lock`. The
`js-deps` stage before it installs ESLint with `yarn install --immutable`, which
fails rather than change `yarn.lock`. The yarn it runs is the one the
`packageManager` field in `package.json` pins by version and hash, which the
node image's own corepack fetches and checks. The stage stays cached until
`package.json`, `yarn.lock` or `.yarnrc.yml` changes, so only the lint step
re-runs and ESLint is not downloaded again. `eslint.config.mjs` turns on the
rules of the JavaScript styleguide `REPO_POLICIES.md` links to that a linter can
check: `no-var` and `prefer-const`. ESLint prints nothing on a pass, so
`script/lint` has no summary line to look for; it names the stage once for both
`--target` and `--no-cache-filter`, and `--target` fails on a name that matches
no stage.
`js-deps` stage before it installs ESLint and stays cached until either file
changes, so only the lint step re-runs and ESLint is not downloaded again.
`eslint.config.mjs` turns on the rules of the JavaScript styleguide
`REPO_POLICIES.md` links to that a linter can check: `no-var` and
`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no summary
line to look for; it names the stage once for both `--target` and
`--no-cache-filter`, and `--target` fails on a name that matches no stage.
prettier formats the Markdown, and it never runs on the host either. It is
pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same
@@ -3405,9 +3384,8 @@ independently of the compiler's:
`@source` lines name. `css-check` fails when the committed file differs from
the generated one, and `make css` writes the generated file out from
`css-output` (see [Stylesheet](#stylesheet)).
3. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn
`package.json` pins, run through the image's corepack) — `js-deps` installs
ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over
3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — `js-deps`
installs ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over
`static/js/` (see [Linting](#linting)).
4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the
Markdown and fails on any file it would change, and `make fmt` writes the
+1 -3
View File
@@ -30,10 +30,8 @@ type Event struct {
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data. RawQuery is the receiving request's query string
// as sent, without the leading "?".
// Request data
Method string `gorm:"not null" json:"method"`
RawQuery string `gorm:"type:text" json:"rawQuery"`
Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"`
-3
View File
@@ -110,7 +110,6 @@ type Task struct {
MaxRetries int
Method string
RawQuery string
Headers string
ContentType string
Body *string
@@ -1753,7 +1752,6 @@ func buildEventFromTask(task *Task) database.Event {
event := database.Event{
EntrypointID: task.EntrypointID,
Method: task.Method,
RawQuery: task.RawQuery,
Headers: task.Headers,
ContentType: task.ContentType,
}
@@ -2104,7 +2102,6 @@ func buildRecoveryTask(
TargetConfig: target.Config,
MaxRetries: target.MaxRetries,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
@@ -673,11 +673,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
t, s.WebhookDB, s.WebhookID, targetID, 3,
)
// A recovered delivery still carries its event's query string.
require.NoError(t, s.WebhookDB.Model(&database.Event{}).
Where("webhook_id = ?", s.WebhookID).
Update("raw_query", eventQuery).Error)
s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB,
s.WebhookID,
@@ -692,8 +687,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
database.TargetTypeLog,
task.TargetType,
)
assert.Equal(t, eventQuery, task.RawQuery)
case <-time.After(2 * time.Second):
t.Fatalf("expected task %d", i)
}
-6
View File
@@ -11,7 +11,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -1991,10 +1990,6 @@ func assertLogLineComplete(
"log line must contain the full request headers",
)
assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery),
"log line must contain the query string",
)
assert.Contains(t, out, event.EntrypointID,
"log line must contain the entrypoint id",
)
@@ -2017,7 +2012,6 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
event := seedEvent(
t, db, `{"log-body-marker":"abc123"}`,
)
event.RawQuery = eventQuery
dlv := seedDelivery(
t, db, event.ID, uuid.New().String(),
+3 -7
View File
@@ -39,9 +39,6 @@ type TargetConfigForm struct {
// Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset.
Timeout string
// ForwardQuery is the HTTP target's setting that passes each
// event's query string on to it.
ForwardQuery bool
// Expiry is the database (archive) target's row expiry.
Expiry string
// Rotation is the database (archive) target's rotation.
@@ -67,10 +64,9 @@ func NewTargetConfigForm(
}
return TargetConfigForm{
URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout),
ForwardQuery: cfg.ForwardQuery,
URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout),
}, nil
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
-7
View File
@@ -171,13 +171,6 @@ func httpConfigFields(t *database.Target) []ConfigField {
})
}
if cfg.ForwardQuery {
fields = append(fields, ConfigField{
Label: "Query string",
Value: "passed on to this target",
})
}
fields = append(fields, maxRetriesField(t))
return fields
+1 -3
View File
@@ -223,8 +223,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` +
`"forwardQuery":true}`,
`"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5,
})
@@ -236,7 +235,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin,
"Timeout": "30s",
"Headers": "1 configured",
"Query string": "passed on to this target",
viewMaxRetries: "5",
},
fields,
-1
View File
@@ -184,7 +184,6 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
@@ -101,7 +101,6 @@ type archivedEvent struct {
WebhookID string
EntrypointID string
Method string
RawQuery string
Headers string
Body string
ContentType string
@@ -360,7 +360,6 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error {
"webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID,
"method": ev.Method,
"raw_query": ev.RawQuery,
"headers": ev.Headers,
"body": ev.Body,
"content_type": ev.ContentType,
@@ -166,7 +166,6 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
RawQuery: eventQuery,
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
@@ -216,13 +215,12 @@ func assertExportedRow(
assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.RawQuery, ev["raw_query"])
assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev)
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return
}
@@ -231,7 +229,7 @@ func assertExportedRow(
require.NoError(t, err)
assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev)
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
}
// TestArchiveExport_Empty proves an archive with nothing in it exports
@@ -85,7 +85,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
event.RawQuery = eventQuery
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
env.eng.ExportDeliverDatabase(webhookDB, d)
@@ -114,7 +113,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
assert.Equal(t, event.ID, rows[0].EventID)
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
assert.Equal(t, event.Method, rows[0].Method)
assert.Equal(t, eventQuery, rows[0].RawQuery)
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
}
-23
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"sync"
"time"
@@ -33,11 +32,6 @@ type HTTPTargetConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"`
// ForwardQuery passes each event's query string on to the target,
// appended to URL. Off, the target URL is sent exactly as
// configured.
ForwardQuery bool `json:"forwardQuery,omitempty"`
}
// httpCore holds the retry, backoff, and circuit-breaker
@@ -450,10 +444,6 @@ func (t *httpTarget) doHTTPRequest(
)
}
if cfg.ForwardQuery {
appendQuery(req.URL, event.RawQuery)
}
originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(),
)
@@ -484,19 +474,6 @@ func (t *httpTarget) doHTTPRequest(
return resp.StatusCode, string(body), dur, nil
}
// appendQuery adds an event's query string to a delivery's URL, joined
// with "&" to any query string the target URL already has.
func appendQuery(u *url.URL, rawQuery string) {
switch {
case rawQuery == "":
return
case u.RawQuery == "":
u.RawQuery = rawQuery
default:
u.RawQuery += "&" + rawQuery
}
}
// clientForRequest returns the client for one delivery attempt.
// originScoped is the header set applyRequestHeaders built for that
// attempt; a request with neither a per-target timeout nor an
-172
View File
@@ -1,172 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// eventQuery is the query string the events in these tests arrived
// with.
const eventQuery = "a=1&b=2"
// httpTargetConfig is the stored configuration of an HTTP target at
// targetURL.
func httpTargetConfig(
t *testing.T, targetURL string, forwardQuery bool,
) string {
t.Helper()
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL, ForwardQuery: forwardQuery,
})
require.NoError(t, err)
return string(cfg)
}
// deliverWithQuery sends one event that arrived with eventQuery to an
// HTTP target configured with cfg, through the path a received event's
// delivery takes, and returns the attempt it recorded.
func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
t.Helper()
s := newISetup(t)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}")
d := iSeedDelivery(
t, s.WebhookDB, event.ID, uuid.NewString(),
database.DeliveryStatusPending,
)
task := iTask(
d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body,
)
task.RawQuery = eventQuery
s.Engine.ExportProcessNewTask(context.TODO(), &task)
var result database.DeliveryResult
require.NoError(t, s.WebhookDB.Where(
"delivery_id = ?", d.ID,
).First(&result).Error)
return result
}
// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to:
// with the target's setting off, the target URL exactly as configured;
// with it on, the event's query string appended, joined with "&" to a
// query string the target URL already has.
func TestDeliverHTTP_ForwardQuery(t *testing.T) {
t.Parallel()
// The target URL's path, without and with a query string of its
// own.
const (
plain = "/in"
withQuery = "/in?key=k"
)
tests := map[string]struct {
path string
forwardQuery bool
want string
}{
"off": {
path: plain, want: plain,
},
"off, the target URL has a query string": {
path: withQuery, want: withQuery,
},
"on": {
path: plain, forwardQuery: true, want: plain + "?" + eventQuery,
},
"on, the target URL has a query string": {
path: withQuery, forwardQuery: true,
want: withQuery + "&" + eventQuery,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusOK)
},
))
t.Cleanup(ts.Close)
result := deliverWithQuery(t, httpTargetConfig(
t, ts.URL+tc.path, tc.forwardQuery,
))
assert.True(t, result.Success)
require.Len(t, received, 1)
assert.Equal(t, tc.want, <-received)
})
}
}
// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the
// credential in a target URL's own query string stays masked once the
// event's query string is appended to it: in a response or error that
// echoes the URL the target was sent, as the event log's Redactor shows
// it, and in the error a failed connection stores.
func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusBadRequest)
},
))
t.Cleanup(ts.Close)
target := &database.Target{
Type: database.TargetTypeHTTP,
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
}
deliverWithQuery(t, target.Config)
require.Len(t, received, 1)
sent := <-received
require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent)
redactor := delivery.NewRedactor(target)
for _, echoed := range []string{sent, ts.URL + sent} {
shown := redactor.Redact("rejected " + echoed)
assert.NotContains(t, shown, secret, echoed)
assert.Contains(t, shown, delivery.RedactionMarker, echoed)
}
// Nothing listens on port 1.
failed := deliverWithQuery(t, httpTargetConfig(
t, "http://127.0.0.1:1/in?token="+secret, true,
))
require.NotEmpty(t, failed.Error)
assert.NotContains(t, failed.Error, secret)
assert.NotContains(t, failed.Error, eventQuery)
}
+3 -4
View File
@@ -9,9 +9,9 @@ import (
)
// logTarget is a fire-and-forget target that logs the entire
// inbound webhook — the full request body, query string and
// headers, plus the method, content type, and the webhook and
// entrypoint ids — then records a single successful attempt.
// inbound webhook — the full request body and headers, plus
// the method, content type, and the webhook and entrypoint
// ids — then records a single successful attempt.
//
// This is the one log call in the service that deliberately writes
// unbounded client-chosen bytes, so it is the one exception to the
@@ -46,7 +46,6 @@ func (t *logTarget) Deliver(
"webhook_id", d.Event.WebhookID,
"entrypoint_id", d.Event.EntrypointID,
"method", d.Event.Method,
"raw_query", d.Event.RawQuery,
"content_type", d.Event.ContentType,
"headers", d.Event.Headers,
"body", d.Event.Body,
+16 -19
View File
@@ -162,22 +162,18 @@ func targetSecrets(t *database.Target) []string {
}
// urlSecrets returns the substrings of a destination URL that
// must not survive into a rendered page: the whole URL; its
// path, unless that is empty or "/"; its query string, and the
// request URI that carries it, which a remote echoing the
// request line shows even when the URL has no path; and its
// userinfo and password.
// must not survive into a rendered page: the whole URL, the
// parts of it MaskURL elides, and any userinfo.
//
// No length floor is applied to the path, the query string or
// the userinfo. A short path or a four-byte username is
// treated as a credential exactly like a long one, because the
// field takes an arbitrary URL and no part of it can be
// assumed non-secret — the same rule MaskURL applies.
// headerSecrets does carry a floor, and the difference is
// deliberate: a header is picked out by a name-shaped guess
// and its value may be ordinary text, whereas a URL's path,
// query string and userinfo are credential material by
// position.
// No length floor is applied to the path, and none to the
// userinfo. A short path or a four-byte username is treated as
// a credential exactly like a long one, because the field takes
// an arbitrary URL and no part of it can be assumed non-secret —
// the same rule MaskURL applies. headerSecrets does carry a
// floor, and the difference is deliberate: a header is picked
// out by a name-shaped guess and its value may be ordinary
// text, whereas a URL's path and userinfo are credential
// material by position.
func urlSecrets(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -192,11 +188,12 @@ func urlSecrets(raw string) []string {
}
if parsed.Path != "" && parsed.Path != "/" {
secrets = append(secrets, parsed.EscapedPath())
}
requestURI := parsed.RequestURI()
secrets = append(secrets, requestURI)
if parsed.RawQuery != "" {
secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery)
if escaped := parsed.EscapedPath(); escaped != requestURI {
secrets = append(secrets, escaped)
}
}
if parsed.User != nil {
-42
View File
@@ -202,48 +202,6 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
}
}
// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an
// HTTP target URL whose credential is all in its query string.
// Written with or without the "/", the request line sends it
// as "/?token=…", and a target passing the event's query string
// on sends that after an "&". The event's part stays visible:
// the event's page shows it anyway.
func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
marker := delivery.RedactionMarker
// An echoed request line, and what the event log shows of it.
echoes := map[string]string{
"POST /?token=" + secret + " HTTP/1.1": "POST " + marker +
" HTTP/1.1",
"POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker +
" HTTP/1.1",
"POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " +
marker + "&a=1&b=2 HTTP/1.1",
"POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" +
marker + "&a=1&b=2 HTTP/1.1",
}
for _, dest := range []string{
"https://example.com/?token=" + secret,
"https://example.com?token=" + secret,
} {
r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + dest + `"}`,
})
for echoed, want := range echoes {
assert.Equal(
t, want, r.Redact(echoed), "%s: %s", dest, echoed,
)
}
}
}
// TestRedactor_LeavesUnrelatedTextAlone pins that the
// redactor matches literally: it does not guess at what a
// secret looks like, so ordinary response content survives.
-1
View File
@@ -310,7 +310,6 @@ func createReplayDelivery(
TargetConfig: target.Config,
MaxRetries: target.MaxRetries,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
@@ -26,9 +26,6 @@ const paramDeliveryID = "deliveryID"
// dispatches to it: the notifier is recorded, not run.
const replayTargetURL = "http://93.184.216.34/hook"
// replayEventQuery is the query string a seeded event arrived with.
const replayEventQuery = "a=1&b=2"
// seedFailedDelivery records an event, a terminally failed delivery of
// it to the given target, and the attempt that failed.
func seedFailedDelivery(
@@ -45,7 +42,6 @@ func seedFailedDelivery(
WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost,
RawQuery: replayEventQuery,
Headers: `{"X-Test":["yes"]}`,
Body: `{"replay":"me"}`,
ContentType: contentTypeJSON,
@@ -300,10 +296,6 @@ func assertReplayTask(
"replay must use the target's current configuration",
)
assert.Equal(t, event.Method, task.Method)
assert.Equal(
t, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
+1 -2
View File
@@ -324,8 +324,7 @@ func loadEventLogRows(
var rows []eventLogRow
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns,
maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes,
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents, err
+7 -29
View File
@@ -17,23 +17,19 @@ import (
// bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an
// oversized body, query string or set of request headers never
// becomes a Go string at all.
// oversized body or set of request headers never becomes a Go
// string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, " +
"substr(cast(raw_query as blob), 1, ?) AS raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, " +
"substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which
// shows the whole body, the whole query string and every request
// header.
// shows the whole body and every request header.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, headers, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
@@ -61,13 +57,6 @@ type EventLogView struct {
// entrypoint's secret.
Entrypoint string
// RawQuery is the query string the event arrived with.
// RawQueryCut reports one left out, RawQuery then empty, because
// it holds more than maxRenderedBodyBytes; only the event log
// leaves it out.
RawQuery string
RawQueryCut bool
// Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than
@@ -96,9 +85,9 @@ func (v EventLogView) ResubmittedFrom() bool {
}
// eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its query string, headers and
// body columns arrive already cut to the cap by SQLite, each with
// its true size beside it.
// eventColumns. In the event log its headers and body columns
// arrive already cut to the cap by SQLite, each with its true
// size beside it.
type eventLogRow struct {
ID string
CreatedAt time.Time
@@ -106,8 +95,6 @@ type eventLogRow struct {
ContentType string
ResubmittedFromID *string
EntrypointID string
RawQuery string
RawQueryBytes int64
Headers string
HeadersBytes int64
Body []byte
@@ -127,13 +114,6 @@ func (r *eventLogRow) view(
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
rawQuery := r.RawQuery
rawQueryCut := r.RawQueryBytes > int64(len(rawQuery))
if rawQueryCut {
rawQuery = ""
}
return EventLogView{
ID: r.ID,
Method: r.Method,
@@ -143,8 +123,6 @@ func (r *eventLogRow) view(
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
RawQuery: rawQuery,
RawQueryCut: rawQueryCut,
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from,
-71
View File
@@ -1,16 +1,13 @@
package handlers_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -119,7 +116,6 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, "No query string.")
assert.Contains(t, page, headerBox(
"Accept: */*",
"User-Agent: shop/1 build\t7",
@@ -335,70 +331,3 @@ func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
})
}
}
// TestHandleWebhook_StoresAndShowsTheQueryString posts to an
// entrypoint's URL with a query string and proves the event stores it
// as sent, and shows it escaped in the event log and on its own page,
// in a box like the one the request headers show in.
func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := seedEntrypoint(t, f.db, f.webhook.ID)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", ep.Path)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, "a=1&b=2", stored.RawQuery)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, headerBox("a=1&amp;b=2"))
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox("a=1&amp;b=2"))
}
// TestEventRequest_QueryStringOverTheLimit proves the event log leaves
// out a query string that holds more than it shows of a body, and links
// to the event's own page, which shows it whole.
func TestEventRequest_QueryStringOverTheLimit(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, `{}`, time.Now())
query := "q=" + strings.Repeat("x", bodyCap)
require.NoError(t, f.webhookDB.Model(event).Update(
"raw_query", query,
).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, `<a href="/hook/`+f.webhook.ID+`/events/`+
event.ID+`" class="btn-small">Show the query string</a>`)
assert.NotContains(t, page, "q=x")
assert.Less(t, len(page), 4*bodyCap)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox(query))
assert.NotContains(t, w.Body.String(), "Show the query string")
}
+1 -3
View File
@@ -30,7 +30,6 @@ type resubmitSource struct {
ID string
EntrypointID string
Method string
RawQuery string
Headers string
ContentType string
Body []byte
@@ -40,7 +39,7 @@ type resubmitSource struct {
// The cast to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, the same reason eventBodyQuery
// casts.
const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " +
const resubmitColumns = "id, entrypoint_id, method, headers, " +
"content_type, cast(body as blob) AS body"
// HandleEventResubmit re-injects a stored event as a new undelivered
@@ -194,7 +193,6 @@ func (h *Handlers) queueResubmit(
WebhookID: webhook.ID,
EntrypointID: src.EntrypointID,
Method: src.Method,
RawQuery: src.RawQuery,
HeadersJSON: src.Headers,
ContentType: src.ContentType,
Body: src.Body,
+3 -10
View File
@@ -22,13 +22,9 @@ import (
// dispatches to it: the notifier is recorded, not run.
const resubmitTargetURL = "http://93.184.216.34/hook"
// resubmitEventHeaders and resubmitEventQuery are the stored header
// JSON and query string a seeded event carries, so a test can prove the
// copy takes them verbatim.
const (
resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
resubmitEventQuery = "a=1&b=2"
)
// resubmitEventHeaders is the stored header JSON a seeded event
// carries, so a test can prove the copy takes it verbatim.
const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
// seedStoredEvent records one event in a webhook's own database with
// no deliveries at all, which is the state a captured event is in when
@@ -47,7 +43,6 @@ func seedStoredEvent(
WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost,
RawQuery: resubmitEventQuery,
Headers: resubmitEventHeaders,
Body: body,
ContentType: contentTypeJSON,
@@ -207,7 +202,6 @@ func assertEventCopy(
t.Helper()
assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, resubmitEventQuery, fresh.RawQuery)
assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
@@ -242,7 +236,6 @@ func assertResubmitTask(
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, fresh.Method, task.Method)
assert.Equal(t, fresh.RawQuery, task.RawQuery)
assert.Equal(t, fresh.Headers, task.Headers)
assert.Equal(t, fresh.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
+13 -18
View File
@@ -173,9 +173,6 @@ type targetFormInput struct {
Headers string
// Timeout is an HTTP target's per-request timeout in seconds.
Timeout string
// ForwardQuery is an HTTP target's checkbox that passes each
// event's query string on to it.
ForwardQuery bool
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry.
@@ -198,15 +195,14 @@ type targetFormInput struct {
// tokens.
func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
ForwardQuery: r.PostFormValue("forward_query") != "",
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
@@ -236,8 +232,8 @@ func (h *Handlers) buildTargetConfig(
}
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers, timeout and
// query string setting the delivery path honours.
// SSRF-validated destination plus the optional headers and timeout
// the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context,
in targetFormInput,
@@ -260,10 +256,9 @@ func (h *Handlers) buildHTTPTargetConfig(
}
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
ForwardQuery: in.ForwardQuery,
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
return configJSON, "", err
+7 -8
View File
@@ -77,14 +77,13 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
}
form := targetFormInput{
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
Rotation: cfg.Rotation,
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
Rotation: cfg.Rotation,
}
h.renderTargetEdit(
-53
View File
@@ -442,59 +442,6 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// TestHandleTarget_ForwardQuery covers the HTTP target's setting that
// passes each event's query string on to it: the add target form
// stores it checked, the edit form starts with it checked and turns it
// off when saved unchecked, and both forms come back with it checked
// when refused.
func TestHandleTarget_ForwardQuery(t *testing.T) {
t.Parallel()
const checkbox = `name="forward_query" value="on" checked`
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/hook/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "forwarding")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", editOriginalURL)
form.Set("forward_query", "on")
w := serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
edit := editForm(editOriginalURL, "", "")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
form.Set("url", editBlockedURL)
w = serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "data-forward-query")
}
// TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage.
//
-4
View File
@@ -230,7 +230,6 @@ type eventSource struct {
WebhookID string
EntrypointID string
Method string
RawQuery string
HeadersJSON string
ContentType string
Body []byte
@@ -246,7 +245,6 @@ func (s eventSource) event() *database.Event {
WebhookID: s.WebhookID,
EntrypointID: s.EntrypointID,
Method: s.Method,
RawQuery: s.RawQuery,
Headers: s.HeadersJSON,
Body: string(s.Body),
BodyBytes: int64(len(s.Body)),
@@ -266,7 +264,6 @@ func requestEventSource(
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
RawQuery: r.URL.RawQuery,
HeadersJSON: string(headersJSON),
ContentType: r.Header.Get("Content-Type"),
Body: body,
@@ -444,7 +441,6 @@ func buildDeliveryTasks(
TargetConfig: targets[i].Config,
MaxRetries: targets[i].MaxRetries,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
+4 -19
View File
@@ -507,10 +507,9 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
t.Helper()
const (
refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]`
forwardQuery = `form[action$="/targets"] input[name="forward_query"]`
reason = `//div[@class="alert-error"]`
refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]`
reason = `//div[@class="alert-error"]`
)
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
@@ -520,7 +519,6 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx,
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
chromedp.Click(forwardQuery, chromedp.ByQuery),
))
click(ctx, t, saveButton)
@@ -528,26 +526,18 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
assert.True(t, shown(ctx, reason),
"a refused target does not show the reason")
var (
name, typed string
checked bool
)
var name, typed string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
))
assert.Equal(t, "refused", name,
"a refused target does not keep the name entered")
assert.Equal(t, refusedURL, typed,
"a refused target does not keep the url entered")
assert.True(t, checked,
"a refused target does not keep the query string setting checked")
assert.True(t, shown(ctx, targetName),
"a refused target does not come back with the form open")
assert.True(t, hidden(ctx, typeSelect),
@@ -563,15 +553,10 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
))
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
assert.False(t, checked,
"after Cancel, the next Add keeps the query string setting checked")
}
// checkTargetDeliveries loads a webhook page and checks that the row of
+1 -2
View File
@@ -3,6 +3,5 @@
"devDependencies": {
"eslint": "10.11.0",
"prettier": "3.9.9"
},
"packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f"
}
}
-4
View File
@@ -138,7 +138,6 @@ document.addEventListener("alpine:init", function () {
url: "",
headers: "",
timeout: "",
forwardQuery: false,
maxRetries: "",
expiry: "",
rotation: "",
@@ -151,8 +150,6 @@ document.addEventListener("alpine:init", function () {
this.url = refused.destination;
this.headers = refused.headers;
this.timeout = refused.timeout;
this.forwardQuery =
this.$root.hasAttribute("data-forward-query");
this.maxRetries = refused.maxRetries;
this.expiry = refused.expiry;
this.rotation = refused.rotation;
@@ -172,7 +169,6 @@ document.addEventListener("alpine:init", function () {
this.url = "";
this.headers = "";
this.timeout = "";
this.forwardQuery = false;
this.maxRetries = "";
this.expiry = "";
this.rotation = "";
+5 -13
View File
@@ -1,23 +1,15 @@
{{define "event_request"}}
<!-- The entrypoint an event arrived at, its query string and its
request headers, as handlers.EventLogView carries them: the same in
the event log and the event's own page. The entrypoint's URL is
never shown. A resubmitted copy, even a copy of a copy, did not
arrive at an entrypoint; the request it copies did. -->
<!-- The entrypoint an event arrived at and its request headers, as
handlers.EventLogView carries them: the same in the event log and
the event's own page. The entrypoint's URL is never shown. A
resubmitted copy, even a copy of a copy, did not arrive at an
entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs">
{{if .ResubmittedFrom}}
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{else}}
<p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{end}}
{{if .RawQueryCut}}
<p class="text-gray-500">The query string is larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the query string</a></p>
{{else if .RawQuery}}
<p class="text-gray-500">Query string</p>
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.RawQuery}}</pre>
{{else}}
<p class="text-gray-500">No query string.</p>
{{end}}
{{if .HeadersCut}}
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
{{else if .Headers}}
-8
View File
@@ -135,7 +135,6 @@
data-destination="{{.TargetForm.URL}}"
data-headers="{{.TargetForm.Headers}}"
data-timeout="{{.TargetForm.Timeout}}"
{{if .TargetForm.ForwardQuery}}data-forward-query{{end}}
data-max-retries="{{.TargetForm.MaxRetries}}"
data-expiry="{{.TargetForm.Expiry}}"
data-rotation="{{.TargetForm.Rotation}}">
@@ -184,13 +183,6 @@
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
</div>
<div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="forward_query" value="on" :checked="forwardQuery" class="h-4 w-4">
Pass the query string on to this target
</label>
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the URL above, after any query string the URL already has.</p>
</div>
<div>
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label>
-8
View File
@@ -47,14 +47,6 @@
<input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
</div>
<div class="form-group">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="checkbox" id="forward_query" name="forward_query" value="on"{{if .TargetForm.ForwardQuery}} checked{{end}} class="h-4 w-4">
Pass the query string on to this target
</label>
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the destination URL, after any query string the URL already has.</p>
</div>
{{end}}
{{if eq .Target.Type "slack"}}
+415 -605
View File
File diff suppressed because it is too large Load Diff