diff --git a/README.md b/README.md index c026e6b..ab4cbda 100644 --- a/README.md +++ b/README.md @@ -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)), and the proxy reaches it over loopback. A default that bound every 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 another host is therefore something you configure, not something you get by default. @@ -835,7 +835,7 @@ reports. `-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 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 that. 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 optional HTTP target URL creates an `http` target named `HTTP`, and the archive checkbox creates a `database` target named `Archive` whose -`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d -or 365d) and whose `rotation` is the rotation chosen below that (none, -monthly, daily or hourly). Both are validated as on the add target form, -and the webhook and its targets are created together or not at all. +`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h, +30d, 90d or 365d) and whose `rotation` is the rotation chosen below that +(none, monthly, daily or hourly). Both are validated as on the add +target form, and the webhook and its targets are created together or +not at all. | Field | Type | Description | | ---------------- | ------- | ----------- | @@ -1707,7 +1708,7 @@ events should be forwarded. | `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `active` | boolean | Whether deliveries are enabled (default: true) | | `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. @@ -1736,7 +1737,9 @@ events should be forwarded. expiry in plain units, such as "30 days", and the rotation. No external delivery and no retries; an archive write failure fails the delivery. 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 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 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 -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 shows after an action. @@ -2743,9 +2746,9 @@ wider than it: | `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | | `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver | -| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form | -| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form | -| `password verification capacity exhausted` | `WARN` | path | 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 sign-in 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 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 this service's lookups miss by design on unauthenticated routes: the 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 `gorm.Open` in the service now installs the adapter in `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 | | ------ | --------------- | ----------- | -| `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 `/` | -| `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/logout` | Logout (destroys session) | +| `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` | 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` | Sign out (destroys session) | #### Authenticated Endpoints -A logged-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 +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 signing in returns to the page that was asked for. | 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 admin password has to carry that load (see [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): sliding-window rate limiter, 5 POST attempts per minute per bucket. It runs behind session auth, so only a client already holding a diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 575706e..ad8fbcd 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -177,16 +177,17 @@ func httpConfigFields(t *database.Target) []ConfigField { } // 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 { - retries := strconv.Itoa(t.MaxRetries) + attempts := strconv.Itoa(t.MaxRetries) if t.MaxRetries == 0 { - retries += " (fire-and-forget)" + attempts = "1 (fire-and-forget: no retries, no circuit breaker)" } return ConfigField{ - Label: "Max Retries", - Value: retries, + Label: "Delivery attempts", + Value: attempts, } } @@ -213,10 +214,10 @@ func databaseConfigFields(configJSON string) []ConfigField { } return []ConfigField{{ - Label: "Archive Expiry", + Label: "Archive expiry", Value: value, }, { - Label: "Archive Rotation", + Label: "Archive rotation", Value: rotation, }} } diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 923bd78..e92e0f9 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -32,7 +32,7 @@ const ( viewMaskedOrigin = viewExampleOrigin + "/..." viewUnavailable = "(unavailable)" viewExpiryNever = "never" - viewMaxRetries = "Max Retries" + viewMaxRetries = "Delivery attempts" ) func TestMaskedWebhookURL(t *testing.T) { @@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) { t, map[string]string{ "Webhook URL": slackMaskedURL, - viewMaxRetries: "0 (fire-and-forget)", + viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", }, fieldMap(view.Config), ) @@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) { t, map[string]string{ "Destination URL": viewMaskedOrigin, - viewMaxRetries: "0 (fire-and-forget)", + viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", }, fieldMap(view.Config), ) @@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) { assert.Equal( t, map[string]string{ - "Archive Expiry": tc.want, - "Archive Rotation": rotationNone, + "Archive expiry": tc.want, + "Archive rotation": rotationNone, }, fieldMap(view.Config), ) @@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) { }) assert.Equal( - t, want, fieldMap(view.Config)["Archive Rotation"], + t, want, fieldMap(view.Config)["Archive rotation"], ) }) } diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go index 9484543..d740f2d 100644 --- a/internal/handlers/archive_expiry_test.go +++ b/internal/handlers/archive_expiry_test.go @@ -45,7 +45,7 @@ func expiryShown( require.Equal(t, http.StatusOK, w.Code) return matched( - `Archive Expiry:\s*([^<]*)`, w.Body.String(), + `Archive expiry:\s*([^<]*)`, w.Body.String(), ) } diff --git a/internal/handlers/archive_rotation_test.go b/internal/handlers/archive_rotation_test.go index 177373c..ff2c511 100644 --- a/internal/handlers/archive_rotation_test.go +++ b/internal/handlers/archive_rotation_test.go @@ -27,7 +27,7 @@ func rotationShown( t.Helper() return matched( - `Archive Rotation:\s*([^<]*)`, + `Archive rotation:\s*([^<]*)`, renderedPage(t, env, webhookID), ) } @@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) { assert.Equal(t, "2.0 kB", view.Size) 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. diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index f22dd98..7a1b6ac 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin( ))) h.renderLoginError( w, r, - "Too many failed login attempts. Please try again later.", + "Too many failed sign-in attempts. Please try again later.", http.StatusTooManyRequests, ) } diff --git a/internal/handlers/notice.go b/internal/handlers/notice.go index 69bb88e..8e2e2ae 100644 --- a/internal/handlers/notice.go +++ b/internal/handlers/notice.go @@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice { }, resubmitNoTargets: { 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))] if !ok { diff --git a/internal/handlers/page_title_test.go b/internal/handlers/page_title_test.go index 8c11f98..441bd1a 100644 --- a/internal/handlers/page_title_test.go +++ b/internal/handlers/page_title_test.go @@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) { data map[string]any title string }{ - {"login.html", map[string]any{}, "Login - Webhooker"}, + {"login.html", map[string]any{}, "Sign in - Webhooker"}, {"profile.html", map[string]any{}, "Profile - Webhooker"}, {"settings.html", map[string]any{}, "Settings - Webhooker"}, {"sources_list.html", map[string]any{}, "Webhooks - Webhooker"}, diff --git a/internal/handlers/source_detail_test.go b/internal/handlers/source_detail_test.go index 2d58837..a77129b 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields( assert.Contains(t, body, "1 configured") assert.NotContains(t, body, "sekrit") - assert.Contains(t, body, "Archive Expiry") - assert.Contains(t, body, "30 days") + // The database type is called an archive: on its badge, in the + // 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, ``) // An unknown type gets the neutral placeholder, never the // stored blob. diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 6959aba..e8130d3 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1749,7 +1749,7 @@ func (h *Handlers) setTargetFromForm( // cannot destroy the count a target is delivering with. maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) if err != nil { - return "Invalid max retries: " + retriesErrorMessage(err), nil + return "Invalid delivery attempts: " + retriesErrorMessage(err), nil } target.Name = in.Name diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 1c7c4d4..9580252 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked( 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`) + assert.Contains(t, page, `class="label">Archive rotation`) +} + // TestHandleTargetEditSubmit_Rejects covers every submission that // must not reach storage. // @@ -588,7 +612,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) { { database.TargetTypeSlack, "name=edited&url=" + editOriginalURL + "&max_retries=25", - "Invalid max retries", + "Invalid delivery attempts", }, { database.TargetTypeDatabase, diff --git a/internal/handlers/target_list_test.go b/internal/handlers/target_list_test.go index 89c60fe..a12fa89 100644 --- a/internal/handlers/target_list_test.go +++ b/internal/handlers/target_list_test.go @@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) { path := delivery.ArchivePath(dbMgr, wh, archive) 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, "not created yet") - assert.NotContains(t, body, "Archive Size:") + assert.NotContains(t, body, "Archive size:") seedArchive(t, path, 1, 100) @@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) { assert.Contains(t, body, filepath.Base(path)) assert.NotContains(t, body, "not created yet") assert.Regexp(t, - `Archive Size:\s*[1-9][0-9.]* [kM]?B`, body, + `Archive size:\s*[1-9][0-9.]* [kM]?B`, body, ) assert.Contains(t, body, `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) assert.Contains(t, body, filepath.Base(path)) 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 diff --git a/internal/handlers/target_paused_test.go b/internal/handlers/target_paused_test.go index cbac037..37d57f2 100644 --- a/internal/handlers/target_paused_test.go +++ b/internal/handlers/target_paused_test.go @@ -81,10 +81,10 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) { list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) 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 "+ "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) 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)) 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") // Not the whole list: the add target form above the rows says 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) 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) } diff --git a/internal/handlers/target_retries.go b/internal/handlers/target_retries.go index 8b68780..15979c3 100644 --- a/internal/handlers/target_retries.go +++ b/internal/handlers/target_retries.go @@ -25,14 +25,14 @@ var ( // errRetriesInvalid signals a max_retries form value that is not // a non-negative whole number. 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 // whole number but above maxTargetRetries. It is distinguished // from errRetriesInvalid so the message can name the ceiling // 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. @@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string { if errors.Is(err, errRetriesTooLarge) { return errRetriesTooLarge.Error() + ": at most " + strconv.Itoa(maxTargetRetries) + - " retries" + " attempts" } return errRetriesInvalid.Error() + diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 21800d6..8b9e655 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -354,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { // and target_http gives up once the attempt number reaches // max_retries), and 0 is special-cased to a single fire-and-forget // attempt with no circuit breaker. -const maxRetriesHelp = "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." +const maxRetriesHelp = "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." // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries -// help text on both the create form (the add-target form on the webhook -// detail page) and the edit form, so the copy cannot drift back to -// calling the number a retry count. +// label, "Delivery attempts", and help text on both the create form +// (the add-target form on the webhook detail page) and the edit form, +// so the copy cannot drift back to calling the number a retry count. func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { t.Parallel() @@ -393,6 +392,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { }, ) + assert.Contains(t, createBody, "Delivery attempts:") assert.Contains( t, createBody, maxRetriesHelp, "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`) assert.Contains( t, editBody, maxRetriesHelp, "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`) + assert.Contains(t, body, `class="label">Archive rotation`) +} diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 6602847..6677cef 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -318,31 +318,35 @@ const ( func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { t.Helper() - // Each target type, with the fields its add target form submits, in - // page order. Only http and slack have a url field. + // Each target type, with the badge its targets are listed with and + // the fields its add target form submits, in page order. Only http + // and slack have a url field. targetTypes := []struct { name string + badge string fields 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}, }, { - "slack", "csrf_token name type url max_retries", + "slack", "slack", "csrf_token name type url max_retries", 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"}, }, - {"log", "csrf_token name type", nil}, + {"log", "log", "csrf_token name type", nil}, } for _, tt := range targetTypes { 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 // 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 -// values, and checks that the section lists it with that type. +// values, and checks that the section lists it with badge. func checkAddTarget( ctx context.Context, t *testing.T, - url, targetType string, + url, targetType, badge string, fields []string, values map[string]string, ) { @@ -412,8 +416,8 @@ func checkAddTarget( click(ctx, t, saveButton) assert.Truef(t, shown(ctx, `//span[text()="`+name+ - `"]/following-sibling::div/span[text()="`+targetType+`"]`), - "%s: the added target is not listed with its type", targetType) + `"]/following-sibling::div/span[text()="`+badge+`"]`), + "%s: the added target is not listed as %s", targetType, badge) } // 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, "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"]`), "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"]`), "a database target added with daily is not listed as daily") diff --git a/internal/server/resubmit_test.go b/internal/server/resubmit_test.go index a5ed43a..e5abb61 100644 --- a/internal/server/resubmit_test.go +++ b/internal/server/resubmit_test.go @@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit( env.requireNotice( t, env.post(path, csrfForm(token), cookies), 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( t, env.post(path, csrfForm(token), cookies), 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, ), intrudersLogs, "resubmit-no-targets", - "this source has no active targets", cookies, + "this webhook has no active targets", cookies, ) } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 3d0dd62..27e8cc9 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -914,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) { 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", + ) + + 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"), + "the desktop and the mobile navbar each say Sign out") +} + // --- /user/{username} group --- // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged @@ -1363,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) { ) env.requireNotice( 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) diff --git a/templates/login.html b/templates/login.html index 6fd64f8..c68b9b0 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,6 +1,6 @@ {{template "base" .}} -{{define "title"}}Login - Webhooker{{end}} +{{define "title"}}Sign in - Webhooker{{end}} {{define "content"}}
@@ -52,7 +52,7 @@ >
- + diff --git a/templates/navbar.html b/templates/navbar.html index 7e1f03f..7b6e616 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -32,7 +32,7 @@ {{if .CSRFToken}}
- +
{{end}} {{end}} @@ -49,7 +49,7 @@ {{if .CSRFToken}}
- +
{{end}} {{end}} diff --git a/templates/source_detail.html b/templates/source_detail.html index edcc187..4d9a83a 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -159,7 +159,7 @@ @@ -182,10 +182,10 @@
- +
-

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.

+

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.

@@ -198,10 +198,10 @@
- +
-

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.

+

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.

@@ -251,7 +251,7 @@
{{.Name}}
- {{.Type}} + {{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}} {{if .Active}} Active {{else}} @@ -275,7 +275,7 @@
{{with .Paused}}
- Deliveries Paused: + Deliveries paused: {{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}}
{{end}} @@ -287,17 +287,17 @@ {{end}} {{with .Archive}}
- Archive File: + Archive file: {{.Name}} {{with .Note}}({{.}}){{end}}
{{if .Files}}
- Archive Size: + Archive size: {{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}
- Last Written: + Last written: {{.Written}}
{{end}} diff --git a/templates/sources_new.html b/templates/sources_new.html index b3964ea..9911b5c 100644 --- a/templates/sources_new.html +++ b/templates/sources_new.html @@ -46,9 +46,9 @@ Archive -

When checked, the webhook is created with a database target that keeps a copy of every event.

+

When checked, the webhook is created with an archive target that keeps a copy of every event.

- + {{range .ArchiveExpiryChoices}} @@ -69,7 +69,7 @@
- + -

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.

+

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.

{{end}}