Use one name for each thing the UI shows (closes #399)
check / check (push) In progress

The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged.

Model: opus-5-5
This commit was merged in pull request #491.
This commit is contained in:
2026-10-03 05:07:41 +02:00
parent 74a96b2226
commit b9ec91c0f7
23 changed files with 180 additions and 98 deletions
+22 -19
View File
@@ -313,7 +313,7 @@ itself; a production deployment puts a reverse proxy in front of it
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)), [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)),
and the proxy reaches it over loopback. A default that bound every and the proxy reaches it over loopback. A default that bound every
interface would leave that cleartext port answering the internet interface would leave that cleartext port answering the internet
alongside the proxy — the admin login form and the receiver, in the alongside the proxy — the admin sign-in form and the receiver, in the
clear, on a port nobody chose to publish. Reaching webhooker from clear, on a port nobody chose to publish. Reaching webhooker from
another host is therefore something you configure, not something you another host is therefore something you configure, not something you
get by default. get by default.
@@ -835,7 +835,7 @@ reports.
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy `-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
and nothing else; widen it only with a firewall or a publish and nothing else; widen it only with a firewall or a publish
address in front of it. A cleartext port answering the internet address in front of it. A cleartext port answering the internet
serves the admin login form and the unauthenticated receiver with serves the admin sign-in form and the unauthenticated receiver with
no TLS at all, and the proxy in front of it changes nothing about no TLS at all, and the proxy in front of it changes nothing about
that. that.
2. **Make sure the environment is not `dev` (leave 2. **Make sure the environment is not `dev` (leave
@@ -1617,10 +1617,11 @@ event routing.
The new webhook form can also give the webhook its first targets: an The new webhook form can also give the webhook its first targets: an
optional HTTP target URL creates an `http` target named `HTTP`, and the optional HTTP target URL creates an `http` target named `HTTP`, and the
archive checkbox creates a `database` target named `Archive` whose archive checkbox creates a `database` target named `Archive` whose
`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d `expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h,
or 365d) and whose `rotation` is the rotation chosen below that (none, 30d, 90d or 365d) and whose `rotation` is the rotation chosen below that
monthly, daily or hourly). Both are validated as on the add target form, (none, monthly, daily or hourly). Both are validated as on the add
and the webhook and its targets are created together or not at all. target form, and the webhook and its targets are created together or
not at all.
| Field | Type | Description | | Field | Type | Description |
| ---------------- | ------- | ----------- | | ---------------- | ------- | ----------- |
@@ -1707,7 +1708,7 @@ events should be forwarded.
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) | | `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration | | `config` | JSON text | Type-specific configuration |
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets | | `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries. **Relations:** Belongs to Webhook. Has many Deliveries.
@@ -1736,7 +1737,9 @@ events should be forwarded.
expiry in plain units, such as "30 days", and the rotation. No external expiry in plain units, such as "30 days", and the rotation. No external
delivery and no retries; an archive write failure fails the delivery. delivery and no retries; an archive write failure fails the delivery.
See the database target section under "Per-Webhook Event Databases" See the database target section under "Per-Webhook Event Databases"
for the full semantics. for the full semantics. The web UI calls this type an archive: its
badge, the add target form's type list and the target edit page say
so, and its settings are labelled Archive expiry and Archive rotation.
- **`log`** — Write the event to the application log (stdout). Useful - **`log`** — Write the event to the application log (stdout). Useful
for debugging. for debugging.
@@ -2588,7 +2591,7 @@ The query string is never logged; it is replaced by the fixed marker
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate `/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
limiter in front of them, so a query on a fixed 200 URL would otherwise limiter 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 buy the same amplification as an invented path. Nothing debuggable is
lost: the only query parameters this service reads are the login page's lost: the only query parameters this service reads are the sign-in page's
`next`, the page to return to, and `notice`, which names the line a page `next`, the page to return to, and `notice`, which names the line a page
shows after an action. shows after an action.
@@ -2743,9 +2746,9 @@ wider than it:
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | | `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver | | `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form | | `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form | | `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
| `password verification capacity exhausted` | `WARN` | path | yes, on the login form | | `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
`DEBUG` being off by default is not a bound. An operator turning it on `DEBUG` being off by default is not a bound. An operator turning it on
to diagnose a flood must not thereby hand the flood an unbounded write, to diagnose a flood must not thereby hand the flood an unbounded write,
@@ -2793,7 +2796,7 @@ standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/h/{uuid}` and the user lookup behind entrypoint lookup behind `/h/{uuid}` and the user lookup behind
the login form, whose path segment and submitted username the client the sign-in form, whose path segment and submitted username the client
picks outright. Every picks outright. Every
`gorm.Open` in the service now installs the adapter in `gorm.Open` in the service now installs the adapter in
`internal/gormlog` instead. It writes through the same `slog` logger as `internal/gormlog` instead. It writes through the same `slog` logger as
@@ -3081,14 +3084,14 @@ abuse limit later; they are tracked as future work.
| Method | Path | Description | | Method | Path | Description |
| ------ | --------------- | ----------- | | ------ | --------------- | ----------- |
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` | | `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` |
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | | `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
| `POST` | `/pages/logout` | Logout (destroys session) | | `POST` | `/pages/logout` | Sign out (destroys session) |
#### Authenticated Endpoints #### Authenticated Endpoints
A logged-out `GET` of any of these is redirected to `/pages/login` with A signed-out `GET` of any of these is redirected to `/pages/login` with
its path and query as `next` when they fit in 2048 bytes, so logging in its path and query as `next` when they fit in 2048 bytes, so signing in
returns to the page that was asked for. returns to the page that was asked for.
| Method | Path | Description | | Method | Path | Description |
@@ -3437,7 +3440,7 @@ check, see [The login endpoint](#the-login-endpoint).
still evaluated, so roughly 27 guesses a second get through and the still evaluated, so roughly 27 guesses a second get through and the
admin password has to carry that load (see admin password has to carry that load (see
[The login endpoint](#the-login-endpoint)). `GET` requests to the [The login endpoint](#the-login-endpoint)). `GET` requests to the
login page are not limited sign-in page are not limited
- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate): - **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
sliding-window rate limiter, 5 POST attempts per minute per bucket. sliding-window rate limiter, 5 POST attempts per minute per bucket.
It runs behind session auth, so only a client already holding a It runs behind session auth, so only a client already holding a
+8 -7
View File
@@ -177,16 +177,17 @@ func httpConfigFields(t *database.Target) []ConfigField {
} }
// maxRetriesField describes a target's retry count, which lives // maxRetriesField describes a target's retry count, which lives
// on the target row rather than in its configuration blob. // on the target row rather than in its configuration blob. A
// stored 0 makes a single attempt, so it is shown as 1.
func maxRetriesField(t *database.Target) ConfigField { func maxRetriesField(t *database.Target) ConfigField {
retries := strconv.Itoa(t.MaxRetries) attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 { if t.MaxRetries == 0 {
retries += " (fire-and-forget)" attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
} }
return ConfigField{ return ConfigField{
Label: "Max Retries", Label: "Delivery attempts",
Value: retries, Value: attempts,
} }
} }
@@ -213,10 +214,10 @@ func databaseConfigFields(configJSON string) []ConfigField {
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive Expiry", Label: "Archive expiry",
Value: value, Value: value,
}, { }, {
Label: "Archive Rotation", Label: "Archive rotation",
Value: rotation, Value: rotation,
}} }}
} }
+6 -6
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Max Retries" viewMaxRetries = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Archive Expiry": tc.want, "Archive expiry": tc.want,
"Archive Rotation": rotationNone, "Archive rotation": rotationNone,
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) {
}) })
assert.Equal( assert.Equal(
t, want, fieldMap(view.Config)["Archive Rotation"], t, want, fieldMap(view.Config)["Archive rotation"],
) )
}) })
} }
+1 -1
View File
@@ -45,7 +45,7 @@ func expiryShown(
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
return matched( return matched(
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(), `Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
) )
} }
+2 -2
View File
@@ -27,7 +27,7 @@ func rotationShown(
t.Helper() t.Helper()
return matched( return matched(
`Archive Rotation:</span>\s*<span>([^<]*)</span>`, `Archive rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID), renderedPage(t, env, webhookID),
) )
} }
@@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
assert.Equal(t, "2.0 kB", view.Size) assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID)) page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive Size: 2.0 kB in 2 files") assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
} }
// renderedPage returns the webhook page. // renderedPage returns the webhook page.
+1 -1
View File
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
))) )))
h.renderLoginError( h.renderLoginError(
w, r, w, r,
"Too many failed login attempts. Please try again later.", "Too many failed sign-in attempts. Please try again later.",
http.StatusTooManyRequests, http.StatusTooManyRequests,
) )
} }
+1 -1
View File
@@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
}, },
resubmitNoTargets: { resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " + Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.", "webhook has no active targets, so nothing was queued.",
}, },
}[noticeCode(r.URL.Query().Get(noticeParam))] }[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok { if !ok {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
data map[string]any data map[string]any
title string title string
}{ }{
{"login.html", map[string]any{}, "Login - Webhooker"}, {"login.html", map[string]any{}, "Sign in - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"}, {"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"}, {"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"}, {"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
+7 -2
View File
@@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "1 configured") assert.Contains(t, body, "1 configured")
assert.NotContains(t, body, "sekrit") assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry") // The database type is called an archive: on its badge, in the
assert.Contains(t, body, "30 days") // add target form's type list and in its settings.
list := targetList(t, body)
assert.Contains(t, list, "t-database archive Active")
assert.Contains(t, list, "Archive expiry: 30 days")
assert.Contains(t, list, "Archive rotation: none")
assert.Contains(t, body, `<option value="database">Archive</option>`)
// An unknown type gets the neutral placeholder, never the // An unknown type gets the neutral placeholder, never the
// stored blob. // stored blob.
+1 -1
View File
@@ -1749,7 +1749,7 @@ func (h *Handlers) setTargetFromForm(
// cannot destroy the count a target is delivering with. // cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil { if err != nil {
return "Invalid max retries: " + retriesErrorMessage(err), nil return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
} }
target.Name = in.Name target.Name = in.Name
+25 -1
View File
@@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
assert.Contains(t, page, "original-name") assert.Contains(t, page, "original-name")
} }
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
// edit page of a database target gives its type and its settings to
// the ones its badge and the add target form use.
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, "Type: archive.")
assert.Contains(t, page, `class="label">Archive expiry</label>`)
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// TestHandleTargetEditSubmit_Rejects covers every submission that // TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage. // must not reach storage.
// //
@@ -588,7 +612,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeSlack, database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25", "name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid max retries", "Invalid delivery attempts",
}, },
{ {
database.TargetTypeDatabase, database.TargetTypeDatabase,
+4 -4
View File
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive) path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive File:")) assert.Equal(t, 1, strings.Count(body, "Archive file:"))
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
seedArchive(t, path, 1, 100) seedArchive(t, path, 1, 100)
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet") assert.NotContains(t, body, "not created yet")
assert.Regexp(t, assert.Regexp(t,
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body, `Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
) )
assert.Contains(t, body, assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`, `title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
@@ -69,7 +69,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
body = renderSourceDetailPage(t, h, sess, wh.ID) body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
} }
// targetList returns the text of the targets section in a rendered // targetList returns the text of the targets section in a rendered
+4 -4
View File
@@ -81,10 +81,10 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+ assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
"Deliveries Paused: after repeated failures, until "+cooldownEnds+ "Deliveries paused: after repeated failures, until "+cooldownEnds+
", then one waiting delivery is sent to test the target while "+ ", then one waiting delivery is sent to test the target while "+
"the others wait at least one more cooldown", list) "the others wait at least one more cooldown", list)
assert.Equal(t, 1, strings.Count(list, "Paused")) assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
log := renderSourceLogsPage(t, h, sess, wh.ID) log := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting")) assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
@@ -105,7 +105,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+ assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
"Deliveries Paused: held while one delivery tests whether the "+ "Deliveries paused: held while one delivery tests whether the "+
"target has recovered") "target has recovered")
// Not the whole list: the add target form above the rows says UTC. // Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC") assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
@@ -115,7 +115,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
breakers.Set(target.ID, delivery.CircuitClosed, 0) breakers.Set(target.ID, delivery.CircuitClosed, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.NotContains(t, list, "Paused") assert.NotContains(t, list, "Deliveries paused")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff) assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
} }
+3 -3
View File
@@ -25,14 +25,14 @@ var (
// errRetriesInvalid signals a max_retries form value that is not // errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number. // a non-negative whole number.
errRetriesInvalid = errors.New( errRetriesInvalid = errors.New(
"retries must be a whole number of attempts", "must be a whole number",
) )
// errRetriesTooLarge signals a max_retries form value that is a // errRetriesTooLarge signals a max_retries form value that is a
// whole number but above maxTargetRetries. It is distinguished // whole number but above maxTargetRetries. It is distinguished
// from errRetriesInvalid so the message can name the ceiling // from errRetriesInvalid so the message can name the ceiling
// instead of implying the input was not a number. // instead of implying the input was not a number.
errRetriesTooLarge = errors.New("retries out of range") errRetriesTooLarge = errors.New("out of range")
) )
// parseMaxRetries interprets a max_retries form value. // parseMaxRetries interprets a max_retries form value.
@@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) { if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() + return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) + ": at most " + strconv.Itoa(maxTargetRetries) +
" retries" " attempts"
} }
return errRetriesInvalid.Error() + return errRetriesInvalid.Error() +
+32 -7
View File
@@ -354,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
// and target_http gives up once the attempt number reaches // and target_http gives up once the attempt number reaches
// max_retries), and 0 is special-cased to a single fire-and-forget // max_retries), and 0 is special-cased to a single fire-and-forget
// attempt with no circuit breaker. // attempt with no circuit breaker.
const maxRetriesHelp = "This is the total number of delivery attempts, " + const maxRetriesHelp = "How many times each delivery is attempted in " +
"not retries on top of the first: a value of 3 makes three attempts " + "all, the first attempt included. 0 means a single attempt with no " +
"in all. 0 means a single attempt with no retries and no circuit " + "retries and no circuit breaker."
"breaker."
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
// help text on both the create form (the add-target form on the webhook // label, "Delivery attempts", and help text on both the create form
// detail page) and the edit form, so the copy cannot drift back to // (the add-target form on the webhook detail page) and the edit form,
// calling the number a retry count. // so the copy cannot drift back to calling the number a retry count.
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
@@ -393,6 +392,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
}, },
) )
assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains( assert.Contains(
t, createBody, maxRetriesHelp, t, createBody, maxRetriesHelp,
"the add-target form must explain max_retries as total attempts", "the add-target form must explain max_retries as total attempts",
@@ -419,8 +419,33 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
}, },
) )
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
assert.Contains( assert.Contains(
t, editBody, maxRetriesHelp, t, editBody, maxRetriesHelp,
"the target edit form must explain max_retries as total attempts", "the target edit form must explain max_retries as total attempts",
) )
} }
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
// webhook page gives the database target its Archive checkbox creates,
// and that target's settings, to the ones the target forms use.
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
dataKeyError: "",
})
assert.Contains(t, body, "created with an archive target")
assert.Contains(t, body, `class="label">Archive expiry</label>`)
assert.Contains(t, body, `class="label">Archive rotation</label>`)
}
+17 -13
View File
@@ -318,31 +318,35 @@ const (
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
// Each target type, with the fields its add target form submits, in // Each target type, with the badge its targets are listed with and
// page order. Only http and slack have a url field. // the fields its add target form submits, in page order. Only http
// and slack have a url field.
targetTypes := []struct { targetTypes := []struct {
name string name string
badge string
fields string fields string
values map[string]string values map[string]string
}{ }{
{ {
"http", "csrf_token name type url headers timeout max_retries", "http", "http",
"csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"slack", "csrf_token name type url max_retries", "slack", "slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"database", "csrf_token name type expiry rotation", "database", "archive", "csrf_token name type expiry rotation",
map[string]string{"expiry": "720h", "rotation": "daily"}, map[string]string{"expiry": "720h", "rotation": "daily"},
}, },
{"log", "csrf_token name type", nil}, {"log", "log", "csrf_token name type", nil},
} }
for _, tt := range targetTypes { for _, tt := range targetTypes {
checkAddTarget( checkAddTarget(
ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values, ctx, t, url, tt.name, tt.badge,
strings.Fields(tt.fields), tt.values,
) )
} }
} }
@@ -353,11 +357,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
// type's own fields in place of the choice, and the form then submits // type's own fields in place of the choice, and the form then submits
// exactly fields, so a field another type uses, such as url, is absent; // exactly fields, so a field another type uses, such as url, is absent;
// Cancel closes it again. It then adds a target of the type, filling in // Cancel closes it again. It then adds a target of the type, filling in
// values, and checks that the section lists it with that type. // values, and checks that the section lists it with badge.
func checkAddTarget( func checkAddTarget(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
url, targetType string, url, targetType, badge string,
fields []string, fields []string,
values map[string]string, values map[string]string,
) { ) {
@@ -412,8 +416,8 @@ func checkAddTarget(
click(ctx, t, saveButton) click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+ assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+targetType+`"]`), `"]/following-sibling::div/span[text()="`+badge+`"]`),
"%s: the added target is not listed with its type", targetType) "%s: the added target is not listed as %s", targetType, badge)
} }
// chooseTargetType clicks Add, picks targetType and clicks Next, and // chooseTargetType clicks Add, picks targetType and clicks Next, and
@@ -466,10 +470,10 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
assert.Equal(t, "none", startRotation, assert.Equal(t, "none", startRotation,
"the add target form's archive rotation does not start on none") "the add target form's archive rotation does not start on none")
assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+ assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+
`/following-sibling::span[text()="30 days"]`), `/following-sibling::span[text()="30 days"]`),
"a database target added with 720h is not listed as 30 days") "a database target added with 720h is not listed as 30 days")
assert.True(t, shown(ctx, row+`//span[text()="Archive Rotation:"]`+ assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+
`/following-sibling::span[text()="daily"]`), `/following-sibling::span[text()="daily"]`),
"a database target added with daily is not listed as daily") "a database target added with daily is not listed as daily")
+3 -3
View File
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
csrfForm(token), cookies, csrfForm(token), cookies,
), ),
intrudersLogs, "resubmit-no-targets", intrudersLogs, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
} }
+21 -1
View File
@@ -914,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil) env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
} }
// TestSignInAndSignOutWording pins one wording for both: the sign-in
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
// the sign-in page's heading, the error page's link and the notice
// after signing out do.
func TestSignInAndSignOutWording(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
assert.Contains(
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
)
userID, _ := env.seedUser(t, "reader", "somepassword")
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
"the desktop and the mobile navbar each say Sign out")
}
// --- /user/{username} group --- // --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -1363,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
) )
env.requireNotice( env.requireNotice(
t, w, logsPath, "resubmit-no-targets", t, w, logsPath, "resubmit-no-targets",
"this source has no active targets", cookies, "this webhook has no active targets", cookies,
) )
webhookDB, err := env.dbMgr.GetDB(wh.ID) webhookDB, err := env.dbMgr.GetDB(wh.ID)
+2 -2
View File
@@ -1,6 +1,6 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}Login - Webhooker{{end}} {{define "title"}}Sign in - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4"> <div class="min-h-screen flex items-center justify-center py-12 px-4">
@@ -52,7 +52,7 @@
> >
</div> </div>
<button type="submit" class="btn-primary w-full py-3">Sign In</button> <button type="submit" class="btn-primary w-full py-3">Sign in</button>
</form> </form>
</div> </div>
</div> </div>
+2 -2
View File
@@ -32,7 +32,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary">Logout</button> <button type="submit" class="btn-secondary">Sign out</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
@@ -49,7 +49,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary w-full">Logout</button> <button type="submit" class="btn-secondary w-full">Sign out</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
+10 -10
View File
@@ -159,7 +159,7 @@
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1"> <select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
<option value="http">HTTP</option> <option value="http">HTTP</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="database">Database</option> <option value="database">Archive</option>
<option value="log">Log</option> <option value="log">Log</option>
</select> </select>
<button type="button" @click="next" class="btn-primary text-sm">Next</button> <button type="button" @click="next" class="btn-primary text-sm">Next</button>
@@ -182,10 +182,10 @@
</div> </div>
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -198,10 +198,10 @@
</div> </div>
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label> <label class="text-sm text-gray-700">Delivery attempts:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -251,7 +251,7 @@
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span> <span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge-info">{{.Type}}</span> <span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
@@ -275,7 +275,7 @@
</div> </div>
{{with .Paused}} {{with .Paused}}
<div class="text-xs text-yellow-600 mt-1"> <div class="text-xs text-yellow-600 mt-1">
<span class="font-medium">Deliveries Paused:</span> <span class="font-medium">Deliveries paused:</span>
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span> <span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
</div> </div>
{{end}} {{end}}
@@ -287,17 +287,17 @@
{{end}} {{end}}
{{with .Archive}} {{with .Archive}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive File:</span> <span class="font-medium text-gray-700">Archive file:</span>
<span class="break-all">{{.Name}}</span> <span class="break-all">{{.Name}}</span>
{{with .Note}}<span>({{.}})</span>{{end}} {{with .Note}}<span>({{.}})</span>{{end}}
</div> </div>
{{if .Files}} {{if .Files}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive Size:</span> <span class="font-medium text-gray-700">Archive size:</span>
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span> <span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span>
</div> </div>
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last Written:</span> <span class="font-medium text-gray-700">Last written:</span>
<span title="{{.WrittenUTC}}">{{.Written}}</span> <span title="{{.WrittenUTC}}">{{.Written}}</span>
</div> </div>
{{end}} {{end}}
+2 -2
View File
@@ -46,9 +46,9 @@
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4"> <input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
Archive Archive
</label> </label>
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p> <p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with an archive target that keeps a copy of every event.</p>
<div x-show="open" x-cloak class="mt-3"> <div x-show="open" x-cloak class="mt-3">
<label for="archive_expiry" class="label">Archive pruning</label> <label for="archive_expiry" class="label">Archive expiry</label>
<select id="archive_expiry" name="archive_expiry" class="input"> <select id="archive_expiry" name="archive_expiry" class="input">
{{range .ArchiveExpiryChoices}} {{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
+5 -5
View File
@@ -7,7 +7,7 @@
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p> <p class="text-sm text-gray-500 mt-1">Type: {{if eq .Target.Type "database"}}archive{{else}}{{.Target.Type}}{{end}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div> </div>
<div class="card p-6"> <div class="card p-6">
@@ -59,7 +59,7 @@
{{if eq .Target.Type "database"}} {{if eq .Target.Type "database"}}
<div class="form-group"> <div class="form-group">
<label for="expiry" class="label">Archive Expiry</label> <label for="expiry" class="label">Archive expiry</label>
<select id="expiry" name="expiry" class="input"> <select id="expiry" name="expiry" class="input">
{{range .ArchiveExpiryChoices}} {{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -69,7 +69,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="rotation" class="label">Archive Rotation</label> <label for="rotation" class="label">Archive rotation</label>
<select id="rotation" name="rotation" class="input"> <select id="rotation" name="rotation" class="input">
{{range .ArchiveRotationChoices}} {{range .ArchiveRotationChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -81,9 +81,9 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_retries" class="label">Max retries</label> <label for="max_retries" class="label">Delivery attempts</label>
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input"> <input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
{{end}} {{end}}