Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef59bf7554 | ||
|
|
803a94be37 | ||
|
|
43ed8d4834 | ||
|
|
1c721ede41 | ||
|
|
bfdbc937c6 |
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
|
||||
with retry support, logging, and observability. Category: infrastructure
|
||||
/ web service. License: MIT.
|
||||
|
||||
Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
|
||||
Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and
|
||||
that UUID is the entrypoint's only credential. webhooker does not use
|
||||
shared secrets, HMAC signatures or token headers on the receiver, and
|
||||
will not add them — read
|
||||
@@ -145,11 +145,6 @@ TTY detection, and security headers are always applied.
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||
|
||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
||||
every one of these with the value the running server loaded. It is
|
||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
||||
set or not set, never their values.
|
||||
|
||||
#### Allowing egress to your own network
|
||||
|
||||
By default every delivery target must resolve to a public address. The
|
||||
@@ -1080,7 +1075,7 @@ unconditionally against whatever files it finds:
|
||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||
`Entrypoint`, `Target`
|
||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||
`DeliveryResult`
|
||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||
- each archive database on every open and reopen
|
||||
|
||||
There is no schema version table, no migration ledger, and no down
|
||||
@@ -1193,7 +1188,7 @@ backups at rest and restrict who can read them.
|
||||
|
||||
**The entrypoint UUID is the credential, and it is the only one.**
|
||||
webhooker mints a version 4 UUID per entrypoint and serves it at
|
||||
`/webhook/{uuid}`. Possession of that URL is the authentication:
|
||||
`/h/{uuid}`. Possession of that URL is the authentication:
|
||||
anyone who holds it can submit events to the entrypoint, and the
|
||||
receiver verifies nothing else about the sender.
|
||||
|
||||
@@ -1397,7 +1392,7 @@ The codebase uses consistent naming throughout (rename completed in
|
||||
|
||||
### Data Model
|
||||
|
||||
webhooker's data model has nine entities organized into two tiers: the
|
||||
webhooker's data model has eleven entities organized into two tiers: the
|
||||
**application tier** (user and webhook configuration) and the **event
|
||||
tier** (event ingestion, delivery, and logging).
|
||||
|
||||
@@ -1426,6 +1421,13 @@ tier** (event ingestion, delivery, and logging).
|
||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ (one row: running counts of events) │
|
||||
│ │ EventTotals │ │
|
||||
│ └──────────────┘ │
|
||||
│ ┌──────────────┐ (one row per target: running counts │
|
||||
│ │ TargetTotals │ of its deliveries) │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -1528,7 +1530,7 @@ the full request and creates an Event.
|
||||
| -------------- | ------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
||||
| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
||||
| `description` | string | Optional description |
|
||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||
|
||||
@@ -1679,6 +1681,7 @@ status across potentially multiple attempts.
|
||||
| `event_id` | UUID | Foreign key → Event |
|
||||
| `target_id`| UUID | Foreign key → Target |
|
||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||
|
||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||
DeliveryResults.
|
||||
@@ -1746,33 +1749,70 @@ retries) is individually logged for full observability.
|
||||
|
||||
**Relations:** Belongs to Delivery.
|
||||
|
||||
#### EventTotals and TargetTotals
|
||||
|
||||
Running counts in each event database, read by the statistics pane at the
|
||||
top of the webhook page. `EventTotals` is one row:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ----------- |
|
||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||
| `events_removed` | integer | Events retention has deleted |
|
||||
| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is |
|
||||
|
||||
`TargetTotals` is one row per target, created by the first delivery to it:
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ------- | ----------- |
|
||||
| `target_id` | UUID | The target (primary key) |
|
||||
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
||||
| `delivered` | integer | Of those, how many became `delivered` |
|
||||
| `failed` | integer | Of those, how many became `failed` |
|
||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
||||
|
||||
Each count changes in the transaction that writes or deletes the rows it
|
||||
counts. The pane's lifetime events are `events`, and its lifetime
|
||||
deliveries and failures are `deliveries` and `failed` summed over the
|
||||
targets; each figure within retention is the same less what retention
|
||||
removed, so neither needs the rows themselves. Its last event is
|
||||
`last_event_at`, written in the transaction that stores the event, so it
|
||||
still shows once retention has removed every event. Its last-10-minutes and
|
||||
last-24-hours figures are counted from the `events` and `deliveries`
|
||||
indexes over just that window, the deliveries in one query grouped by
|
||||
target. Its failure percentage for a window is the deliveries that became
|
||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||
a dash when none did.
|
||||
|
||||
#### Event-tier indexes
|
||||
|
||||
These indexes on the per-webhook event databases are declared in the model
|
||||
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
||||
tags, so `AutoMigrate` creates them on a fresh database:
|
||||
|
||||
| Table | Columns | Serves |
|
||||
| ------------------ | --------------------------- | ------ |
|
||||
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
||||
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
||||
no statistics on these tables, and without them it rates the `deleted_at`
|
||||
index, which every live row matches, above an index on a column matched
|
||||
against several values or compared with `<`. So every index but the last also
|
||||
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
||||
index without it, except in `events`, where `created_at` is compared with `<`
|
||||
and SQLite narrows by a `<` only on the last column it uses.
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||
rates the `deleted_at` index, which every live row matches, above an index on
|
||||
a column matched against several values or compared with a range. So every
|
||||
index but the last also covers `deleted_at`. It comes second, so that
|
||||
retention can use the index without it, except in `events`, where the
|
||||
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||
range only on the last column it uses.
|
||||
|
||||
#### Common Fields
|
||||
|
||||
Every entity except `Setting` includes these fields from `BaseModel`.
|
||||
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
||||
soft delete:
|
||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
||||
and by `target_id`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | --------- | ----------- |
|
||||
@@ -1814,6 +1854,8 @@ encryption key is generated and stored, and an `admin` user is created.
|
||||
- **Events** — captured incoming webhook payloads
|
||||
- **Deliveries** — event-to-target pairings and their status
|
||||
- **DeliveryResults** — individual delivery attempt logs
|
||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||
the deliveries per target, kept through retention
|
||||
|
||||
Per-webhook databases are created automatically when a webhook is
|
||||
created (and lazily on first access for webhooks that predate this
|
||||
@@ -1910,7 +1952,7 @@ runtime, though CGO is required at build time due to the transitive
|
||||
```
|
||||
External Service
|
||||
│
|
||||
│ POST /webhook/{uuid}
|
||||
│ POST /h/{uuid}
|
||||
▼
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||
@@ -2136,7 +2178,7 @@ The middleware records three more on the same registry:
|
||||
Two of those labels are written once per request from bytes the client
|
||||
chose, so both are bounded to something this service registers:
|
||||
|
||||
- `handler` is the chi route pattern — `/webhook/{uuid}`, never the
|
||||
- `handler` is the chi route pattern — `/h/{uuid}`, never the
|
||||
concrete path. A request matching no route carries `(unmatched)`,
|
||||
and no entrypoint UUID ever reaches a label.
|
||||
- `method` is the request method when the router can route it, and
|
||||
@@ -2166,7 +2208,7 @@ unpredictable rates, and blanket limits shared with other routes would
|
||||
cause legitimate deliveries to be dropped.
|
||||
|
||||
The receiver instead has its own dedicated abuse limit, scoped to the
|
||||
`/webhook/{uuid}` route only and keyed per client IP per request path
|
||||
`/h/{uuid}` route only and keyed per client IP per request path
|
||||
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
||||
affecting other senders of the same entrypoint or the same sender's
|
||||
other entrypoints. Keying on the path rather than on the entrypoint
|
||||
@@ -2203,7 +2245,7 @@ log spends. The access log is bounded by neither limit: every request
|
||||
is recorded once at `INFO`, served or rejected alike.
|
||||
|
||||
What the access log does bound is the _content_ of those lines. A 3xx
|
||||
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
|
||||
or 4xx response logs the chi route pattern — `/h/{uuid}`,
|
||||
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
||||
no route at all — in place of the concrete URL. Those are the outcomes
|
||||
an unauthenticated client can drive for free: 404 and 429 on any
|
||||
@@ -2237,12 +2279,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
||||
|
||||
The same hook rewrites the request URL. The SDK builds it as
|
||||
`scheme://host/path` from the concrete path, which on the receiver
|
||||
route is `/webhook/<uuid>` in full — and that UUID is a write
|
||||
route is `/h/<uuid>` in full — and that UUID is a write
|
||||
capability, not an identifier: anyone holding it can post events this
|
||||
service accepts and its targets then deliver. A tracker has its own
|
||||
retention, access control and deletion policy, so the rule the access
|
||||
log follows above does not carry across that boundary. What is sent is
|
||||
the chi route pattern instead: `http://host/webhook/{uuid}`.
|
||||
the chi route pattern instead: `http://host/h/{uuid}`.
|
||||
|
||||
The scheme and the host are kept, and everything else in the URL is
|
||||
discarded rather than edited, so a future SDK version that starts
|
||||
@@ -2286,8 +2328,8 @@ fallback is never the concrete path. The path becomes the literal
|
||||
rewrite cannot parse into a scheme is withheld whole. A transaction
|
||||
event additionally carries the SDK's own `METHOD /path` name, built
|
||||
from the concrete path as well; it is rewritten on the same terms, to
|
||||
`POST /webhook/{uuid}` where the pattern is known and `POST
|
||||
/(redacted)` where it is not.
|
||||
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
|
||||
where it is not.
|
||||
|
||||
The headers are an allowlist for the same reason the rules above are
|
||||
unconditional: the SDK's own filter removes four names and passes
|
||||
@@ -2422,7 +2464,7 @@ logger printed the fully interpolated SQL — parameters and all — to
|
||||
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 `/webhook/{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
|
||||
picks outright. Every
|
||||
`gorm.Open` in the service now installs the adapter in
|
||||
@@ -2699,10 +2741,10 @@ abuse limit later; they are tracked as future work.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------- | ----------- |
|
||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
|
||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||
| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||
|
||||
#### Authentication Endpoints
|
||||
|
||||
@@ -2718,26 +2760,25 @@ abuse limit later; they are tracked as future work.
|
||||
| ------ | ------------------------ | ----------- |
|
||||
| `GET` | `/user/{username}` | User profile page |
|
||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
||||
| `GET` | `/sources` | List user's webhooks |
|
||||
| `GET` | `/sources/new` | Create webhook form |
|
||||
| `POST` | `/sources/new` | Create webhook submission |
|
||||
| `GET` | `/source/{id}` | Webhook detail view |
|
||||
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
||||
| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||
| `POST` | `/source/{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` | `/source/{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`) |
|
||||
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
||||
| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||
| `GET` | `/hooks` | List user's webhooks |
|
||||
| `GET` | `/hooks/new` | Create webhook form |
|
||||
| `POST` | `/hooks/new` | Create webhook submission |
|
||||
| `GET` | `/hook/{id}` | Webhook detail view |
|
||||
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||
| `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`) |
|
||||
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||
|
||||
#### Infrastructure Endpoints
|
||||
|
||||
@@ -2794,6 +2835,7 @@ webhooker/
|
||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||
│ │ ├── model_apikey.go # APIKey entity
|
||||
│ │ ├── password.go # Argon2id hashing and verification
|
||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||
@@ -2831,7 +2873,6 @@ webhooker/
|
||||
│ │ ├── healthcheck.go # Health check handler
|
||||
│ │ ├── index.go # Index page handler
|
||||
│ │ ├── profile.go # User profile handler
|
||||
│ │ ├── settings.go # Read-only Settings page handler
|
||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||
│ │ └── webhook.go # Webhook receiver handler
|
||||
│ ├── healthcheck/
|
||||
@@ -2939,8 +2980,14 @@ local record instead of nothing. What that placement gives up is
|
||||
recovery of a panic in the six entries above it, none of which does
|
||||
more than set a header or start a timer.
|
||||
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
||||
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||
with the `500` error page in the normal layout; the global one keeps
|
||||
the plain-text `500` for every other route.
|
||||
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||
CSRF middleware in every one of those route groups, because
|
||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||
@@ -2964,7 +3011,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
|
||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||
rather than global: **PasswordChangeRateLimit** on
|
||||
`/user/{username}/password` and **ReceiverRateLimit** on
|
||||
`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
|
||||
`/h/{uuid}`. There is deliberately none on `/pages/login` — that
|
||||
endpoint counts failures inside the handler, after the credential
|
||||
check, see [The login endpoint](#the-login-endpoint).
|
||||
|
||||
@@ -3005,8 +3052,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
by middleware that runs before CSRF parses the form
|
||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||
on all state-changing forms (cookie-based double-submit tokens with
|
||||
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
||||
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
|
||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||
`/api` (stateless API). The middleware detects TLS per-request through
|
||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||
to set appropriate cookie security flags and Origin/Referer validation
|
||||
@@ -3059,7 +3106,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
before the app starts; the image's health check; and `docker exec`,
|
||||
unless given `--user`
|
||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||
all of them but `Setting` (data preserved for audit)
|
||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
||||
preserved for audit)
|
||||
|
||||
### Shutdown
|
||||
|
||||
|
||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
deliveries []database.Delivery
|
||||
results []database.DeliveryResult
|
||||
depths []struct{ Depth int }
|
||||
removed []database.TargetTotals
|
||||
)
|
||||
|
||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
||||
|
||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||
// stranded pending deliveries, and the queue depth count.
|
||||
@@ -123,25 +123,80 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
Order("attempt_num ASC").Find(&results),
|
||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||
|
||||
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||
// afresh for each statement as it builds them.
|
||||
expiredEventIDs := func() *gorm.DB {
|
||||
return dry.Model(&database.Event{}).Select("id").
|
||||
Where("created_at < ?", cutoff)
|
||||
// Retention (reapExpired, deleteEvents): one batch of expired
|
||||
// events, then their attempts, deliveries and the events.
|
||||
var expired []string
|
||||
|
||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
||||
"idx_events_created_at (created_at<?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
||||
Select("id").Where("event_id IN ?", ids),
|
||||
).Delete(&database.DeliveryResult{}),
|
||||
"idx_delivery_results_delivery_id (delivery_id=?)",
|
||||
"idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
||||
Select("target_id, count(*) AS deliveries_removed, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||
database.DeliveryStatusFailed).
|
||||
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
||||
"idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
||||
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||
}
|
||||
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||
).Delete(&database.DeliveryResult{}),
|
||||
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"event_id IN (?)", expiredEventIDs(),
|
||||
).Delete(&database.Delivery{}),
|
||||
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"created_at < ?", cutoff,
|
||||
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||
// page's statistics (readEventStats in the handlers): deliveries in
|
||||
// progress, each target's deliveries finished since a time, which must
|
||||
// come from the index alone, and events received since a time.
|
||||
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
db, err := mgr.GetDB(uuid.New().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
dry := db.Session(&gorm.Session{DryRun: true})
|
||||
since := time.Now()
|
||||
|
||||
var (
|
||||
count int64
|
||||
byTarget []struct{ TargetID string }
|
||||
)
|
||||
|
||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||
Where("status IN ?", []database.DeliveryStatus{
|
||||
database.DeliveryStatusPending,
|
||||
database.DeliveryStatusRetrying,
|
||||
}).Count(&count),
|
||||
"idx_deliveries_status (status=? AND deleted_at=?)")
|
||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||
Select("target_id, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed).
|
||||
Where("status IN ? AND finished_at >= ?",
|
||||
[]database.DeliveryStatus{
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed,
|
||||
}, since).
|
||||
Group("target_id").Find(&byTarget),
|
||||
"COVERING INDEX idx_deliveries_status "+
|
||||
"(status=? AND deleted_at=? AND finished_at>?)")
|
||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||
Where("created_at >= ?", since).Count(&count),
|
||||
"idx_events_deleted_at_created_at "+
|
||||
"(deleted_at=? AND created_at>?)")
|
||||
}
|
||||
|
||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -28,6 +29,10 @@ func NewTestRetentionReaper(
|
||||
}
|
||||
}
|
||||
|
||||
// ExportReapBatchSize exposes how many expired events one retention
|
||||
// transaction deletes.
|
||||
const ExportReapBatchSize = reapBatchSize
|
||||
|
||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||
r.sweep(ctx)
|
||||
@@ -79,3 +84,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
||||
func DummyPasswordHashForTest() string {
|
||||
return dummyPasswordHash()
|
||||
}
|
||||
|
||||
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
||||
// memory cost until t ends. t must not run in parallel with other
|
||||
// tests, which would hash at that cost alongside it.
|
||||
func HashAtShippedCostForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
hashAtShippedCostInTest = true
|
||||
|
||||
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeliveryStatus represents the status of a delivery
|
||||
type DeliveryStatus string
|
||||
@@ -37,7 +41,7 @@ type Delivery struct {
|
||||
BaseModel
|
||||
|
||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||
|
||||
// DeletedAt repeats the BaseModel field only to be the second column
|
||||
@@ -45,6 +49,13 @@ type Delivery struct {
|
||||
// gives.
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||
|
||||
// FinishedAt is when the delivery became delivered or failed, and
|
||||
// nil while it is pending or retrying. It and then TargetID end the
|
||||
// status index, so the webhook page counts each target's deliveries
|
||||
// that finished in a recent window by reading just that window from
|
||||
// the index.
|
||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||
|
||||
// Relations
|
||||
Event Event `json:"event,omitzero"`
|
||||
Target Target `json:"target,omitzero"`
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The running totals in a webhook's event database keep the webhook
|
||||
// page's lifetime figures right after retention has removed the rows
|
||||
// they count, and let the page show them without counting every row.
|
||||
// Each total changes in the transaction that writes or deletes the
|
||||
// rows it counts.
|
||||
|
||||
// EventTotals is the single row counting a webhook's events: every
|
||||
// event ever stored, how many of them retention has deleted, and when
|
||||
// the newest arrived, which retention leaves as it is.
|
||||
type EventTotals struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
|
||||
Events int64 `gorm:"not null"`
|
||||
EventsRemoved int64 `gorm:"not null"`
|
||||
|
||||
// LastEventAt is when the newest event arrived, or nil before the
|
||||
// first.
|
||||
LastEventAt *time.Time
|
||||
}
|
||||
|
||||
// TableName names the table AddEventTotals updates.
|
||||
func (EventTotals) TableName() string {
|
||||
return "event_totals"
|
||||
}
|
||||
|
||||
// TargetTotals is one row per target counting its deliveries: every
|
||||
// delivery ever created, how many became delivered and how many
|
||||
// failed, and how many deliveries and failed deliveries retention has
|
||||
// deleted. The webhook's delivery figures are these rows summed.
|
||||
type TargetTotals struct {
|
||||
TargetID string `gorm:"type:uuid;primaryKey"`
|
||||
|
||||
Deliveries int64 `gorm:"not null"`
|
||||
Delivered int64 `gorm:"not null"`
|
||||
Failed int64 `gorm:"not null"`
|
||||
|
||||
DeliveriesRemoved int64 `gorm:"not null"`
|
||||
FailedRemoved int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
// TableName names the table AddTargetTotals updates.
|
||||
func (TargetTotals) TableName() string {
|
||||
return "target_totals"
|
||||
}
|
||||
|
||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||
// and records add.LastEventAt as when the newest event arrived if it is
|
||||
// set. Call it on the transaction that writes or deletes the events it
|
||||
// counts.
|
||||
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
|
||||
err := tx.Exec(
|
||||
`UPDATE event_totals SET
|
||||
events = events + ?,
|
||||
events_removed = events_removed + ?,
|
||||
last_event_at = coalesce(?, last_event_at)`,
|
||||
add.Events, add.EventsRemoved, add.LastEventAt,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding to event totals: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTargetTotals adds each count in add to the totals of the target
|
||||
// add.TargetID names, creating its row the first time. Call it on the
|
||||
// transaction that writes or deletes the deliveries it counts.
|
||||
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
||||
err := tx.Exec(
|
||||
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
||||
failed, deliveries_removed, failed_removed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (target_id) DO UPDATE SET
|
||||
deliveries = deliveries + excluded.deliveries,
|
||||
delivered = delivered + excluded.delivered,
|
||||
failed = failed + excluded.failed,
|
||||
deliveries_removed =
|
||||
deliveries_removed + excluded.deliveries_removed,
|
||||
failed_removed = failed_removed + excluded.failed_removed`,
|
||||
add.TargetID, add.Deliveries, add.Delivered,
|
||||
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"adding to totals of target %s: %w", add.TargetID, err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,7 +2,8 @@ package database
|
||||
|
||||
// Migrate runs database migrations for the main application database.
|
||||
// Only configuration-tier models are stored in the main database.
|
||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||
// TargetTotals) live in
|
||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||
func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword generates an Argon2id hash of the password
|
||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
||||
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
||||
// that starts a database hashes the bootstrap admin password, dozens
|
||||
// of them run in parallel, and under the race detector each 64 MB hash
|
||||
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
||||
// checks, so verification follows.
|
||||
const testArgon2Memory = 1024
|
||||
|
||||
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
||||
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
||||
//
|
||||
//nolint:gochecknoglobals // set by one test, see above
|
||||
var hashAtShippedCostInTest bool
|
||||
|
||||
// HashPassword generates an Argon2id hash of the password. A binary
|
||||
// built by go test hashes at testArgon2Memory; one built by go build
|
||||
// always hashes at the defaults.
|
||||
func HashPassword(password string) (string, error) {
|
||||
config := DefaultPasswordConfig()
|
||||
|
||||
if testing.Testing() && !hashAtShippedCostInTest {
|
||||
config.Memory = testArgon2Memory
|
||||
}
|
||||
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
|
||||
|
||||
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPassword_ShippedParameters hashes and verifies through
|
||||
// HashPassword at the shipped Argon2id parameters. Every other test
|
||||
// hashes at the lower memory cost a test binary uses, so this is the
|
||||
// one that keeps production hashing covered. One hash and one
|
||||
// verification: each costs 64 MB.
|
||||
//
|
||||
//nolint:paralleltest // changes the hashing cost for the whole binary
|
||||
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||
database.HashAtShippedCostForTest(t)
|
||||
|
||||
password := "correct horse battery staple"
|
||||
|
||||
hash, err := database.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||
}
|
||||
|
||||
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||
|
||||
if !strings.HasPrefix(hash, shipped) {
|
||||
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||
}
|
||||
|
||||
valid, err := database.VerifyPassword(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
t.Error("VerifyPassword() returned false for correct password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
|
||||
+116
-51
@@ -18,6 +18,19 @@ import (
|
||||
// computation.
|
||||
const hoursPerDay = 24
|
||||
|
||||
// reapBatchSize is how many expired events one retention transaction
|
||||
// deletes. A transaction holds the event database's write lock, which
|
||||
// the receiver and the delivery workers wait for, so a large prune is
|
||||
// split into transactions each short enough to finish well inside the
|
||||
// busy timeout.
|
||||
const reapBatchSize = 1000
|
||||
|
||||
// reapBatchPause is how long retention waits after one batch before
|
||||
// starting the next. A writer waiting for the write lock checks for it
|
||||
// again after at most 100 ms, so a longer pause lets it in between two
|
||||
// batches instead of only after the whole prune.
|
||||
const reapBatchPause = 200 * time.Millisecond
|
||||
|
||||
// RetentionReaperParams holds the fx dependencies for the
|
||||
// RetentionReaper.
|
||||
type RetentionReaperParams struct {
|
||||
@@ -187,13 +200,15 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
r.reapWebhook(wh.ID, wh.RetentionDays)
|
||||
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// reapWebhook removes every expired event (and its dependents) from a
|
||||
// single webhook's database.
|
||||
// single webhook's database, or as many as it reaches before ctx is
|
||||
// cancelled.
|
||||
func (r *RetentionReaper) reapWebhook(
|
||||
ctx context.Context,
|
||||
webhookID string,
|
||||
retentionDays int,
|
||||
) {
|
||||
@@ -213,7 +228,7 @@ func (r *RetentionReaper) reapWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := reapExpired(db, cutoff)
|
||||
deleted, err := reapExpired(ctx, db, cutoff)
|
||||
if err != nil {
|
||||
r.log.Error(
|
||||
"retention sweep: failed to reap expired events",
|
||||
@@ -265,57 +280,107 @@ func retentionCutoff(
|
||||
), true
|
||||
}
|
||||
|
||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||
// results, deliveries, and events associated with events older than
|
||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||
// deleted.
|
||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||
// Fresh subqueries are built per statement to avoid reusing a
|
||||
// mutated builder across executions.
|
||||
expiredEventIDs := func() *gorm.DB {
|
||||
return db.Model(&Event{}).
|
||||
Select("id").
|
||||
Where("created_at < ?", cutoff)
|
||||
}
|
||||
expiredDeliveryIDs := func() *gorm.DB {
|
||||
return db.Model(&Delivery{}).
|
||||
Select("id").
|
||||
Where("event_id IN (?)", expiredEventIDs())
|
||||
}
|
||||
// reapExpired hard-deletes the events older than cutoff, with their
|
||||
// deliveries and delivery results, reapBatchSize events per
|
||||
// transaction with reapBatchPause between transactions, until none is
|
||||
// left. Once ctx is cancelled it returns after the batch in hand,
|
||||
// leaving the rest to the next sweep, so stopping the app does not
|
||||
// wait for a long prune. It returns the number of events deleted.
|
||||
func reapExpired(
|
||||
ctx context.Context, db *gorm.DB, cutoff time.Time,
|
||||
) (int64, error) {
|
||||
var total int64
|
||||
|
||||
// 1. Delivery results whose delivery belongs to an expired event.
|
||||
res := db.Unscoped().
|
||||
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||
Delete(&DeliveryResult{})
|
||||
if res.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired delivery results: %w",
|
||||
res.Error,
|
||||
)
|
||||
}
|
||||
for {
|
||||
var eventIDs []string
|
||||
|
||||
// 2. Deliveries belonging to an expired event.
|
||||
del := db.Unscoped().
|
||||
Where("event_id IN (?)", expiredEventIDs()).
|
||||
Delete(&Delivery{})
|
||||
if del.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired deliveries: %w",
|
||||
del.Error,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. The expired events themselves.
|
||||
ev := db.Unscoped().
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Unscoped().Model(&Event{}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Delete(&Event{})
|
||||
if ev.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired events: %w",
|
||||
ev.Error,
|
||||
)
|
||||
Limit(reapBatchSize).
|
||||
Pluck("id", &eventIDs).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("selecting expired events: %w", err)
|
||||
}
|
||||
|
||||
return ev.RowsAffected, nil
|
||||
if len(eventIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return deleteEvents(tx, eventIDs)
|
||||
})
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
|
||||
total += int64(len(eventIDs))
|
||||
|
||||
if len(eventIDs) < reapBatchSize {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return total, nil
|
||||
case <-time.After(reapBatchPause):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
||||
// order before them, their delivery results and deliveries, then adds
|
||||
// what it deleted to the running totals. It runs on reapExpired's
|
||||
// transaction, so the totals change exactly when the rows do. Deletes
|
||||
// are unscoped so rows are physically removed rather than
|
||||
// soft-deleted, reclaiming disk.
|
||||
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
||||
// 1. The delivery results of the events' deliveries.
|
||||
err := tx.Unscoped().
|
||||
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
||||
Select("id").
|
||||
Where("event_id IN ?", eventIDs)).
|
||||
Delete(&DeliveryResult{}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting expired delivery results: %w", err)
|
||||
}
|
||||
|
||||
// 2. The events' deliveries, after counting them, and the failed
|
||||
// ones among them, per target. The status is tested in the select
|
||||
// list rather than the WHERE clause: there, SQLite would read every
|
||||
// failed delivery the webhook has through the status index,
|
||||
// instead of only these through the event_id index.
|
||||
var removed []TargetTotals
|
||||
|
||||
err = tx.Unscoped().Model(&Delivery{}).
|
||||
Select("target_id, count(*) AS deliveries_removed, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||
DeliveryStatusFailed).
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Group("target_id").
|
||||
Find(&removed).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting expired deliveries: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Unscoped().
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Delete(&Delivery{}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting expired deliveries: %w", err)
|
||||
}
|
||||
|
||||
// 3. The events themselves.
|
||||
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
||||
if ev.Error != nil {
|
||||
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
||||
}
|
||||
|
||||
for i := range removed {
|
||||
err = AddTargetTotals(tx, removed[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// readEventTotals reads a webhook database's row of event totals,
|
||||
// asserting that it has exactly one.
|
||||
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
||||
t.Helper()
|
||||
|
||||
var rows []database.EventTotals
|
||||
|
||||
require.NoError(t, db.Find(&rows).Error)
|
||||
require.Len(t, rows, 1)
|
||||
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
// readTargetTotals reads a webhook database's target totals, keyed by
|
||||
// target.
|
||||
func readTargetTotals(
|
||||
t *testing.T, db *gorm.DB,
|
||||
) map[string]database.TargetTotals {
|
||||
t.Helper()
|
||||
|
||||
var rows []database.TargetTotals
|
||||
|
||||
require.NoError(t, db.Find(&rows).Error)
|
||||
|
||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||
for _, row := range rows {
|
||||
byTarget[row.TargetID] = row
|
||||
}
|
||||
|
||||
return byTarget
|
||||
}
|
||||
|
||||
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
||||
// database starts with one row of zero event totals and no target
|
||||
// totals, that adding to a target twice adds to the one row, and that
|
||||
// opening the database again keeps everything added.
|
||||
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
fresh := readEventTotals(t, db)
|
||||
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
||||
assert.Empty(t, readTargetTotals(t, db))
|
||||
|
||||
first, second := uuid.New().String(), uuid.New().String()
|
||||
|
||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||
Events: 2,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: first, Deliveries: 2, Delivered: 1,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: first, Failed: 1,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: second, Deliveries: 1,
|
||||
}))
|
||||
|
||||
// Drop the cached connection so the next open reopens the file,
|
||||
// as a restart would.
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
|
||||
db, err = mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
|
||||
readEventTotals(t, db))
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
first: {
|
||||
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
||||
},
|
||||
second: {TargetID: second, Deliveries: 1},
|
||||
}, readTargetTotals(t, db))
|
||||
}
|
||||
|
||||
// seedExpiredEvents stores count events created at the given time,
|
||||
// each with a delivered delivery to one target and a failed delivery
|
||||
// to the other, and one attempt for each delivery.
|
||||
func seedExpiredEvents(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
webhookID string,
|
||||
count int,
|
||||
createdAt time.Time,
|
||||
delivered, failed string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
events := make([]database.Event, count)
|
||||
deliveries := make([]database.Delivery, 0, 2*count)
|
||||
|
||||
for i := range events {
|
||||
events[i] = database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
}
|
||||
events[i].ID = uuid.New().String()
|
||||
events[i].CreatedAt = createdAt
|
||||
|
||||
deliveries = append(deliveries,
|
||||
database.Delivery{
|
||||
EventID: events[i].ID,
|
||||
TargetID: delivered,
|
||||
Status: database.DeliveryStatusDelivered,
|
||||
},
|
||||
database.Delivery{
|
||||
EventID: events[i].ID,
|
||||
TargetID: failed,
|
||||
Status: database.DeliveryStatusFailed,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||
|
||||
results := make([]database.DeliveryResult, len(deliveries))
|
||||
for i := range deliveries {
|
||||
results[i] = database.DeliveryResult{
|
||||
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||
}
|
||||
|
||||
// seedBareEvents stores count events created at the given time, with
|
||||
// no deliveries.
|
||||
func seedBareEvents(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
webhookID string,
|
||||
count int,
|
||||
createdAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
events := make([]database.Event, count)
|
||||
for i := range events {
|
||||
events[i] = database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
}
|
||||
events[i].CreatedAt = createdAt
|
||||
}
|
||||
|
||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||
}
|
||||
|
||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||
// larger than one transaction's batch removes every expired event with
|
||||
// its deliveries and delivery results, keeps the recent event, and
|
||||
// adds what it removed to the event and target totals, so the totals
|
||||
// within retention match the rows still stored.
|
||||
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
expired := database.ExportReapBatchSize + 1
|
||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||
seedExpiredEvents(t, db, webhookID, expired,
|
||||
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
||||
|
||||
// One recent event, delivered to the first target.
|
||||
recent := seedEventChain(t, db, webhookID, time.Now())
|
||||
require.NoError(t, db.Model(&database.Delivery{}).
|
||||
Where("id = ?", recent.deliveryID).
|
||||
Update("target_id", delivered).Error)
|
||||
|
||||
// The totals storing those rows would have left.
|
||||
n := int64(expired)
|
||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||
Events: n + 1,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: failed, Deliveries: n, Failed: n,
|
||||
}))
|
||||
|
||||
env.reaper.ExportSweep(context.Background())
|
||||
|
||||
// Only the recent event's rows are left.
|
||||
for _, model := range []any{
|
||||
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
||||
} {
|
||||
var count int64
|
||||
|
||||
require.NoError(t, db.Model(model).Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count, "%T rows left", model)
|
||||
}
|
||||
|
||||
assertChainPresent(t, db, recent)
|
||||
|
||||
eventTotals := readEventTotals(t, db)
|
||||
assert.Equal(t, database.EventTotals{
|
||||
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
||||
}, eventTotals)
|
||||
|
||||
targetTotals := readTargetTotals(t, db)
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
delivered: {
|
||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||
DeliveriesRemoved: n,
|
||||
},
|
||||
failed: {
|
||||
TargetID: failed, Deliveries: n, Failed: n,
|
||||
DeliveriesRemoved: n, FailedRemoved: n,
|
||||
},
|
||||
}, targetTotals)
|
||||
|
||||
// A sweep with nothing left to remove changes nothing.
|
||||
env.reaper.ExportSweep(context.Background())
|
||||
|
||||
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
||||
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
||||
}
|
||||
|
||||
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
|
||||
// of several batches lets other writers in between its batches: an
|
||||
// event stored once the first batch is deleted is stored while expired
|
||||
// events are still left, not only after the prune has finished.
|
||||
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Three batches of expired events, with nothing else stored: only
|
||||
// the number of batches matters here.
|
||||
expired := 3 * database.ExportReapBatchSize
|
||||
seedBareEvents(t, db, webhookID, expired,
|
||||
time.Now().Add(-40*24*time.Hour))
|
||||
|
||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||
countExpired := func() int64 {
|
||||
var count int64
|
||||
|
||||
require.NoError(t, db.Model(&database.Event{}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Count(&count).Error)
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
pruned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(pruned)
|
||||
|
||||
env.reaper.ExportSweep(context.Background())
|
||||
}()
|
||||
|
||||
t.Cleanup(func() { <-pruned })
|
||||
|
||||
// Every stored event is expired until the write below.
|
||||
require.Eventually(t, func() bool {
|
||||
var count int64
|
||||
|
||||
err := db.Model(&database.Event{}).Count(&count).Error
|
||||
|
||||
return err == nil && count < int64(expired)
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
}
|
||||
require.NoError(t, db.Create(event).Error)
|
||||
|
||||
assert.Positive(t, countExpired(),
|
||||
"the event was stored only after the whole prune")
|
||||
|
||||
<-pruned
|
||||
|
||||
assert.Zero(t, countExpired())
|
||||
|
||||
var stored database.Event
|
||||
|
||||
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
|
||||
}
|
||||
|
||||
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
|
||||
// stopping the reaper during a prune of several batches returns
|
||||
// between two batches, well inside the stop timeout, leaving the
|
||||
// remaining expired events for the next sweep, and that the totals
|
||||
// match the rows left.
|
||||
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two batches and one more of expired events, a few of them with a
|
||||
// delivered and a failed delivery for the target totals to count.
|
||||
// Most carry nothing else, to keep the test quick.
|
||||
const withDeliveries = 10
|
||||
|
||||
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
|
||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
|
||||
delivered, failed)
|
||||
seedBareEvents(t, db, webhookID,
|
||||
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
|
||||
|
||||
n := int64(2*database.ExportReapBatchSize + 1)
|
||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||
Events: n,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: delivered, Deliveries: withDeliveries,
|
||||
Delivered: withDeliveries,
|
||||
}))
|
||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||
TargetID: failed, Deliveries: withDeliveries,
|
||||
Failed: withDeliveries,
|
||||
}))
|
||||
|
||||
env.reaper.ExportSetInterval(time.Millisecond)
|
||||
env.reaper.ExportStart()
|
||||
|
||||
// Stop once the first batch is deleted. The stop lands in the pause
|
||||
// after it, or at worst during the second batch, so at least the
|
||||
// last event is left.
|
||||
require.Eventually(t, func() bool {
|
||||
var count int64
|
||||
|
||||
err := db.Model(&database.Event{}).Count(&count).Error
|
||||
|
||||
return err == nil && count < n
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
|
||||
// The app's stop timeout.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, env.reaper.ExportStop(ctx))
|
||||
|
||||
var events int64
|
||||
|
||||
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
|
||||
assert.Positive(t, events, "the stop waited for the whole prune")
|
||||
|
||||
eventTotals := readEventTotals(t, db)
|
||||
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
|
||||
|
||||
targetTotals := readTargetTotals(t, db)
|
||||
require.Len(t, targetTotals, 2)
|
||||
|
||||
for target, totals := range targetTotals {
|
||||
var deliveries, failures int64
|
||||
|
||||
require.NoError(t, db.Model(&database.Delivery{}).
|
||||
Where("target_id = ?", target).
|
||||
Count(&deliveries).Error)
|
||||
require.NoError(t, db.Model(&database.Delivery{}).
|
||||
Where("target_id = ? AND status = ?",
|
||||
target, database.DeliveryStatusFailed).
|
||||
Count(&failures).Error)
|
||||
|
||||
assert.Equal(t, deliveries,
|
||||
totals.Deliveries-totals.DeliveriesRemoved, target)
|
||||
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
|
||||
target)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
|
||||
|
||||
// WebhookDBManager manages per-webhook SQLite database files
|
||||
// for event storage. Each webhook gets its own dedicated
|
||||
// database containing Events, Deliveries, and DeliveryResults.
|
||||
// database containing Events, Deliveries, DeliveryResults and the
|
||||
// running totals of them (EventTotals, TargetTotals).
|
||||
// Database connections are opened lazily and cached.
|
||||
type WebhookDBManager struct {
|
||||
dataDir string
|
||||
@@ -295,6 +296,7 @@ func (m *WebhookDBManager) openDB(
|
||||
// Run migrations for event-tier models only
|
||||
err = db.AutoMigrate(
|
||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||
&EventTotals{}, &TargetTotals{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
@@ -305,6 +307,18 @@ func (m *WebhookDBManager) openDB(
|
||||
)
|
||||
}
|
||||
|
||||
// A new database gets its row of event totals, all zero. Target
|
||||
// totals rows are created by the first delivery to each target.
|
||||
err = db.FirstOrCreate(&EventTotals{}).Error
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"creating event totals for webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
m.log.Info(
|
||||
"opened per-webhook database",
|
||||
"webhook_id", webhookID,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// targetTotals reads one target's totals from a webhook database, all
|
||||
// zero when it has no row.
|
||||
func targetTotals(
|
||||
t *testing.T, db *gorm.DB, targetID string,
|
||||
) database.TargetTotals {
|
||||
t.Helper()
|
||||
|
||||
var rows []database.TargetTotals
|
||||
|
||||
require.NoError(t, db.Where("target_id = ?", targetID).
|
||||
Find(&rows).Error)
|
||||
|
||||
if len(rows) == 0 {
|
||||
return database.TargetTotals{TargetID: targetID}
|
||||
}
|
||||
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||
// write records for the webhook page's statistics: the time a delivery
|
||||
// finished, set only when it becomes delivered or failed, and one more
|
||||
// on its target's delivered or failed total.
|
||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
status database.DeliveryStatus
|
||||
finished bool
|
||||
delivered int64
|
||||
failed int64
|
||||
}{
|
||||
{database.DeliveryStatusRetrying, false, 0, 0},
|
||||
{database.DeliveryStatusDelivered, true, 1, 0},
|
||||
{database.DeliveryStatusFailed, true, 0, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
event := seedEvent(t, db, `{}`)
|
||||
targetID := uuid.New().String()
|
||||
d := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
before := time.Now()
|
||||
|
||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||
db, &d, tt.status,
|
||||
))
|
||||
|
||||
var stored database.Delivery
|
||||
|
||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||
assert.Equal(t, tt.status, stored.Status)
|
||||
|
||||
if tt.finished {
|
||||
require.NotNil(t, stored.FinishedAt)
|
||||
assert.False(t, stored.FinishedAt.Before(before))
|
||||
} else {
|
||||
assert.Nil(t, stored.FinishedAt)
|
||||
}
|
||||
|
||||
assert.Equal(t, database.TargetTotals{
|
||||
TargetID: targetID,
|
||||
Delivered: tt.delivered,
|
||||
Failed: tt.failed,
|
||||
}, targetTotals(t, db, targetID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
||||
// delivery retention deleted while the engine still held it. Failing
|
||||
// it afterwards writes no row, so it adds no failure either: retention
|
||||
// has already counted what it removed.
|
||||
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
event := seedEvent(t, db, `{}`)
|
||||
targetID := uuid.New().String()
|
||||
d := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusRetrying,
|
||||
)
|
||||
|
||||
require.NoError(t, db.Unscoped().
|
||||
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
||||
|
||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||
db, &d, database.DeliveryStatusFailed,
|
||||
))
|
||||
|
||||
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
||||
targetTotals(t, db, targetID))
|
||||
}
|
||||
|
||||
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
|
||||
// delivery settled a second time, as recovery can do when a worker has
|
||||
// settled it since recovery read it. Neither status writes over the
|
||||
// first, and the totals do not move.
|
||||
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
finished := []database.DeliveryStatus{
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed,
|
||||
}
|
||||
|
||||
for _, first := range finished {
|
||||
t.Run(string(first), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
event := seedEvent(t, db, `{}`)
|
||||
targetID := uuid.New().String()
|
||||
d := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusRetrying,
|
||||
)
|
||||
|
||||
// The delivery as recovery read it, before the worker
|
||||
// settled it.
|
||||
readBefore := d
|
||||
|
||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||
db, &d, first,
|
||||
))
|
||||
|
||||
var settled database.Delivery
|
||||
|
||||
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
|
||||
require.NotNil(t, settled.FinishedAt)
|
||||
|
||||
totals := targetTotals(t, db, targetID)
|
||||
|
||||
for _, again := range finished {
|
||||
stale := readBefore
|
||||
|
||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||
db, &stale, again,
|
||||
))
|
||||
}
|
||||
|
||||
var stored database.Delivery
|
||||
|
||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||
assert.Equal(t, first, stored.Status)
|
||||
require.NotNil(t, stored.FinishedAt)
|
||||
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
|
||||
assert.Equal(t, totals, targetTotals(t, db, targetID))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -531,6 +531,11 @@ func (e *Engine) processRetryTask(
|
||||
return
|
||||
}
|
||||
|
||||
// Set before anything below can fail the delivery: the failure is
|
||||
// added to this target's totals.
|
||||
d.EventID = task.EventID
|
||||
d.TargetID = task.TargetID
|
||||
|
||||
if d.Status != database.DeliveryStatusRetrying {
|
||||
e.log.Debug(
|
||||
"skipping retry for delivery "+
|
||||
@@ -562,8 +567,6 @@ func (e *Engine) processRetryTask(
|
||||
}
|
||||
|
||||
target := buildTargetFromTask(task)
|
||||
d.EventID = task.EventID
|
||||
d.TargetID = task.TargetID
|
||||
d.Event = event
|
||||
d.Target = target
|
||||
|
||||
@@ -1554,8 +1557,9 @@ func (e *Engine) updateDeliveryStatus(
|
||||
targetType database.TargetType,
|
||||
status database.DeliveryStatus,
|
||||
) error {
|
||||
err := webhookDB.Model(d).
|
||||
Update("status", status).Error
|
||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
return writeDeliveryStatus(tx, d, status)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"updating delivery %s to status %s: %w",
|
||||
@@ -1574,6 +1578,43 @@ func (e *Engine) updateDeliveryStatus(
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
||||
// becomes delivered or failed also gets the time it finished, and is
|
||||
// added to its target's delivered or failed total. That write changes
|
||||
// only a delivery not yet delivered or failed, and the total moves
|
||||
// only when it changed a row: retention may have deleted the delivery
|
||||
// while the engine was working on it, and a recovery path may settle
|
||||
// a delivery that a worker has already settled.
|
||||
func writeDeliveryStatus(
|
||||
tx *gorm.DB,
|
||||
d *database.Delivery,
|
||||
status database.DeliveryStatus,
|
||||
) error {
|
||||
if !status.Terminal() {
|
||||
return tx.Model(d).Update("status", status).Error
|
||||
}
|
||||
|
||||
res := tx.Model(d).
|
||||
Where("status NOT IN ?", []database.DeliveryStatus{
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed,
|
||||
}).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"finished_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||
if status == database.DeliveryStatusFailed {
|
||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||
}
|
||||
|
||||
return database.AddTargetTotals(tx, add)
|
||||
}
|
||||
|
||||
// settleStatus moves a delivery to its outcome status and reports a
|
||||
// failed write through bookkeepingFailed, which leaves the row
|
||||
// recoverable. It exists so the target call sites read as one
|
||||
|
||||
@@ -57,7 +57,10 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
||||
&database.Event{},
|
||||
&database.Delivery{},
|
||||
&database.DeliveryResult{},
|
||||
&database.EventTotals{},
|
||||
&database.TargetTotals{},
|
||||
))
|
||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -150,6 +150,16 @@ func (e *Engine) ExportDeliverSlack(
|
||||
)
|
||||
}
|
||||
|
||||
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
||||
// target type, so no metric moves.
|
||||
func (e *Engine) ExportUpdateDeliveryStatus(
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
status database.DeliveryStatus,
|
||||
) error {
|
||||
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||
}
|
||||
|
||||
// ExportProcessNewTask exposes processNewTask.
|
||||
func (e *Engine) ExportProcessNewTask(
|
||||
ctx context.Context, task *Task,
|
||||
|
||||
@@ -418,6 +418,38 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||
}
|
||||
|
||||
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
|
||||
// that the failure of a retry abandoned because its target is gone is
|
||||
// added to that target's own totals, not to a row with no target.
|
||||
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
var hits atomic.Int64
|
||||
|
||||
task, targetID := tRetryChainSetup(
|
||||
t, s, "gone-counted", &hits,
|
||||
)
|
||||
|
||||
require.NoError(t, s.MainDB.Delete(
|
||||
&database.Target{}, "id = ?", targetID,
|
||||
).Error)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
context.Background(), &task,
|
||||
)
|
||||
|
||||
var rows []database.TargetTotals
|
||||
|
||||
require.NoError(t, s.WebhookDB.Find(&rows).Error)
|
||||
assert.Equal(t, []database.TargetTotals{
|
||||
{TargetID: targetID, Failed: 1},
|
||||
}, rows)
|
||||
}
|
||||
|
||||
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
||||
// mutation check: a liveness check that refused every retry would pass
|
||||
// the test above and break every retry there is.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// SQL — parameters and all — for every statement that returns an
|
||||
// error, including gorm.ErrRecordNotFound. Two of this service's
|
||||
// lookups miss by design on unauthenticated routes: the entrypoint
|
||||
// lookup on /webhook/{uuid}, whose path segment the client picks
|
||||
// lookup on /h/{uuid}, whose path segment the client picks
|
||||
// outright, and the user lookup behind the login form, whose username
|
||||
// the client picks outright. Under the default logger each of those
|
||||
// misses printed an unbounded, attacker-chosen string, at no level the
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -166,11 +166,7 @@ func (h *Handlers) authenticateUser(
|
||||
|
||||
valid, err := database.VerifyPassword(password, user.Password)
|
||||
if err != nil {
|
||||
h.log.Error("failed to verify password", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to verify password", err)
|
||||
|
||||
return user, err
|
||||
}
|
||||
@@ -242,24 +238,14 @@ func (h *Handlers) createAuthenticatedSession(
|
||||
) error {
|
||||
oldSess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to get session", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to regenerate session", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to regenerate session", err)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -268,11 +254,7 @@ func (h *Handlers) createAuthenticatedSession(
|
||||
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
h.log.Error("failed to save session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to save session", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,9 +105,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
|
||||
webhook database.Webhook,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
|
||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||
).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to count in-flight deliveries", err,
|
||||
w, r, "failed to count in-flight deliveries", err,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
|
||||
err = webhookDB.
|
||||
First(&event, "id = ?", original.EventID).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to load event for replay", err)
|
||||
h.serverError(w, r, "failed to load event for replay", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to create replay delivery", err,
|
||||
w, r, "failed to create replay delivery", err,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -299,8 +297,9 @@ func countInFlightDeliveries(
|
||||
return count, err
|
||||
}
|
||||
|
||||
// createReplayDelivery writes the new pending delivery row and returns
|
||||
// the task that carries it to the delivery engine.
|
||||
// createReplayDelivery writes the new pending delivery row, adds it to
|
||||
// its target's totals in the same transaction, and returns the task
|
||||
// that carries it to the delivery engine.
|
||||
//
|
||||
// The row is written with associations omitted, and neither Event nor
|
||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||
@@ -319,7 +318,16 @@ func createReplayDelivery(
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
|
||||
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Omit(clause.Associations).Create(dlv).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.AddTargetTotals(tx, database.TargetTotals{
|
||||
TargetID: dlv.TargetID, Deliveries: 1,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return delivery.Task{}, err
|
||||
}
|
||||
@@ -362,7 +370,7 @@ func (h *Handlers) finishReplay(
|
||||
webhook database.Webhook,
|
||||
code replayOutcomeCode,
|
||||
) {
|
||||
dest := "/source/" + webhook.ID + "/logs?" +
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
replayOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
|
||||
@@ -138,7 +138,7 @@ func postReplay(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+webhookID+"/deliveries/"+
|
||||
"/hook/"+webhookID+"/deliveries/"+
|
||||
deliveryID+"/replay",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=queued",
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=target-deleted",
|
||||
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=target-missing",
|
||||
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||
missing.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||
require.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=queued",
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
first.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=in-flight",
|
||||
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||
second.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?replay=not-terminal",
|
||||
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||
pending.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`action="/source/`+wh.ID+`/deliveries/`+
|
||||
`action="/hook/`+wh.ID+`/deliveries/`+
|
||||
original.ID+`/replay"`,
|
||||
)
|
||||
assert.Contains(t, body, `method="POST"`)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
)
|
||||
|
||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
||||
// which cannot render answers with the status it was reporting, as
|
||||
// plain text, and is not attempted again: a page whose own render
|
||||
// fails reaches the error page, and the error page failing as well
|
||||
// ends there with the 500.
|
||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// .Status is an int, so asking it for a field fails the render.
|
||||
failing := `{{.Status.Missing}}`
|
||||
h.AddTemplateForTest("error.html", template.Must(
|
||||
template.New("error").Parse(failing),
|
||||
))
|
||||
h.AddTemplateForTest("failing.html", template.Must(
|
||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||
// steered by a client.
|
||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
||||
eventID string,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to read event body", err)
|
||||
h.serverError(w, r, "failed to read event body", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
||||
// row and the whole body is served, or it does not and the
|
||||
// response is a clean 404.
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ func fetchEventBody(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/source/"+url.PathEscape(sourceID)+
|
||||
"/logs/"+url.PathEscape(eventID)+"/body",
|
||||
"/hook/"+url.PathEscape(sourceID)+
|
||||
"/events/"+url.PathEscape(eventID)+"/body",
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
||||
page := renderSourceLogsPage(t, h, sess, big.ID)
|
||||
assert.Contains(
|
||||
t, page,
|
||||
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
|
||||
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
|
||||
)
|
||||
|
||||
small := seedWebhook(t, db)
|
||||
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
||||
page = renderSourceLogsPage(t, h, sess, small.ID)
|
||||
assert.NotContains(
|
||||
t, page,
|
||||
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
|
||||
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
||||
// alphabet rather than from the request.
|
||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
||||
webhookDB, webhook.ID, eventID.String(),
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to load event to resubmit", err)
|
||||
h.serverError(w, r, "failed to load event to resubmit", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
||||
// A miss is a 404 whether the event was reaped, belongs to
|
||||
// another webhook, or never existed.
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
||||
// inactive one is skipped rather than refused.
|
||||
targets, err := h.loadActiveTargets(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
h.serverError(w, r, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
||||
targets,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to store resubmitted event", err)
|
||||
h.serverError(w, r, "failed to store resubmitted event", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
|
||||
webhook database.Webhook,
|
||||
code resubmitOutcomeCode,
|
||||
) {
|
||||
dest := "/source/" + webhook.ID + "/logs?" +
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
resubmitOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
|
||||
@@ -65,7 +65,7 @@ func postResubmit(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
),
|
||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"a resubmit must not be refused while an earlier "+
|
||||
"one is in flight",
|
||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"an inactive target is skipped, not an error",
|
||||
)
|
||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
||||
)
|
||||
assert.Contains(
|
||||
t, body,
|
||||
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||
"the log must offer the resubmit action per event",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
@@ -63,12 +67,43 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
||||
page int,
|
||||
) []EventLogView {
|
||||
views, _, _ := s.loadEventsWithDeliveries(
|
||||
w, webhook, nil, page,
|
||||
w, newRequestForTest(), webhook, nil, page,
|
||||
)
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
// WebhookStatsForTest returns the figures the statistics pane on a
|
||||
// webhook's page shows, from the webhook's entrypoints and targets
|
||||
// loaded as that page loads them.
|
||||
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
||||
var entrypoints []database.Entrypoint
|
||||
|
||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
||||
|
||||
var targets []database.Target
|
||||
|
||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
||||
|
||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||
}
|
||||
|
||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||
// handlers_test package.
|
||||
func FinishedByTargetForTest(
|
||||
webhookDB *gorm.DB, since time.Time,
|
||||
) ([]TargetFinished, error) {
|
||||
return finishedByTarget(webhookDB, since)
|
||||
}
|
||||
|
||||
// newRequestForTest is the request the helpers here pass on for
|
||||
// callers that have none: it is used only to render the error page.
|
||||
func newRequestForTest() *http.Request {
|
||||
return httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
}
|
||||
|
||||
// AddTemplateForTest registers a template under a page name so that
|
||||
// the handlers_test package can drive the render path with a
|
||||
// template of its own.
|
||||
@@ -122,5 +157,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
expiry string,
|
||||
) (string, error) {
|
||||
return s.buildDatabaseTargetConfig(w, expiry)
|
||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func postWebhook(
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/webhook/x",
|
||||
context.Background(), http.MethodPost, "/h/x",
|
||||
strings.NewReader("{}"),
|
||||
)
|
||||
|
||||
|
||||
+102
-31
@@ -13,7 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
@@ -54,7 +53,6 @@ type HandlersParams struct {
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
WebhookDBMgr *database.WebhookDBManager
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
@@ -93,18 +91,22 @@ type Handlers struct {
|
||||
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, and navbar templates. The page file must be
|
||||
// listed first so that its root action ({{template "base" .}})
|
||||
// becomes the template set's entry point.
|
||||
func parsePageTemplate(pageFile string) *template.Template {
|
||||
return template.Must(
|
||||
template.ParseFS(
|
||||
templates.Templates,
|
||||
// base, htmlheader, and navbar templates, and with any further files
|
||||
// the page includes. The page file must be listed first so that its
|
||||
// root action ({{template "base" .}}) becomes the template set's entry
|
||||
// point.
|
||||
func parsePageTemplate(
|
||||
pageFile string, included ...string,
|
||||
) *template.Template {
|
||||
files := append([]string{
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
),
|
||||
}, included...)
|
||||
|
||||
return template.Must(
|
||||
template.ParseFS(templates.Templates, files...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,13 +133,13 @@ func New(
|
||||
s.templates = map[string]*template.Template{
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"settings.html": parsePageTemplate("settings.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -149,6 +151,16 @@ func New(
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HandleErrorPage returns a handler that answers every request with
|
||||
// the error page for status. The router uses it for unknown paths, the
|
||||
// CSRF middleware for a refused form, and each admin page route
|
||||
// group's recoverer for a panic.
|
||||
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
@@ -166,15 +178,76 @@ func (s *Handlers) respondJSON(
|
||||
}
|
||||
}
|
||||
|
||||
// serverError logs an error and sends a 500 response.
|
||||
// serverError logs an error and answers with the 500 error page.
|
||||
func (s *Handlers) serverError(
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||
) {
|
||||
s.log.Error(msg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// renderError answers with status and the error page: the normal
|
||||
// layout, one fixed line explaining the status, and a link back to the
|
||||
// webhook list, or to sign-in when nobody is signed in.
|
||||
//
|
||||
// It renders the page itself rather than through renderTemplate,
|
||||
// whose own failure comes here. If the error page cannot render
|
||||
// either, the answer is the same status in plain text: never a second
|
||||
// attempt, and never a different status.
|
||||
func (s *Handlers) renderError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
status int,
|
||||
) {
|
||||
// The page names the signed-in user, and some error pages are
|
||||
// served outside the routes where NoCache runs.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
data := s.pageData(r, map[string]any{
|
||||
"Status": status,
|
||||
"StatusText": http.StatusText(status),
|
||||
"Message": errorPageText(status),
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := s.templates["error.html"].Execute(&buf, data)
|
||||
if err != nil {
|
||||
s.log.Error("failed to render error page", "error", err)
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_, err = buf.WriteTo(w)
|
||||
if err != nil {
|
||||
s.log.Error("failed to write error page", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errorPageText is the line the error page shows for status. It is
|
||||
// fixed per status, so the page tells the reader no more than the
|
||||
// plain-text answers it replaced did.
|
||||
func errorPageText(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "The request could not be read."
|
||||
case http.StatusForbidden:
|
||||
return "The request was refused. If it came from a form " +
|
||||
"left open for a long time, reload the page and try " +
|
||||
"again."
|
||||
case http.StatusNotFound:
|
||||
return "There is nothing here. It may have been deleted, " +
|
||||
"or the address may be wrong."
|
||||
case http.StatusServiceUnavailable:
|
||||
return "The server is busy. Please try again in a moment."
|
||||
default: // http.StatusInternalServerError
|
||||
return "Something went wrong on the server. Please try " +
|
||||
"again."
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfo represents user information for templates
|
||||
@@ -227,14 +300,17 @@ func (s *Handlers) renderTemplate(
|
||||
"template not found",
|
||||
"template", pageTemplate,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
||||
}
|
||||
|
||||
// pageData adds the fields the shared layout renders to a page's own
|
||||
// data.
|
||||
func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
@@ -248,19 +324,16 @@ func (s *Handlers) renderTemplate(
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
return
|
||||
return m
|
||||
}
|
||||
|
||||
wrapper := templateDataWrapper{
|
||||
return templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
s.executeTemplate(w, tmpl, wrapper)
|
||||
}
|
||||
|
||||
// executeTemplate renders the template into a buffer and writes to
|
||||
@@ -273,6 +346,7 @@ func (s *Handlers) renderTemplate(
|
||||
// this reason.
|
||||
func (s *Handlers) executeTemplate(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
tmpl *template.Template,
|
||||
data any,
|
||||
) {
|
||||
@@ -283,10 +357,7 @@ func (s *Handlers) executeTemplate(
|
||||
s.log.Error(
|
||||
"failed to execute template", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
s.renderError(w, r, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,25 +83,16 @@ func newTestApp(
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return newTestAppWithConfig(
|
||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||
)
|
||||
}
|
||||
|
||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||
func newTestAppWithConfig(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config { return cfg },
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
@@ -185,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||
assert.Equal(
|
||||
t, "/sources", w2.Header().Get("Location"),
|
||||
t, "/hooks", w2.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
"a failed render must report a 500",
|
||||
)
|
||||
assert.Equal(
|
||||
t, "Internal server error\n", w.Body.String(),
|
||||
assert.NotContains(
|
||||
t, w.Body.String(), partialPageMarker,
|
||||
"the response must carry no part of the aborted page",
|
||||
)
|
||||
assert.Contains(
|
||||
t, w.Body.String(), "500 Internal Server Error",
|
||||
"a failed render must answer with the error page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
)
|
||||
|
||||
// HandleIndex returns a handler for the root path that redirects
|
||||
// based on authentication state: authenticated users go to /sources
|
||||
// based on authentication state: authenticated users go to /hooks
|
||||
// (the dashboard), unauthenticated users go to the login page.
|
||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.Get(r)
|
||||
if err == nil && s.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package handlers_test
|
||||
// this package reach a value an UNAUTHENTICATED client picks outright
|
||||
// and of a length it picks outright:
|
||||
//
|
||||
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
|
||||
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
|
||||
// path segment matched no stored entrypoint and so is bounded by
|
||||
// nothing;
|
||||
// - the failed-login DEBUG lines, whose username is a form field.
|
||||
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
|
||||
// route pattern.
|
||||
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Post("/webhook/{uuid}", h.HandleWebhook())
|
||||
router.Post("/h/{uuid}", h.HandleWebhook())
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// postReceiver sends one POST at /webhook/<segment>.
|
||||
// postReceiver sends one POST at /h/<segment>.
|
||||
//
|
||||
// RawPath is cleared after parsing so chi routes on the decoded path
|
||||
// and the handler sees the raw bytes rather than their percent-escaped
|
||||
@@ -210,7 +210,7 @@ func postReceiver(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/webhook/"+url.PathEscape(segment),
|
||||
"/h/"+url.PathEscape(segment),
|
||||
strings.NewReader(""),
|
||||
)
|
||||
req.URL.RawPath = ""
|
||||
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
|
||||
http.StatusServiceUnavailable,
|
||||
postLoginAtPath(
|
||||
t, h,
|
||||
"/source/"+url.PathEscape(
|
||||
"/hook/"+url.PathEscape(
|
||||
oversizedFill(fill),
|
||||
)+"/login",
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||
r.Context(),
|
||||
w,
|
||||
r,
|
||||
sessionUsername,
|
||||
// PostFormValue, not FormValue: the credential must
|
||||
// come from the body, never from the query string.
|
||||
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
||||
// applyPasswordChange verifies the current password and, on success,
|
||||
// persists a fresh hash for the user, reusing the same helpers that
|
||||
// bootstrap the admin user. It returns the success and error messages
|
||||
// to display on the profile page. On an internal failure it writes a
|
||||
// 500 response itself and returns handled=false, signalling the caller
|
||||
// to display on the profile page. On an internal failure it writes the
|
||||
// error page itself and returns handled=false, signalling the caller
|
||||
// to stop without re-rendering the page.
|
||||
func (h *Handlers) applyPasswordChange(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
username, currentPassword, newPassword, confirmPassword string,
|
||||
) (string, string, bool) {
|
||||
// This endpoint verifies one password and hashes another, at
|
||||
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
|
||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||
// this path outside it would leave a hole in it. The slot is held
|
||||
// across both hashes.
|
||||
release, ok := h.mw.BeginPasswordVerification(ctx)
|
||||
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
||||
if !ok {
|
||||
h.log.Warn("password verification capacity exhausted")
|
||||
http.Error(
|
||||
w,
|
||||
"The server is busy verifying credentials. "+
|
||||
"Please try again.",
|
||||
http.StatusServiceUnavailable,
|
||||
)
|
||||
h.renderError(w, r, http.StatusServiceUnavailable)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
).First(&user).Error
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to load user for password change", err,
|
||||
w, r, "failed to load user for password change", err,
|
||||
)
|
||||
|
||||
return "", "", false
|
||||
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
currentPassword, user.Password,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to verify password", err)
|
||||
h.serverError(w, r, "failed to verify password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
|
||||
hashedPassword, err := database.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to hash new password", err)
|
||||
h.serverError(w, r, "failed to hash new password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
|
||||
"password", hashedPassword,
|
||||
).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to update password", err)
|
||||
h.serverError(w, r, "failed to update password", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
) (string, string, bool) {
|
||||
requestedUsername := chi.URLParam(r, "username")
|
||||
if requestedUsername == "" {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
// unexpected retrieval error.
|
||||
sess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get session", err)
|
||||
h.serverError(w, r, "failed to get session", err)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
sessionUsername, ok := h.session.GetUsername(sess)
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing username")
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.renderError(w, r, http.StatusInternalServerError)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
|
||||
sessionUserID, ok := h.session.GetUserID(sess)
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing user ID")
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.renderError(w, r, http.StatusInternalServerError)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Only allow users to act on their own profile.
|
||||
if requestedUsername != sessionUsername {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
h.renderError(w, r, http.StatusForbidden)
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestApp(t, &log, &cfg, &sess)
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &log, &cfg, &sess, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/user/{username}", func(r chi.Router) {
|
||||
r.Use(mw.CSRF())
|
||||
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(mw.RequireAuth())
|
||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
handlerReached = true
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||
body := strings.Repeat("é", 1024)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/webhook/x",
|
||||
context.Background(), http.MethodPost, "/h/x",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
)
|
||||
|
||||
// notSet is what the Settings page shows for a value that is empty.
|
||||
const notSet = "not set"
|
||||
|
||||
// settingRow is one line of the Settings page: an environment
|
||||
// variable, what it controls, and the value the server loaded for it.
|
||||
type settingRow struct {
|
||||
Name string
|
||||
Description string
|
||||
Value string
|
||||
}
|
||||
|
||||
// HandleSettings returns a handler for the read-only Settings page,
|
||||
// which lists the configuration the server started with.
|
||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||
"Settings": settingRows(h.params.Config),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// settingRows lists every field of cfg under the environment variable
|
||||
// it is read from, in the order of the README's configuration table.
|
||||
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
|
||||
// never reach the page: only whether they are set.
|
||||
func settingRows(cfg *config.Config) []settingRow {
|
||||
metricsUsername := cfg.MetricsUsername
|
||||
if metricsUsername == "" {
|
||||
metricsUsername = notSet
|
||||
}
|
||||
|
||||
return []settingRow{
|
||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||
{
|
||||
"BIND_ADDRESS",
|
||||
"IP address the HTTP listener binds",
|
||||
cfg.BindAddress,
|
||||
},
|
||||
{
|
||||
"DATA_DIR",
|
||||
"Directory for all SQLite databases",
|
||||
cfg.DataDir,
|
||||
},
|
||||
{
|
||||
"DEBUG",
|
||||
"Enable debug logging",
|
||||
strconv.FormatBool(cfg.Debug),
|
||||
},
|
||||
{
|
||||
"MAINTENANCE_MODE",
|
||||
"Report maintenanceMode: true in the healthcheck JSON. " +
|
||||
"It does not change how any request is served",
|
||||
strconv.FormatBool(cfg.MaintenanceMode),
|
||||
},
|
||||
{
|
||||
"METRICS_USERNAME",
|
||||
"Basic auth username for /metrics",
|
||||
metricsUsername,
|
||||
},
|
||||
{
|
||||
"METRICS_PASSWORD",
|
||||
"Basic auth password for /metrics",
|
||||
setOrNotSet(cfg.MetricsPassword),
|
||||
},
|
||||
{
|
||||
"SENTRY_DSN",
|
||||
"Error reporting DSN. Unset leaves error reporting off",
|
||||
setOrNotSet(cfg.SentryDSN),
|
||||
},
|
||||
{
|
||||
"RETENTION_SWEEP_INTERVAL",
|
||||
"How often the retention reaper and archive sweeper run",
|
||||
cfg.RetentionSweepInterval.String(),
|
||||
},
|
||||
{
|
||||
"SESSION_IDLE_TIMEOUT",
|
||||
"Idle session timeout. Zero or negative disables idle " +
|
||||
"expiry",
|
||||
cfg.SessionIdleTimeout.String(),
|
||||
},
|
||||
{
|
||||
"RECEIVER_RATE_LIMIT",
|
||||
"Receiver requests per minute per IP per entrypoint " +
|
||||
"(10x that per IP across the route)",
|
||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||
},
|
||||
{
|
||||
"TRUSTED_PROXIES",
|
||||
"CIDRs whose forwarded headers are trusted",
|
||||
cidrList(cfg.TrustedProxies),
|
||||
},
|
||||
{
|
||||
"ALLOWED_EGRESS_CIDRS",
|
||||
"CIDRs that delivery targets may reach despite the " +
|
||||
"SSRF blocklist",
|
||||
cidrList(cfg.AllowedEgressCIDRs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||
// has a value, never the value itself.
|
||||
func setOrNotSet(value string) string {
|
||||
if value == "" {
|
||||
return notSet
|
||||
}
|
||||
|
||||
return "set"
|
||||
}
|
||||
|
||||
// cidrList renders a CIDR list setting for the Settings page.
|
||||
func cidrList(prefixes []netip.Prefix) string {
|
||||
if len(prefixes) == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||
// and returns the value it shows for each variable name, plus the
|
||||
// whole page.
|
||||
func settingsShown(
|
||||
t *testing.T, cfg *config.Config,
|
||||
) (map[string]string, string) {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/settings", nil,
|
||||
)
|
||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSettings().ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
row := regexp.MustCompile(
|
||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||
)
|
||||
|
||||
shown := map[string]string{}
|
||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||
shown[match[1]] = html.UnescapeString(match[2])
|
||||
}
|
||||
|
||||
return shown, body
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const metricsPassword = "metrics-password-1f9a"
|
||||
|
||||
// No two rows show the same value: DEBUG and MAINTENANCE_MODE, and
|
||||
// METRICS_PASSWORD and SENTRY_DSN, get opposite values, so each row
|
||||
// is checked against its own field.
|
||||
cfg := &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Debug: true,
|
||||
MaintenanceMode: false,
|
||||
Environment: config.EnvironmentDev,
|
||||
MetricsUsername: "scraper",
|
||||
MetricsPassword: metricsPassword,
|
||||
Port: 9123,
|
||||
SentryDSN: "",
|
||||
BindAddress: "192.0.2.10",
|
||||
RetentionSweepInterval: 17 * time.Minute,
|
||||
SessionIdleTimeout: 3 * time.Hour,
|
||||
ReceiverRateLimit: 77,
|
||||
TrustedProxies: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.1.0.0/16"),
|
||||
},
|
||||
AllowedEgressCIDRs: []netip.Prefix{
|
||||
netip.MustParsePrefix("192.168.5.0/24"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
}
|
||||
|
||||
shown, body := settingsShown(t, cfg)
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||
"PORT": "9123",
|
||||
"BIND_ADDRESS": "192.0.2.10",
|
||||
"DATA_DIR": cfg.DataDir,
|
||||
"DEBUG": "true",
|
||||
"MAINTENANCE_MODE": "false",
|
||||
"METRICS_USERNAME": "scraper",
|
||||
"METRICS_PASSWORD": "set",
|
||||
"SENTRY_DSN": "not set",
|
||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||
"RECEIVER_RATE_LIMIT": "77",
|
||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||
}, shown)
|
||||
|
||||
assert.NotContains(t, body, metricsPassword)
|
||||
assert.Contains(
|
||||
t, body, `href="/settings"`,
|
||||
"the navigation bar links to the page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
sentryKey = "dsnkey7c2e"
|
||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||
)
|
||||
|
||||
// SENTRY_DSN is set here and empty in the test above, the opposite
|
||||
// of METRICS_PASSWORD, so each secret is seen both set and not set.
|
||||
shown, body := settingsShown(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
SentryDSN: sentryDSN,
|
||||
})
|
||||
|
||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
||||
assert.NotContains(t, body, sentryKey)
|
||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||
)
|
||||
assert.Empty(
|
||||
t, w.Header().Get("Location"),
|
||||
"a failed deletion must not redirect to /sources",
|
||||
"a failed deletion must not redirect to /hooks",
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
|
||||
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/source/"+f.webhook,
|
||||
"/hook/"+f.webhook,
|
||||
nil,
|
||||
)
|
||||
req.Host = host
|
||||
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
||||
tc.scheme+"://"+host+"/h/"+fixture.path,
|
||||
fixture.entrypointURL(
|
||||
t, host, forwardedProto(tc.header),
|
||||
),
|
||||
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://"+host+"/webhook/"+fixture.path,
|
||||
"https://"+host+"/h/"+fixture.path,
|
||||
got,
|
||||
"a connection this process terminated with TLS "+
|
||||
"outranks a header claiming plaintext",
|
||||
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://"+host+"/webhook/"+fixture.path,
|
||||
"https://"+host+"/h/"+fixture.path,
|
||||
fixture.entrypointURL(
|
||||
t, host, forwardedProto("HTTPS"),
|
||||
),
|
||||
|
||||
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/source/"+webhookID,
|
||||
"/hook/"+webhookID,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
),
|
||||
|
||||
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/source/"+webhookID+"/logs"+query,
|
||||
"/hook/"+webhookID+"/events"+query,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
||||
"user_id = ?", userID,
|
||||
).Order("created_at DESC").Find(&webhooks).Error
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to list webhooks", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to list webhooks", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
||||
|
||||
err := h.commitWebhook(webhook)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to create webhook", err)
|
||||
h.serverError(w, r, "failed to create webhook", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -330,7 +322,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
||||
)
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -420,7 +412,7 @@ func (h *Handlers) renderSourceDetail(
|
||||
if h.dbMgr.DBExists(webhook.ID) {
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -429,7 +421,7 @@ func (h *Handlers) renderSourceDetail(
|
||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to load recent events", err)
|
||||
h.serverError(w, r, "failed to load recent events", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -457,6 +449,7 @@ func (h *Handlers) renderSourceDetail(
|
||||
"Targets": delivery.NewTargetViews(targets),
|
||||
"Events": events,
|
||||
"BaseURL": baseURL,
|
||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_detail.html", data)
|
||||
@@ -482,7 +475,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -517,7 +510,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -526,9 +519,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -582,13 +573,13 @@ func (h *Handlers) applyWebhookEdit(
|
||||
|
||||
err := h.db.DB().Save(webhook).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to update webhook", err)
|
||||
h.serverError(w, r, "failed to update webhook", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -612,7 +603,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -639,7 +630,7 @@ func (h *Handlers) deleteWebhookResources(
|
||||
// be removed by hand; deleted history cannot be recovered.
|
||||
err := h.commitWebhookDeletion(&webhook)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to delete webhook", err)
|
||||
h.serverError(w, r, "failed to delete webhook", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -665,13 +656,13 @@ func (h *Handlers) deleteWebhookResources(
|
||||
// redirecting as though everything succeeded: the file
|
||||
// needs removing by hand, and the logged error names it.
|
||||
h.serverError(
|
||||
w, "failed to delete webhook event database", err,
|
||||
w, r, "failed to delete webhook event database", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||
@@ -809,7 +800,7 @@ func (h *Handlers) ownedWebhook(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return database.Webhook{}, false
|
||||
}
|
||||
@@ -831,7 +822,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
// Without the map every delivery renders through a
|
||||
// zero redactor, so failing the page is the only
|
||||
// safe answer.
|
||||
h.serverError(w, "failed to load targets", err)
|
||||
h.serverError(w, r, "failed to load targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -839,7 +830,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
page := h.parsePage(r)
|
||||
|
||||
evts, total, ok := h.loadEventsWithDeliveries(
|
||||
w, webhook, targets, page,
|
||||
w, r, webhook, targets, page,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
@@ -949,6 +940,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
||||
// caller must then render nothing further.
|
||||
func (h *Handlers) loadEventsWithDeliveries(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
targetMap map[string]eventLogTarget,
|
||||
page int,
|
||||
@@ -962,7 +954,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to get webhook database", err,
|
||||
w, r, "failed to get webhook database", err,
|
||||
)
|
||||
|
||||
return nil, 0, false
|
||||
@@ -999,7 +991,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to load delivery attempts", err,
|
||||
w, r, "failed to load delivery attempts", err,
|
||||
)
|
||||
|
||||
return nil, 0, false
|
||||
@@ -1008,7 +1000,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to count event resubmissions", err,
|
||||
w, r, "failed to count event resubmissions", err,
|
||||
)
|
||||
|
||||
return nil, 0, false
|
||||
@@ -1231,7 +1223,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1240,9 +1232,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1258,13 +1248,13 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
|
||||
err = h.db.DB().Create(entrypoint).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to create entrypoint", err)
|
||||
h.serverError(w, r, "failed to create entrypoint", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1289,7 +1279,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1298,9 +1288,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1320,7 +1308,7 @@ func (h *Handlers) processTargetCreate(
|
||||
//
|
||||
// Every field here is read with PostFormValue, not FormValue.
|
||||
// FormValue falls back to the query string, which would let
|
||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// configure a target from a value the request line carries — and
|
||||
// the request line, unlike the body, is what logs, proxies,
|
||||
// Referer headers and error trackers record.
|
||||
@@ -1371,13 +1359,13 @@ func (h *Handlers) processTargetCreate(
|
||||
|
||||
err = h.db.DB().Create(target).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to create target", err)
|
||||
h.serverError(w, r, "failed to create target", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1435,7 +1423,7 @@ type targetFormInput struct {
|
||||
//
|
||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||
// falls back to the query string, which would let
|
||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// configure a target from a value the request line carries — and the
|
||||
// request line, unlike the body, is what logs, proxies, Referer
|
||||
// headers and error trackers record. The headers field is under the
|
||||
@@ -1465,7 +1453,7 @@ func (h *Handlers) buildTargetConfig(
|
||||
case database.TargetTypeSlack:
|
||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||
case database.TargetTypeDatabase:
|
||||
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
||||
case database.TargetTypeLog:
|
||||
return "", nil
|
||||
default:
|
||||
@@ -1515,7 +1503,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
||||
URL: in.URL,
|
||||
Headers: headers,
|
||||
Timeout: timeout,
|
||||
@@ -1537,7 +1525,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
||||
WebhookURL: targetURL,
|
||||
})
|
||||
}
|
||||
@@ -1591,16 +1579,14 @@ func (h *Handlers) validateTargetURL(
|
||||
|
||||
// marshalTargetConfig serialises a target configuration for storage,
|
||||
// writing a 500 itself if it cannot.
|
||||
func marshalTargetConfig(
|
||||
func (h *Handlers) marshalTargetConfig(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
cfg any,
|
||||
) (string, error) {
|
||||
configBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, "failed to encode target config", err)
|
||||
|
||||
return "", err
|
||||
}
|
||||
@@ -1616,6 +1602,7 @@ func marshalTargetConfig(
|
||||
// expiry yields an empty config (the keep-forever default).
|
||||
func (h *Handlers) buildDatabaseTargetConfig(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
expiry string,
|
||||
) (string, error) {
|
||||
expiry = strings.TrimSpace(expiry)
|
||||
@@ -1634,8 +1621,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalTargetConfig(
|
||||
w, map[string]any{"expiry": expiry},
|
||||
return h.marshalTargetConfig(
|
||||
w, r, map[string]any{"expiry": expiry},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1689,7 +1676,7 @@ func (h *Handlers) deleteChildResource(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1699,11 +1686,7 @@ func (h *Handlers) deleteChildResource(
|
||||
childID, webhook.ID,
|
||||
).Delete(model)
|
||||
if result.Error != nil {
|
||||
h.log.Error(errMsg, "error", result.Error)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, errMsg, result.Error)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1714,7 +1697,7 @@ func (h *Handlers) deleteChildResource(
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/source/"+webhook.ID,
|
||||
"/hook/"+webhook.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
@@ -1793,25 +1776,21 @@ func (h *Handlers) toggleChildResource(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = toggleFn(webhook.ID, childID)
|
||||
if err != nil {
|
||||
h.log.Error(errMsg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
h.serverError(w, r, errMsg, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/source/"+webhook.ID,
|
||||
"/hook/"+webhook.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func submitCreate(
|
||||
form.Set("retention_days", *retention)
|
||||
}
|
||||
|
||||
req := formRequest("/sources/new", cookies, form, nil)
|
||||
req := formRequest("/hooks/new", cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceCreate().ServeHTTP(
|
||||
w, getRequest(t, "/sources/new", env.cookies, nil),
|
||||
w, getRequest(t, "/hooks/new", env.cookies, nil),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
form.Set("description", description)
|
||||
form.Set("retention_days", "nonsense")
|
||||
|
||||
req := formRequest("/sources/new", env.cookies, form, nil)
|
||||
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
@@ -430,7 +430,7 @@ func submitEdit(
|
||||
form.Set("retention_days", retention)
|
||||
|
||||
req := formRequest(
|
||||
"/source/"+wh.ID+"/edit",
|
||||
"/hook/"+wh.ID+"/edit",
|
||||
env.cookies,
|
||||
form,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
)
|
||||
|
||||
req := getRequest(
|
||||
t, "/source/"+wh.ID+"/edit", env.cookies,
|
||||
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
|
||||
listW := httptest.NewRecorder()
|
||||
env.handlers.HandleSourceList().ServeHTTP(
|
||||
listW, getRequest(t, "/sources", env.cookies, nil),
|
||||
listW, getRequest(t, "/hooks", env.cookies, nil),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, listW.Code)
|
||||
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||
detailW,
|
||||
getRequest(
|
||||
t, "/source/"+wh.ID, env.cookies,
|
||||
t, "/hook/"+wh.ID, env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,11 +76,11 @@ func postTargetCreate(
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.Logging())
|
||||
router.Post(
|
||||
"/source/{sourceID}/targets",
|
||||
"/hook/{sourceID}/targets",
|
||||
env.handlers.HandleTargetCreate(),
|
||||
)
|
||||
|
||||
target := "/source/" + webhookID + "/targets"
|
||||
target := "/hook/" + webhookID + "/targets"
|
||||
if query != "" {
|
||||
target += "?" + query
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func postTargetCreate(
|
||||
// regression test for the ingress leak. r.FormValue falls back to the
|
||||
// query string when a field is absent from the POST body, so
|
||||
//
|
||||
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
|
||||
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/...
|
||||
//
|
||||
// with an empty url field used to create a working target from a value
|
||||
// carried on the request line — where logs, proxies, Referer headers
|
||||
|
||||
@@ -47,7 +47,7 @@ type targetEditView struct {
|
||||
//
|
||||
// This page is the one place the full destination URL and header
|
||||
// values are shown. It is reachable only through the
|
||||
// /source/{sourceID} route group, which supplies RequireAuth and
|
||||
// /hook/{sourceID} route group, which supplies RequireAuth and
|
||||
// NoCache, and only for a target of a webhook the session's user
|
||||
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
||||
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -157,13 +155,13 @@ func (h *Handlers) applyTargetEdit(
|
||||
|
||||
err = h.db.DB().Save(target).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to update target", err)
|
||||
h.serverError(w, r, "failed to update target", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,7 +218,7 @@ func (h *Handlers) ownedTarget(
|
||||
chi.URLParam(r, "targetID"), webhook.ID,
|
||||
).First(&target).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return database.Webhook{}, nil, false
|
||||
}
|
||||
|
||||
@@ -42,15 +42,15 @@ const (
|
||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Post(
|
||||
"/source/{sourceID}/targets",
|
||||
"/hook/{sourceID}/targets",
|
||||
env.handlers.HandleTargetCreate(),
|
||||
)
|
||||
router.Get(
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEdit(),
|
||||
)
|
||||
router.Post(
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEditSubmit(),
|
||||
)
|
||||
|
||||
@@ -117,7 +117,7 @@ func seedHTTPTarget(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhook.ID+"/targets", form,
|
||||
"/hook/"+webhook.ID+"/targets", form,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
@@ -188,7 +188,7 @@ func submitTargetEdit(
|
||||
) *httptest.ResponseRecorder {
|
||||
return serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhookID+"/targets/"+targetID+"/edit",
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
|
||||
form,
|
||||
)
|
||||
}
|
||||
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+
|
||||
"/hook/"+webhook.ID+"/targets/"+target.ID+
|
||||
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
||||
"&headers="+url.QueryEscape(editAuthHeader),
|
||||
form,
|
||||
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
|
||||
|
||||
get := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNotFound, get.Code)
|
||||
|
||||
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
@@ -102,7 +102,7 @@ func createWithRetries(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhook.ID+"/targets",
|
||||
"/hook/"+webhook.ID+"/targets",
|
||||
createRetriesForm(retries),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ func renderPage(
|
||||
}
|
||||
|
||||
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
||||
// label to "Webhooks". The /sources route is deliberately unchanged, so
|
||||
// the assertion targets the link text rather than the href.
|
||||
// label to "Webhooks" and its link to the webhook list at /hooks.
|
||||
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -95,15 +94,11 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||
t, body, ">Sources<",
|
||||
"no user-visible element may still be labelled Sources",
|
||||
)
|
||||
assert.Contains(
|
||||
t, body, `href="/sources"`,
|
||||
"the /sources route itself must not change",
|
||||
)
|
||||
assert.Contains(t, body, `href="/hooks"`)
|
||||
}
|
||||
|
||||
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
||||
// its back link. The link's href still points at /source/{id}, which is
|
||||
// intentional: only user-visible copy changes.
|
||||
// its back link to the webhook page at /hook/{id}.
|
||||
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -130,7 +125,57 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||
|
||||
assert.Contains(t, body, "Edit Webhook")
|
||||
assert.NotContains(t, body, ">Sources<")
|
||||
assert.Contains(t, body, `href="/source/wh-1"`)
|
||||
assert.Contains(t, body, `href="/hook/wh-1"`)
|
||||
}
|
||||
|
||||
// TestEventLogPageIsCalledFullEventLog pins the one name the event log
|
||||
// page at /hook/{id}/events goes by: both links to it on the webhook
|
||||
// page, and its own heading, read "Full Event Log".
|
||||
func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestApp(t, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// A pointer, as in the handlers: source_detail.html calls
|
||||
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
||||
// over their lists, and a list left out renders as empty, so the
|
||||
// lists are left out.
|
||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||
webhook.ID = testWebhookID
|
||||
|
||||
detailBody := renderPage(
|
||||
t, h, sess, "source_detail.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
},
|
||||
)
|
||||
|
||||
assert.Contains(
|
||||
t, detailBody,
|
||||
`<a href="/hook/wh-1/events" class="btn-secondary">Full Event Log</a>`,
|
||||
"the button at the top of the webhook page",
|
||||
)
|
||||
assert.Contains(
|
||||
t, detailBody,
|
||||
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
|
||||
"the link under recent events",
|
||||
)
|
||||
|
||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
"TotalEvents": int64(0),
|
||||
})
|
||||
|
||||
assert.Contains(
|
||||
t, logBody,
|
||||
`<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>`,
|
||||
)
|
||||
}
|
||||
|
||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||
@@ -283,7 +328,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||
t, body,
|
||||
`<code id="entrypoint-url-ep-1"`,
|
||||
)
|
||||
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
|
||||
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||
|
||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
||||
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to serialize headers", err)
|
||||
h.receiverError(w, "failed to serialize headers", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
h.receiverError(w, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
|
||||
"path = ?", entrypointUUID,
|
||||
).First(&entrypoint)
|
||||
if result.Error != nil {
|
||||
// The receiver is unauthenticated and /webhook/{uuid}
|
||||
// The receiver is unauthenticated and /h/{uuid}
|
||||
// matches any single segment, so this value is entirely
|
||||
// client-chosen on exactly the branch where the lookup
|
||||
// failed. DEBUG is off by default; the cap is what keeps
|
||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
||||
targets,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to store webhook event", err)
|
||||
h.receiverError(w, "failed to store webhook event", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
|
||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||
}
|
||||
|
||||
// receiverError logs an error and answers the sender with a plain-text
|
||||
// 500. The receiver's answers are for programs, so it never sends the
|
||||
// error page the web UI uses.
|
||||
func (h *Handlers) receiverError(
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
) {
|
||||
h.log.Error(msg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// eventSource carries the fields a new event is built from. The
|
||||
// receiver fills it from the live request; the resubmit handler fills
|
||||
// it from a stored event. Both then go through createAndFanOut, so an
|
||||
@@ -253,11 +266,12 @@ func requestEventSource(
|
||||
}
|
||||
}
|
||||
|
||||
// createAndFanOut writes the event and one pending delivery per target
|
||||
// in a single transaction, then hands the tasks to the delivery
|
||||
// engine. It is the only path by which an event and its deliveries are
|
||||
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||
// circuit-broken exactly as a received one is.
|
||||
// createAndFanOut writes the event and one pending delivery per target,
|
||||
// and adds them to the webhook's running totals, in a single
|
||||
// transaction, then hands the tasks to the delivery engine. It is the
|
||||
// only path by which an event and its deliveries are created, so a
|
||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||
// exactly as a received one is.
|
||||
//
|
||||
// The tasks are returned as well as queued, so a caller can report how
|
||||
// many targets the event went to.
|
||||
@@ -297,6 +311,15 @@ func (h *Handlers) createAndFanOut(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = database.AddEventTotals(tx, database.EventTotals{
|
||||
Events: 1, LastEventAt: &event.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
@@ -355,8 +378,9 @@ func (h *Handlers) finishWebhookResponse(
|
||||
}
|
||||
|
||||
// buildDeliveryTasks creates one pending delivery per target in the
|
||||
// transaction and returns the tasks for the delivery engine. The
|
||||
// caller owns the transaction and rolls it back on error.
|
||||
// transaction, adds each to its target's totals, and returns the tasks
|
||||
// for the delivery engine. The caller owns the transaction and rolls
|
||||
// it back on error.
|
||||
func buildDeliveryTasks(
|
||||
tx *gorm.DB,
|
||||
event *database.Event,
|
||||
@@ -380,6 +404,13 @@ func buildDeliveryTasks(
|
||||
)
|
||||
}
|
||||
|
||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
||||
TargetID: targets[i].ID, Deliveries: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tasks = append(tasks, delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// The spans of the two recent windows the statistics pane reports on:
|
||||
// the last 10 minutes and the last 24 hours.
|
||||
const (
|
||||
shortWindow = 10 * time.Minute
|
||||
longWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
// percent turns a fraction into a percentage.
|
||||
const percent = 100
|
||||
|
||||
// WebhookStats holds the figures in the statistics pane at the top of
|
||||
// the webhook page.
|
||||
type WebhookStats struct {
|
||||
Entrypoints int
|
||||
ActiveEntrypoints int
|
||||
Targets int
|
||||
ActiveTargets int
|
||||
|
||||
// Lifetime counts every event, delivery and failure the webhook
|
||||
// has had, and WithinRetention those still stored.
|
||||
Lifetime Counts
|
||||
WithinRetention Counts
|
||||
|
||||
// InProgress counts the deliveries still pending or retrying.
|
||||
InProgress int64
|
||||
|
||||
// LastEventAt is when the newest event arrived, or nil when none
|
||||
// has. Retention does not change it.
|
||||
LastEventAt *time.Time
|
||||
|
||||
Last10Minutes RecentWindow
|
||||
Last24Hours RecentWindow
|
||||
}
|
||||
|
||||
// Counts holds a number of events, of deliveries and of failed
|
||||
// deliveries.
|
||||
type Counts struct {
|
||||
Events int64
|
||||
Deliveries int64
|
||||
Failures int64
|
||||
}
|
||||
|
||||
// RecentWindow holds what happened in one recent window: the events
|
||||
// received in it, and the deliveries that became delivered or failed in
|
||||
// it.
|
||||
type RecentWindow struct {
|
||||
Events int64
|
||||
Delivered int64
|
||||
Failed int64
|
||||
}
|
||||
|
||||
// TargetFinished is how many of one target's deliveries became
|
||||
// delivered, and how many failed, in a recent window.
|
||||
type TargetFinished struct {
|
||||
TargetID string
|
||||
Delivered int64
|
||||
Failed int64
|
||||
}
|
||||
|
||||
// FailurePercent is the share of the deliveries finished in the window
|
||||
// that failed, or a dash when none finished. Deliveries still pending
|
||||
// or retrying are not counted either way.
|
||||
func (w RecentWindow) FailurePercent() string {
|
||||
finished := w.Delivered + w.Failed
|
||||
if finished == 0 {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
||||
)
|
||||
}
|
||||
|
||||
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||
// webhook's entrypoints and targets, as the page has already loaded
|
||||
// them, and from its event database. It returns nil, and logs why, when
|
||||
// the event database cannot be read.
|
||||
func (h *Handlers) loadWebhookStats(
|
||||
webhookID string,
|
||||
entrypoints []database.Entrypoint,
|
||||
targets []database.Target,
|
||||
) *WebhookStats {
|
||||
stats := &WebhookStats{
|
||||
Entrypoints: len(entrypoints),
|
||||
Targets: len(targets),
|
||||
}
|
||||
|
||||
for i := range entrypoints {
|
||||
if entrypoints[i].Active {
|
||||
stats.ActiveEntrypoints++
|
||||
}
|
||||
}
|
||||
|
||||
for i := range targets {
|
||||
if targets[i].Active {
|
||||
stats.ActiveTargets++
|
||||
}
|
||||
}
|
||||
|
||||
// Opening an event database that does not exist would create it,
|
||||
// and it would hold nothing to count.
|
||||
if !h.dbMgr.DBExists(webhookID) {
|
||||
return stats
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||
if err == nil {
|
||||
err = readEventStats(webhookDB, time.Now(), stats)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to read webhook statistics",
|
||||
"webhook_id", webhookID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// readEventStats fills in the figures that come from the webhook's
|
||||
// event database. None of them reads every stored row: the totals are
|
||||
// one row for the events and one per target for the deliveries, and
|
||||
// every other figure is read from an index, over only the rows it
|
||||
// counts.
|
||||
func readEventStats(
|
||||
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||
) error {
|
||||
err := readTotals(db, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Model(&database.Delivery{}).
|
||||
Where("status IN ?", []database.DeliveryStatus{
|
||||
database.DeliveryStatusPending,
|
||||
database.DeliveryStatusRetrying,
|
||||
}).
|
||||
Count(&stats.InProgress).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting deliveries in progress: %w", err)
|
||||
}
|
||||
|
||||
stats.Last10Minutes, err = readRecentWindow(
|
||||
db, now.Add(-shortWindow),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stats.Last24Hours, err = readRecentWindow(
|
||||
db, now.Add(-longWindow),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// readTotals fills in the lifetime and within-retention figures, and
|
||||
// when the last event arrived, from the running totals: the events'
|
||||
// row, and the targets' rows summed.
|
||||
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||
var events database.EventTotals
|
||||
|
||||
err := db.Take(&events).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading event totals: %w", err)
|
||||
}
|
||||
|
||||
var targets []database.TargetTotals
|
||||
|
||||
err = db.Find(&targets).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading target totals: %w", err)
|
||||
}
|
||||
|
||||
stats.Lifetime.Events = events.Events
|
||||
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
||||
stats.LastEventAt = events.LastEventAt
|
||||
|
||||
for _, t := range targets {
|
||||
stats.Lifetime.Deliveries += t.Deliveries
|
||||
stats.Lifetime.Failures += t.Failed
|
||||
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
||||
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRecentWindow counts the events received, and the deliveries that
|
||||
// became delivered or failed, since the given time.
|
||||
func readRecentWindow(
|
||||
db *gorm.DB, since time.Time,
|
||||
) (RecentWindow, error) {
|
||||
var w RecentWindow
|
||||
|
||||
err := db.Model(&database.Event{}).
|
||||
Where("created_at >= ?", since).
|
||||
Count(&w.Events).Error
|
||||
if err != nil {
|
||||
return w, fmt.Errorf("counting recent events: %w", err)
|
||||
}
|
||||
|
||||
byTarget, err := finishedByTarget(db, since)
|
||||
if err != nil {
|
||||
return w, err
|
||||
}
|
||||
|
||||
for _, f := range byTarget {
|
||||
w.Delivered += f.Delivered
|
||||
w.Failed += f.Failed
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// finishedByTarget counts, for each target, the deliveries that became
|
||||
// delivered and those that failed since the given time, in one query
|
||||
// over just that window of the deliveries' status index. A target with
|
||||
// neither is left out.
|
||||
func finishedByTarget(
|
||||
db *gorm.DB, since time.Time,
|
||||
) ([]TargetFinished, error) {
|
||||
var byTarget []TargetFinished
|
||||
|
||||
err := db.Model(&database.Delivery{}).
|
||||
Select("target_id, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed).
|
||||
Where("status IN ? AND finished_at >= ?",
|
||||
[]database.DeliveryStatus{
|
||||
database.DeliveryStatusDelivered,
|
||||
database.DeliveryStatusFailed,
|
||||
}, since).
|
||||
Group("target_id").
|
||||
Find(&byTarget).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"counting deliveries finished by target: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return byTarget, nil
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
||||
func statsEntrypoint(
|
||||
t *testing.T, db *database.Database, webhookID string, active bool,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
WebhookID: webhookID,
|
||||
Path: uuid.New().String(),
|
||||
}
|
||||
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
||||
|
||||
return ep.Path
|
||||
}
|
||||
|
||||
// statsDelivery returns an event's delivery to a target.
|
||||
func statsDelivery(
|
||||
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
||||
) database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
var d database.Delivery
|
||||
|
||||
require.NoError(t, webhookDB.Where(
|
||||
"event_id = ? AND target_id = ?", eventID, targetID,
|
||||
).First(&d).Error)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// statsFinish settles a delivery as the delivery engine does: its
|
||||
// final status and the time it finished, and one more on its target's
|
||||
// delivered or failed total, in one transaction.
|
||||
func statsFinish(
|
||||
t *testing.T,
|
||||
webhookDB *gorm.DB,
|
||||
d database.Delivery,
|
||||
status database.DeliveryStatus,
|
||||
at time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||
if status == database.DeliveryStatusFailed {
|
||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||
}
|
||||
|
||||
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Model(&database.Delivery{}).
|
||||
Where("id = ?", d.ID).
|
||||
Updates(map[string]any{"status": status, "finished_at": at}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.AddTargetTotals(tx, add)
|
||||
}))
|
||||
}
|
||||
|
||||
// statsAge moves an event's arrival back to the given time.
|
||||
func statsAge(
|
||||
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, webhookDB.Model(&database.Event{}).
|
||||
Where("id = ?", eventID).
|
||||
Update("created_at", at).Error)
|
||||
}
|
||||
|
||||
// statsTargetTotals reads a webhook database's target totals, keyed by
|
||||
// target.
|
||||
func statsTargetTotals(
|
||||
t *testing.T, webhookDB *gorm.DB,
|
||||
) map[string]database.TargetTotals {
|
||||
t.Helper()
|
||||
|
||||
var rows []database.TargetTotals
|
||||
|
||||
require.NoError(t, webhookDB.Find(&rows).Error)
|
||||
|
||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||
for _, row := range rows {
|
||||
byTarget[row.TargetID] = row
|
||||
}
|
||||
|
||||
return byTarget
|
||||
}
|
||||
|
||||
// statsHistory is the webhook seedStatsHistory builds: its event
|
||||
// database, its newest event, and its two active targets.
|
||||
type statsHistory struct {
|
||||
webhook *database.Webhook
|
||||
webhookDB *gorm.DB
|
||||
newest database.Event
|
||||
first, second string
|
||||
}
|
||||
|
||||
// seedStatsHistory builds the webhook the statistics test checks: 14
|
||||
// days of retention, twelve entrypoints (one inactive) and six targets
|
||||
// (four inactive). Ten events arrive through the receiver, and so each
|
||||
// has a delivery to the two active targets. The oldest event is past
|
||||
// retention, the next 30 hours old, the next six hours old, the other
|
||||
// seven just in. Six deliveries are settled as the delivery engine
|
||||
// would, two of them inside a recent window though their event arrived
|
||||
// before it. The newest event's delivery to the second target is
|
||||
// retrying, the rest are left pending, and a replay adds a pending
|
||||
// delivery to the oldest event. Once retention has removed the oldest
|
||||
// event, every figure in the pane differs from every other.
|
||||
func seedStatsHistory(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
sess *session.Session,
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
) statsHistory {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
path := statsEntrypoint(t, db, wh.ID, true)
|
||||
for range 10 {
|
||||
statsEntrypoint(t, db, wh.ID, true)
|
||||
}
|
||||
|
||||
statsEntrypoint(t, db, wh.ID, false)
|
||||
|
||||
first := seedConfiguredTarget(
|
||||
t, db, wh.ID, database.TargetTypeHTTP,
|
||||
`{"url":"`+replayTargetURL+`"}`,
|
||||
)
|
||||
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
for range 4 {
|
||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
require.NoError(t, db.DB().Model(inactive).
|
||||
Update("active", false).Error)
|
||||
}
|
||||
|
||||
router := receiverRouter(h)
|
||||
|
||||
for range 10 {
|
||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||
}
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
events := listEvents(t, webhookDB)
|
||||
require.Len(t, events, 10)
|
||||
|
||||
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
|
||||
now := time.Now()
|
||||
|
||||
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
||||
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
|
||||
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
||||
|
||||
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
||||
statsFinish(t, webhookDB, oldestFailure,
|
||||
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
||||
|
||||
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
|
||||
require.NoError(t, webhookDB.Model(&retrying).
|
||||
Update("status", database.DeliveryStatusRetrying).Error)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther,
|
||||
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
||||
|
||||
return statsHistory{
|
||||
webhook: wh, webhookDB: webhookDB, newest: newest,
|
||||
first: first.ID, second: second.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// statsPrune runs the real retention reaper until it has removed one
|
||||
// event from the webhook's database, then stops it.
|
||||
func statsPrune(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
log *logger.Logger,
|
||||
webhookDB *gorm.DB,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
||||
Config: &config.Config{
|
||||
RetentionSweepInterval: 10 * time.Millisecond,
|
||||
},
|
||||
Database: db,
|
||||
DBManager: dbMgr,
|
||||
Logger: log,
|
||||
})
|
||||
|
||||
lc.RequireStart()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var totals database.EventTotals
|
||||
|
||||
err := webhookDB.Take(&totals).Error
|
||||
|
||||
return err == nil && totals.EventsRemoved == 1
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
|
||||
lc.RequireStop()
|
||||
}
|
||||
|
||||
// statsPane returns the text of the statistics pane in a rendered
|
||||
// webhook page, everything from its heading to the next heading on the
|
||||
// page, with the markup taken out and each run of space made one
|
||||
// space. A table then reads header by header and row by row, each
|
||||
// row's label followed by its figures in column order.
|
||||
func statsPane(t *testing.T, page string) string {
|
||||
t.Helper()
|
||||
|
||||
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
||||
require.True(t, found, "the page has no statistics pane")
|
||||
|
||||
pane, _, _ = strings.Cut(pane, "<h2")
|
||||
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
||||
|
||||
return strings.Join(strings.Fields(pane), " ")
|
||||
}
|
||||
|
||||
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
||||
// each target's totals and its deliveries finished in the last 24
|
||||
// hours. The first target has ten deliveries and the replay, the
|
||||
// second ten; the inactive targets have none and so no row.
|
||||
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
||||
t.Helper()
|
||||
|
||||
first, second := hist.first, hist.second
|
||||
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
|
||||
second: {TargetID: second, Deliveries: 10, Failed: 2},
|
||||
}, statsTargetTotals(t, hist.webhookDB))
|
||||
|
||||
lastDay, err := handlers.FinishedByTargetForTest(
|
||||
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []handlers.TargetFinished{
|
||||
{TargetID: first, Delivered: 1, Failed: 1},
|
||||
{TargetID: second, Failed: 2},
|
||||
}, lastDay)
|
||||
}
|
||||
|
||||
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
||||
// history seedStatsHistory builds, once retention has removed the
|
||||
// oldest event: each figure after its label, in its column.
|
||||
func assertStatsPaneAfterPrune(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
sess *session.Session,
|
||||
hist statsHistory,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
|
||||
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
||||
"Targets 6 (2 active) "+
|
||||
"Deliveries in progress 13 "+
|
||||
"Last event "+lastEvent+" "+
|
||||
"Retention 14 days")
|
||||
assert.Contains(t, pane, "Lifetime Within retention "+
|
||||
"Events 10 9 "+
|
||||
"Deliveries 21 18 "+
|
||||
"Failures 5 4")
|
||||
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
||||
"Events 7 8 "+
|
||||
"Failures 1 3 "+
|
||||
"Failure percentage 50.0% 75.0%")
|
||||
}
|
||||
|
||||
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
||||
// the statistics pane shows for the history seedStatsHistory builds,
|
||||
// and each target's totals and recent figures, before and after the
|
||||
// real retention reaper removes the oldest event.
|
||||
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||
first, second := hist.first, hist.second
|
||||
|
||||
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
||||
require.NotNil(t, stats)
|
||||
|
||||
assert.Equal(t, 12, stats.Entrypoints)
|
||||
assert.Equal(t, 11, stats.ActiveEntrypoints)
|
||||
assert.Equal(t, 6, stats.Targets)
|
||||
assert.Equal(t, 2, stats.ActiveTargets)
|
||||
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
|
||||
stats.Lifetime)
|
||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
||||
assert.Equal(t, int64(15), stats.InProgress)
|
||||
require.NotNil(t, stats.LastEventAt)
|
||||
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 7, Delivered: 1, Failed: 1,
|
||||
}, stats.Last10Minutes)
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 8, Delivered: 1, Failed: 3,
|
||||
}, stats.Last24Hours)
|
||||
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
||||
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
|
||||
|
||||
assertStatsTargets(t, hist)
|
||||
|
||||
// Retention removes the oldest event with its three deliveries:
|
||||
// the first target's failed one and the pending replay, and the
|
||||
// second target's pending one.
|
||||
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
||||
|
||||
after := h.WebhookStatsForTest(hist.webhook.ID)
|
||||
require.NotNil(t, after)
|
||||
|
||||
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
||||
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
|
||||
after.WithinRetention)
|
||||
assert.Equal(t, int64(13), after.InProgress)
|
||||
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
||||
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
||||
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
||||
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
first: {
|
||||
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
|
||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
||||
},
|
||||
second: {
|
||||
TargetID: second, Deliveries: 10, Failed: 2,
|
||||
DeliveriesRemoved: 1,
|
||||
},
|
||||
}, statsTargetTotals(t, hist.webhookDB))
|
||||
|
||||
assertStatsPaneAfterPrune(t, h, sess, hist)
|
||||
}
|
||||
|
||||
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
||||
// retention has removed every event, the pane still shows when the last
|
||||
// one arrived rather than "none".
|
||||
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *logger.Logger
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
path := statsEntrypoint(t, db, wh.ID, true)
|
||||
require.Equal(t, http.StatusOK,
|
||||
postReceiver(t, receiverRouter(h), path))
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
events := listEvents(t, webhookDB)
|
||||
require.Len(t, events, 1)
|
||||
|
||||
arrived := events[0].CreatedAt
|
||||
|
||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||
require.Empty(t, listEvents(t, webhookDB))
|
||||
|
||||
stats := h.WebhookStatsForTest(wh.ID)
|
||||
require.NotNil(t, stats)
|
||||
require.NotNil(t, stats.LastEventAt)
|
||||
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.Contains(t, pane,
|
||||
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||
}
|
||||
|
||||
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
||||
// last event arrived in UTC, as the event list does, when the time was
|
||||
// stored in another zone, as it is on a host whose local time is not
|
||||
// UTC.
|
||||
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
||||
time.FixedZone("EST", -5*60*60))
|
||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
||||
}
|
||||
|
||||
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
||||
// pane itself, not only the line at the foot of the page, shows the
|
||||
// webhook's retention period, for a finite one and for forever.
|
||||
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
tests := []struct {
|
||||
retentionDays int
|
||||
want string
|
||||
}{
|
||||
{30, "30 days"},
|
||||
{database.RetentionForeverDays, "forever"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID,
|
||||
Name: "retention",
|
||||
RetentionDays: tt.retentionDays,
|
||||
}
|
||||
require.NoError(t,
|
||||
db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.Contains(t, pane, "Retention "+tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
||||
// database has never been opened: every count is zero, the
|
||||
// percentages are a dash, and showing the page does not create the
|
||||
// database.
|
||||
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
|
||||
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.Contains(t, pane, "Last event none")
|
||||
assert.Contains(t, pane, "Failure percentage — —")
|
||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||
}
|
||||
|
||||
// TestRecentWindow_FailurePercent pins the percentage: failed
|
||||
// deliveries out of all that finished in the window.
|
||||
func TestRecentWindow_FailurePercent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
window handlers.RecentWindow
|
||||
want string
|
||||
}{
|
||||
{handlers.RecentWindow{}, "—"},
|
||||
{handlers.RecentWindow{Events: 4}, "—"},
|
||||
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
||||
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
||||
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range []string{
|
||||
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
|
||||
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
|
||||
} {
|
||||
assert.Equal(t, s, logfield.Truncate(s, budget))
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
||||
)
|
||||
|
||||
router.HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
"/h/{uuid}",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
// Stands in for the real handler: an unknown entrypoint
|
||||
// UUID 404s, a known one succeeds.
|
||||
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
||||
assertFloodIsBounded(
|
||||
t,
|
||||
func(i int) string {
|
||||
return "/webhook/" + attackerMarker +
|
||||
return "/h/" + attackerMarker +
|
||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||
},
|
||||
http.StatusNotFound,
|
||||
"/webhook/{uuid}",
|
||||
"/h/{uuid}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,10 +346,10 @@ type sizeCase struct {
|
||||
func lineSizeCases() map[string]sizeCase {
|
||||
cases := map[string]sizeCase{
|
||||
"oversized path segment": {
|
||||
target: "/webhook/" + attackerMarker +
|
||||
target: "/h/" + attackerMarker +
|
||||
strings.Repeat("x", oversizedSegmentBytes),
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantURL: "/webhook/{uuid}",
|
||||
wantURL: "/h/{uuid}",
|
||||
bound: maxLineBytes,
|
||||
},
|
||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
||||
router := accessLogRouter(m)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
||||
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
||||
)
|
||||
|
||||
// The path resolved against a stored entrypoint, so it stays. The
|
||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
||||
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
||||
assert.NotContains(t, buf.String(), "src=ci")
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
http.StatusNotFound,
|
||||
get(t, router, "/webhook/"+attackerMarker),
|
||||
get(t, router, "/h/"+attackerMarker),
|
||||
)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
|
||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||
// token receive a 403 Forbidden response.
|
||||
// token are logged and answered by forbidden, which must write the 403.
|
||||
//
|
||||
// The middleware detects the client-facing transport protocol
|
||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||
@@ -36,12 +36,14 @@ func CSRFToken(r *http.Request) string {
|
||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||
// csrf.Secure option is set at creation time, not per-request.
|
||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
func (m *Middleware) CSRF(
|
||||
forbidden http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF is registered ahead of RequireAuth on every route
|
||||
// group that uses it, so this WARN is reachable by an
|
||||
// unauthenticated client: a POST with no token to
|
||||
// /source/<any length of any text>/edit lands here. The
|
||||
// /hook/<any length of any text>/edit lands here. The
|
||||
// method and path are capped against the same budgets as
|
||||
// the access log. remote_addr is set by net/http from the
|
||||
// accepted connection rather than by the client, and
|
||||
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"reason", csrf.FailureReason(r),
|
||||
)
|
||||
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||
forbidden.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
key := m.session.GetKey()
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
// csrfCookieName is the gorilla/csrf cookie name.
|
||||
const csrfCookieName = "_gorilla_csrf"
|
||||
|
||||
// forbidden stands in for the error page the server hands CSRF to
|
||||
// answer a refused request with.
|
||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
|
||||
// csrfGetToken performs a GET request through the CSRF middleware
|
||||
// and returns the token and cookies.
|
||||
func csrfGetToken(
|
||||
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
||||
|
||||
var gotToken string
|
||||
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotToken = middleware.CSRFToken(r)
|
||||
},
|
||||
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
newReq := func(method string) *http.Request {
|
||||
r := httptest.NewRequestWithContext(
|
||||
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
|
||||
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
return m.CSRF()(unreachable(t))
|
||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
||||
unreachable(t),
|
||||
)
|
||||
},
|
||||
send: postNoToken,
|
||||
wantStatus: http.StatusForbidden,
|
||||
@@ -383,7 +385,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
|
||||
t, newHandler,
|
||||
)
|
||||
|
||||
path := "/source/" +
|
||||
path := "/hook/" +
|
||||
oversizedPathSegment(fill) + "/edit"
|
||||
|
||||
assert.Equal(
|
||||
@@ -434,7 +436,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/source/"+
|
||||
"/hook/"+
|
||||
oversizedPathSegment(fill)+"/login",
|
||||
nil,
|
||||
)
|
||||
@@ -499,7 +501,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
postOversize(
|
||||
h,
|
||||
"/source/"+segment(i)+"/edit",
|
||||
"/hook/"+segment(i)+"/edit",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
|
||||
//
|
||||
// The pattern is what bounds the label's domain to the routes the
|
||||
// service registers. The path does not bound it at all — every byte
|
||||
// after /webhook/ is client-chosen, so labelling by path lets any
|
||||
// after /h/ is client-chosen, so labelling by path lets any
|
||||
// unauthenticated client mint permanent series at will, and publishes
|
||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||
// while doing it.
|
||||
|
||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
||||
// dimension varying, so any series growth a probe produces is the
|
||||
// method label's and nothing else's.
|
||||
func methodProbePath() string {
|
||||
return "/webhook/" + uuid.NewString()
|
||||
return "/h/" + uuid.NewString()
|
||||
}
|
||||
|
||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
|
||||
// receiverRoutePattern is the one handler label every receiver
|
||||
// request must produce, however the client varies the path.
|
||||
receiverRoutePattern = "/webhook/{uuid}"
|
||||
receiverRoutePattern = "/h/{uuid}"
|
||||
|
||||
// okRoute is a static route used to pin that the response-writer
|
||||
// interceptor still reports status and size after the handler id
|
||||
@@ -143,13 +143,13 @@ func drivePaths(
|
||||
return drive(t, h, probes)
|
||||
}
|
||||
|
||||
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
||||
// receiverPaths returns n distinct /h/ paths, each naming a
|
||||
// fresh UUID exactly as an unauthenticated flood would.
|
||||
func receiverPaths(n int) []string {
|
||||
paths := make([]string, 0, n)
|
||||
|
||||
for range n {
|
||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
||||
paths = append(paths, "/h/"+uuid.NewString())
|
||||
}
|
||||
|
||||
return paths
|
||||
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
||||
|
||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||
// assertion the issue asks for: N requests to N distinct
|
||||
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
||||
// /h/<uuid> paths must produce exactly ONE handler label, the
|
||||
// route pattern. Before the fix this produced N of them.
|
||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
// The scrape must not republish the UUIDs it was driven with.
|
||||
// They are the receiver's only credential.
|
||||
for _, p := range paths {
|
||||
id := strings.TrimPrefix(p, "/webhook/")
|
||||
id := strings.TrimPrefix(p, "/h/")
|
||||
for label := range labels {
|
||||
assert.NotContains(
|
||||
t, label, id,
|
||||
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
paths = append(paths, "/"+id)
|
||||
} else {
|
||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
||||
paths = append(paths, "/h/"+id+"/"+id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
|
||||
//
|
||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||
// on any invented /webhook/ path, 303 to the login page on any
|
||||
// on any invented /h/ path, 303 to the login page on any
|
||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||
// write attacker-chosen text, of attacker-chosen length, into the
|
||||
// operator's log at one line per request. The pattern comes from the
|
||||
@@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize(
|
||||
// internal/server/routes.go), so an
|
||||
// unauthenticated client reaches it with a path
|
||||
// of its own choosing and its own length —
|
||||
// POST /source/<8 KB>/edit with an oversize
|
||||
// POST /hook/<8 KB>/edit with an oversize
|
||||
// declared Content-Length costs nothing to
|
||||
// send. At WARN, on by default, that is a
|
||||
// write into the operator's log sized by the
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/sources", nil,
|
||||
http.MethodGet, "/hooks", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const (
|
||||
|
||||
// receiverAggregateMultiplier scales the configured
|
||||
// per-entrypoint receiver limit into the aggregate limit one
|
||||
// client IP may spend across the whole /webhook/* route. Ten
|
||||
// client IP may spend across the whole /h/* route. Ten
|
||||
// entrypoints' worth lets a single sender address drive several
|
||||
// entrypoints at their full rate, while still capping what one
|
||||
// address costs the unauthenticated receiver.
|
||||
@@ -389,7 +389,7 @@ func (m *Middleware) postRateLimit(
|
||||
// It is Config.ReceiverRateLimit requests per minute.
|
||||
//
|
||||
// That limit alone bounds nothing in aggregate. The route pattern
|
||||
// /webhook/{uuid} matches any single segment, so a client that
|
||||
// /h/{uuid} matches any single segment, so a client that
|
||||
// invents a fresh path per request mints a fresh bucket per request
|
||||
// and never refills one — and every such request still reaches the
|
||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||
|
||||
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
// pass.
|
||||
for i := range limit {
|
||||
w := receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
// The next request over the limit is rejected with a 429
|
||||
// carrying a Retry-After header.
|
||||
w := receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.NotEmpty(
|
||||
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
|
||||
// The same IP is not limited on a different entrypoint.
|
||||
w = receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-b",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
|
||||
// A different IP is not limited on the same entrypoint.
|
||||
w = receiverPost(
|
||||
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
||||
handler, "8.8.8.8:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
||||
const (
|
||||
limit = 2
|
||||
ip = "7.7.7.7:1234"
|
||||
path = "/webhook/uuid-c"
|
||||
path = "/h/uuid-c"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
// none of them shares a per-entrypoint bucket with another.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", i),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
}
|
||||
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"a client must not be able to raise its aggregate rate "+
|
||||
"against /webhook/* by varying the path",
|
||||
"against /h/* by varying the path",
|
||||
)
|
||||
|
||||
// The aggregate limit is still per client IP: exhausting one
|
||||
// address must not throttle another.
|
||||
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
||||
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a different client IP must not be affected",
|
||||
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
||||
// limit requests are served; the rest are rejected by the
|
||||
// per-entrypoint limiter but still count against the aggregate.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
||||
w := receiverPost(handler, ip, "/h/exhausted")
|
||||
|
||||
want := http.StatusTooManyRequests
|
||||
if i < limit {
|
||||
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
||||
)
|
||||
}
|
||||
|
||||
w := receiverPost(handler, ip, "/webhook/never-used")
|
||||
w := receiverPost(handler, ip, "/h/never-used")
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"requests rejected per entrypoint must still count "+
|
||||
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
||||
const (
|
||||
limit = 3
|
||||
peer = "203.0.113.10:44444"
|
||||
path = "/webhook/uuid-d"
|
||||
path = "/h/uuid-d"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
|
||||
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
|
||||
// Recoverer returns middleware that turns a handler panic into one
|
||||
// structured ERROR record and a 500, rather than a dropped
|
||||
// connection.
|
||||
// connection. The 500 is page when page is not nil, and plain text
|
||||
// when it is nil or when page panics before writing anything.
|
||||
//
|
||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||
@@ -136,9 +137,13 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
//
|
||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||
// set before panicking, because a request that failed must not hand
|
||||
// the client a credential; every other header is left to http.Error.
|
||||
// the client a credential. It touches no other header: when page
|
||||
// answers, every other header the handler set goes out with it, apart
|
||||
// from any page sets itself; otherwise they are left to http.Error.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
func (s *Middleware) Recoverer(
|
||||
page http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
@@ -171,6 +176,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
|
||||
rw.Header().Del("Set-Cookie")
|
||||
|
||||
if page != nil {
|
||||
s.servePage(rw, r, page)
|
||||
}
|
||||
|
||||
if rw.committed {
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(
|
||||
rw,
|
||||
http.StatusText(
|
||||
@@ -185,6 +198,27 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// servePage answers with page. A panic in page itself is logged and
|
||||
// recovered here, so the Recoverer can still send its plain 500.
|
||||
func (s *Middleware) servePage(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
page http.Handler,
|
||||
) {
|
||||
defer func() {
|
||||
rvr := recover()
|
||||
if rvr != nil {
|
||||
s.log.Error("error page panic",
|
||||
"panic", logfield.Truncate(
|
||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
page.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// logPanic writes the record. Every field it can grow is truncated to
|
||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||
//
|
||||
|
||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
||||
// Logging outside so the recovered 500 is the status it records.
|
||||
router.Use(chimw.RequestID)
|
||||
router.Use(m.Logging())
|
||||
router.Use(m.Recoverer())
|
||||
router.Use(m.Recoverer(nil))
|
||||
router.Get("/probe", handler)
|
||||
|
||||
serverErrors := new(bytes.Buffer)
|
||||
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
|
||||
m, _ := capturingMiddleware(t)
|
||||
|
||||
handler := m.Recoverer()(http.HandlerFunc(
|
||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("chunk"))
|
||||
|
||||
@@ -672,3 +672,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "chunk", string(body))
|
||||
}
|
||||
|
||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
||||
// the panic is logged as before, and the 500 is that page.
|
||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("the error page"))
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "the error page", w.Body.String())
|
||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
}
|
||||
|
||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
||||
// before writing anything: both panics are logged, and the client
|
||||
// still gets the plain 500.
|
||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
panic(pagePanic)
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
assert.Contains(t, logs.String(), pagePanic)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// finds no account seeds one with an Argon2id hash, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
)
|
||||
|
||||
// The link back the error page offers: to the webhook list for a
|
||||
// signed-in user, to sign-in for anyone else.
|
||||
const (
|
||||
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
|
||||
`Back to webhooks</a>`
|
||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
||||
`Sign in</a>`
|
||||
)
|
||||
|
||||
// assertErrorPage checks that w is the error page for status, in the
|
||||
// normal layout, offering link.
|
||||
func assertErrorPage(
|
||||
t *testing.T,
|
||||
w *httptest.ResponseRecorder,
|
||||
status int,
|
||||
link string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Equal(t, status, w.Code)
|
||||
assert.Equal(
|
||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
||||
)
|
||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
||||
assert.Contains(
|
||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
||||
)
|
||||
assert.Contains(t, body, link)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
||||
|
||||
w := env.get("/hook/"+wh.ID, cookies)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
tgt := env.seedTarget(t, wh.ID)
|
||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
||||
|
||||
w := env.get(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
||||
)
|
||||
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
}
|
||||
|
||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.get("/no-such-page", nil),
|
||||
http.StatusNotFound, backToSignIn,
|
||||
)
|
||||
|
||||
// Outside every route group there is no form token, so the
|
||||
// page leaves out the logout form rather than offer one that
|
||||
// would be refused.
|
||||
w := env.get("/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
|
||||
// Inside a route group the page has a token, and logout works.
|
||||
wh := env.seedWebhook(t, userID)
|
||||
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
|
||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
||||
}
|
||||
|
||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("username", "someone")
|
||||
form.Set("password", "irrelevant")
|
||||
form.Set("csrf_token", "not-a-token")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/pages/login", form, nil),
|
||||
http.StatusForbidden, backToSignIn,
|
||||
)
|
||||
|
||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "owner")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
|
||||
edit := url.Values{}
|
||||
edit.Set("name", "renamed")
|
||||
|
||||
assertErrorPage(
|
||||
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
|
||||
http.StatusForbidden, backToWebhooks,
|
||||
)
|
||||
}
|
||||
|
||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
||||
// admin page route group through the real router, with error
|
||||
// tracking on: the client gets the 500 error page, and the tracker
|
||||
// still gets the panic, once. The same panic outside the admin page
|
||||
// route groups keeps the plain 500.
|
||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
transport := &captureTransport{}
|
||||
|
||||
opts := server.SentryClientOptionsForTest(
|
||||
"https://public@sentry.invalid/1", "webhooker-test",
|
||||
)
|
||||
opts.Transport = transport
|
||||
|
||||
client, err := sentry.NewClient(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
sentry.SetHubOnContext(
|
||||
context.Background(),
|
||||
sentry.NewHub(client, sentry.NewScope()),
|
||||
),
|
||||
http.MethodGet, path, nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
w := serve(
|
||||
server.NewRouterWithPageProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.PageProbePattern,
|
||||
)
|
||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
||||
|
||||
w = serve(
|
||||
server.NewRouterWithProbeForTest(
|
||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||
true, panicProbeHandler,
|
||||
),
|
||||
server.ProbePattern,
|
||||
)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
|
||||
require.Len(t, transport.events, 2)
|
||||
|
||||
for _, event := range transport.events {
|
||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
||||
// the web UI only: a sender posting to an entrypoint that does not
|
||||
// exist still gets the plain-text answer.
|
||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// newTestEnv leaves the receiver rate limit at zero, which
|
||||
// refuses every request before it reaches the receiver.
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: 10,
|
||||
})
|
||||
|
||||
w := env.post(
|
||||
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
@@ -101,3 +102,39 @@ func NewRouterWithProbeForTest(
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
||||
// probe: inside the /pages route group, the admin page group a
|
||||
// request reaches without signing in.
|
||||
const PageProbePattern = "/pages/probe"
|
||||
|
||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
||||
// probe added to the /pages route group once SetupRoutes has built
|
||||
// it, so the probe runs behind that group's own middleware exactly as
|
||||
// the group's real routes do.
|
||||
func NewRouterWithPageProbeForTest(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
mw *middleware.Middleware,
|
||||
h *handlers.Handlers,
|
||||
sentryEnabled bool,
|
||||
probe http.HandlerFunc,
|
||||
) http.Handler {
|
||||
s := &Server{
|
||||
log: log,
|
||||
mw: mw,
|
||||
h: h,
|
||||
params: ServerParams{Config: cfg},
|
||||
}
|
||||
s.sentryEnabled.Store(sentryEnabled)
|
||||
s.SetupRoutes()
|
||||
|
||||
for _, route := range s.router.Routes() {
|
||||
pages, ok := route.SubRoutes.(chi.Router)
|
||||
if ok && route.Pattern == "/pages/*" {
|
||||
pages.Get("/probe", probe)
|
||||
}
|
||||
}
|
||||
|
||||
return s.router
|
||||
}
|
||||
|
||||
+49
-34
@@ -15,9 +15,10 @@ import (
|
||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||
// submission while preventing abuse from oversized payloads.
|
||||
//
|
||||
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||
// orderings are deliberate.
|
||||
// The four admin page route groups below (/pages, /user/{username},
|
||||
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
||||
// right after their recoverer and error reporting, ahead of both CSRF
|
||||
// and RequireAuth. Both orderings are deliberate.
|
||||
//
|
||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||
// be installed before anything reads the body, or the parse runs
|
||||
@@ -46,6 +47,14 @@ const requestTimeout = 60 * time.Second
|
||||
// server's router.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// An unknown path gets the error page. Registered before the
|
||||
// global middleware, because chi wraps a not-found handler in the
|
||||
// middleware already on its router, which would then run twice.
|
||||
// The route groups below wrap it in their own middleware the same
|
||||
// way; running theirs twice is harmless.
|
||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||
|
||||
s.setupGlobalMiddleware()
|
||||
s.setupRoutes()
|
||||
}
|
||||
@@ -69,23 +78,33 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
// Panic recovery, deliberately here rather than first. It has to
|
||||
// run inside every middleware that observes the response, so the
|
||||
// 500 it writes is the status the access log records and the
|
||||
// metrics count, and outside the sentryhttp handler below, whose
|
||||
// metrics count, and outside the sentryhttp handler, whose
|
||||
// Repanic option needs something further out to catch what it
|
||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||
// until it was measured: on a current Go release it crashes
|
||||
// inside its stack pretty-printer instead of recovering, so the
|
||||
// connection dropped and the original panic was never reported.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||
s.router.Use(s.mw.Recoverer())
|
||||
s.recoverPanics(s.router, nil)
|
||||
}
|
||||
|
||||
// recoverPanics installs on r the recoverer, answering a panic with
|
||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
||||
// bubble up to the recoverer.
|
||||
//
|
||||
// Each admin page route group installs its own, with the error page,
|
||||
// as its first middleware. A panic there is logged, reported and
|
||||
// answered inside the group and never reaches the global recoverer,
|
||||
// which keeps the plain 500 for every other route.
|
||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
||||
r.Use(s.mw.Recoverer(page))
|
||||
|
||||
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the Recoverer middleware
|
||||
// registered immediately above.
|
||||
if s.sentryEnabled.Load() {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
r.Use(sentryHandler.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,17 +160,19 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSettingsRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupPageRoutes() {
|
||||
s.router.Route("/pages", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
@@ -170,10 +191,13 @@ func (s *Server) setupPageRoutes() {
|
||||
|
||||
func (s *Server) setupUserRoutes() {
|
||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
@@ -183,27 +207,15 @@ func (s *Server) setupUserRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||
// configuration comes from the environment and nothing here changes
|
||||
// it.
|
||||
func (s *Server) setupSettingsRoutes() {
|
||||
s.router.Route("/settings", func(r chi.Router) {
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/sources", func(r chi.Router) {
|
||||
s.router.Route("/hooks", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceList())
|
||||
@@ -211,25 +223,28 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||
})
|
||||
|
||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceDetail())
|
||||
r.Get("/edit", s.h.HandleSourceEdit())
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
r.Post("/delete", s.h.HandleSourceDelete())
|
||||
r.Get("/logs", s.h.HandleSourceLogs())
|
||||
r.Get("/events", s.h.HandleSourceLogs())
|
||||
// The log page renders each body only up to its cap, so
|
||||
// this is the only route that serves a whole one. It
|
||||
// belongs to this group for its RequireAuth and
|
||||
// NoCache; see HandleEventBodyDownload for the headers
|
||||
// that keep the bytes it returns inert.
|
||||
r.Get(
|
||||
"/logs/{eventID}/body",
|
||||
"/events/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
)
|
||||
// Replay is the one page action that queues outbound work:
|
||||
@@ -296,7 +311,7 @@ func (s *Server) setupSourceRoutes() {
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
"/h/{uuid}",
|
||||
s.h.HandleWebhook(),
|
||||
)
|
||||
}
|
||||
|
||||
+420
-10
@@ -47,8 +47,8 @@ type noopNotifier struct{}
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||
// dependency. These tests never delete a webhook, so there is
|
||||
// nothing to record.
|
||||
// dependency. No test here checks what gets evicted, so it records
|
||||
// nothing.
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (e *noopEvictor) EvictWebhook(string) {}
|
||||
@@ -240,6 +240,26 @@ func (e *testEnv) csrfFrom(
|
||||
return token, combined
|
||||
}
|
||||
|
||||
// urlFrom renders the page at path and returns the link or form
|
||||
// action that pattern's one group captures, so a test requests the
|
||||
// URL the template emitted rather than one it wrote itself.
|
||||
func (e *testEnv) urlFrom(
|
||||
t *testing.T,
|
||||
path, pattern string,
|
||||
cookies []*http.Cookie,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
w := e.get(path, cookies)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
match := regexp.MustCompile(pattern).
|
||||
FindStringSubmatch(w.Body.String())
|
||||
require.Len(t, match, 2, "%s should render %s", path, pattern)
|
||||
|
||||
return html.UnescapeString(match[1])
|
||||
}
|
||||
|
||||
// authCookies forges an authenticated session for the given user.
|
||||
func (e *testEnv) authCookies(
|
||||
t *testing.T,
|
||||
@@ -674,7 +694,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
||||
|
||||
require.NotNil(t, fresh, "login must set a session cookie")
|
||||
assert.Equal(
|
||||
t, "/sources",
|
||||
t, "/hooks",
|
||||
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
||||
"the new session cookie must authenticate",
|
||||
)
|
||||
@@ -741,7 +761,344 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- /source/{sourceID} group ---
|
||||
// --- /hooks group ---
|
||||
|
||||
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
|
||||
// production router, follows both of its links to the new-webhook
|
||||
// form, then submits the form to the action and with the token the
|
||||
// page rendered. A mistyped route, link or form action fails here;
|
||||
// the handler tests cannot catch any of them, because they never
|
||||
// route a request.
|
||||
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "lister", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "lister")
|
||||
|
||||
// The list shows its "Create Webhook" link only while it is empty.
|
||||
createLink := env.urlFrom(
|
||||
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
|
||||
)
|
||||
|
||||
existing := env.seedWebhook(t, userID)
|
||||
|
||||
list := env.get("/hooks", cookies)
|
||||
require.Equal(t, http.StatusOK, list.Code)
|
||||
assert.Contains(
|
||||
t, list.Body.String(), `href="/hook/`+existing.ID+`"`,
|
||||
"the list should link the user's webhook",
|
||||
)
|
||||
|
||||
// The "New Webhook" link has an icon between its href and its text.
|
||||
newLink := env.urlFrom(
|
||||
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
|
||||
cookies,
|
||||
)
|
||||
|
||||
token, cookies := env.csrfFrom(t, newLink, cookies)
|
||||
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
|
||||
assert.Equal(
|
||||
t, action,
|
||||
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
|
||||
"both links should open the new-webhook form",
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
form.Set("name", "created")
|
||||
|
||||
w := env.post(action, form, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
var created database.Webhook
|
||||
|
||||
require.NoError(t,
|
||||
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
||||
)
|
||||
assert.Equal(
|
||||
t, "/hook/"+created.ID, w.Header().Get("Location"),
|
||||
"creating a webhook should redirect to its page",
|
||||
)
|
||||
}
|
||||
|
||||
// --- /hook/{sourceID} group ---
|
||||
|
||||
// TestHook_EditFormAndDelete follows the webhook page's Edit link to
|
||||
// the edit form and submits it, then deletes the webhook with the
|
||||
// form on its page, every URL and token taken from the rendered
|
||||
// pages.
|
||||
func TestHook_EditFormAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "editor", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "editor")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
page := "/hook/" + wh.ID
|
||||
|
||||
editPage := env.urlFrom(t, page, `href="(/hook/[^/"]+/edit)"`, cookies)
|
||||
token, cookies := env.csrfFrom(t, editPage, cookies)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
form.Set("name", "renamed")
|
||||
|
||||
w := env.post(
|
||||
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, page, w.Header().Get("Location"))
|
||||
|
||||
var edited database.Webhook
|
||||
|
||||
require.NoError(t, env.db.DB().First(&edited, "id = ?", wh.ID).Error)
|
||||
assert.Equal(t, "renamed", edited.Name)
|
||||
|
||||
form = url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
w = env.post(
|
||||
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||
assert.Equal(
|
||||
t, http.StatusNotFound, env.get(page, cookies).Code,
|
||||
"a deleted webhook's page should be gone",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHook_EntrypointActions adds, deactivates and deletes an
|
||||
// entrypoint with the forms on the webhook page, each submitted to
|
||||
// the action and with the token the page rendered.
|
||||
func TestHook_EntrypointActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "epuser", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "epuser")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
page := "/hook/" + wh.ID
|
||||
|
||||
token, cookies := env.csrfFrom(t, page, cookies)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
// submit posts the webhook page's form whose action pattern
|
||||
// captures, and requires the redirect back to that page.
|
||||
submit := func(pattern string) {
|
||||
t.Helper()
|
||||
|
||||
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints)"`)
|
||||
|
||||
var added database.Entrypoint
|
||||
|
||||
require.NoError(t,
|
||||
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
|
||||
)
|
||||
require.True(t, added.Active)
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
|
||||
|
||||
var toggled database.Entrypoint
|
||||
|
||||
require.NoError(t,
|
||||
env.db.DB().First(&toggled, "id = ?", added.ID).Error,
|
||||
)
|
||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||
|
||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
|
||||
|
||||
var left int64
|
||||
|
||||
require.NoError(t, env.db.DB().Model(&database.Entrypoint{}).
|
||||
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
||||
assert.Zero(t, left, "the delete should remove the entrypoint")
|
||||
}
|
||||
|
||||
// TestHook_TargetActions adds a target with the form on the webhook
|
||||
// page, follows its Edit link to the target edit form and submits
|
||||
// it, then deactivates and deletes it, every URL and token taken from
|
||||
// the rendered pages.
|
||||
func TestHook_TargetActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "tgtuser", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "tgtuser")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
page := "/hook/" + wh.ID
|
||||
|
||||
token, cookies := env.csrfFrom(t, page, cookies)
|
||||
|
||||
// submit posts form, with the token, to the action pattern
|
||||
// captures on the page at from, and requires the redirect back to
|
||||
// the webhook page.
|
||||
submit := func(from, pattern string, form url.Values) {
|
||||
t.Helper()
|
||||
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
require.Equal(t, page, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
||||
"name": {"added"},
|
||||
"type": {string(database.TargetTypeLog)},
|
||||
})
|
||||
|
||||
editPage := env.urlFrom(
|
||||
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
||||
)
|
||||
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
||||
url.Values{"name": {"renamed"}})
|
||||
|
||||
var edited database.Target
|
||||
|
||||
require.NoError(t,
|
||||
env.db.DB().First(&edited, "webhook_id = ?", wh.ID).Error,
|
||||
)
|
||||
assert.Equal(t, "renamed", edited.Name)
|
||||
require.True(t, edited.Active)
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
|
||||
url.Values{})
|
||||
|
||||
var toggled database.Target
|
||||
|
||||
require.NoError(t,
|
||||
env.db.DB().First(&toggled, "id = ?", edited.ID).Error,
|
||||
)
|
||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||
|
||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
||||
url.Values{})
|
||||
|
||||
var left int64
|
||||
|
||||
require.NoError(t, env.db.DB().Model(&database.Target{}).
|
||||
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
||||
assert.Zero(t, left, "the delete should remove the target")
|
||||
}
|
||||
|
||||
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
|
||||
// Event Log" link, then resubmits a stored event with the form on
|
||||
// that page, submitted to the action and with the token the page
|
||||
// rendered.
|
||||
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "resubmitter")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
|
||||
|
||||
logsPath := env.urlFrom(
|
||||
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
|
||||
)
|
||||
|
||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
|
||||
w := env.post(
|
||||
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var events int64
|
||||
|
||||
require.NoError(t,
|
||||
webhookDB.Model(&database.Event{}).Count(&events).Error,
|
||||
)
|
||||
assert.Equal(t, int64(2), events, "the resubmit stores a new event")
|
||||
}
|
||||
|
||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
||||
// link beside the recent events, and the event log's page links. Each
|
||||
// must point where it should, and that page must render.
|
||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "navigator", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "navigator")
|
||||
wh := env.seedWebhook(t, userID)
|
||||
tgt := env.seedTarget(t, wh.ID)
|
||||
|
||||
// The event log shows 25 events a page; one more gives it a second
|
||||
// page, so it renders its Next and Previous links.
|
||||
for range 26 {
|
||||
env.seedEvent(t, wh.ID, "paged")
|
||||
}
|
||||
|
||||
list := "/hooks"
|
||||
newForm := list + "/new"
|
||||
page := "/hook/" + wh.ID
|
||||
targetEdit := page + "/targets/" + tgt.ID + "/edit"
|
||||
events := page + "/events"
|
||||
back := `href="([^"]+)"[^>]*>← Back to `
|
||||
cancel := `href="([^"]+)"[^>]*>Cancel<`
|
||||
|
||||
for _, link := range []struct{ from, pattern, want string }{
|
||||
// The navbar on the profile page: its desktop link, then its
|
||||
// mobile menu link.
|
||||
{
|
||||
"/user/navigator/",
|
||||
`href="([^"]+)" class="btn-text">Webhooks<`,
|
||||
list,
|
||||
},
|
||||
{
|
||||
"/user/navigator/",
|
||||
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
|
||||
list,
|
||||
},
|
||||
{list, `href="(/hook/[^"]+)"`, page},
|
||||
{newForm, back, list},
|
||||
{newForm, cancel, list},
|
||||
{page, back, list},
|
||||
{page, `Recent Events</h2>\s*<a href="([^"]+)"`, events},
|
||||
{page + "/edit", back, page},
|
||||
{page + "/edit", cancel, page},
|
||||
{targetEdit, back, page},
|
||||
{targetEdit, cancel, page},
|
||||
{events, back, page},
|
||||
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
||||
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
||||
} {
|
||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||
assert.Equal(t, http.StatusOK, env.get(got, cookies).Code, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||
// feature the way a user does: render the event log page through
|
||||
@@ -769,11 +1126,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
||||
wh := env.seedWebhook(t, userID)
|
||||
env.seedEvent(t, wh.ID, stored)
|
||||
|
||||
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
||||
page := env.get("/hook/"+wh.ID+"/events", cookies)
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
link := regexp.MustCompile(
|
||||
`href="(/source/[^"]+/body)"`,
|
||||
`href="(/hook/[^"]+/body)"`,
|
||||
).FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, link, 2,
|
||||
@@ -819,7 +1176,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
||||
const payload = "OWNERS-PAYLOAD-77c1"
|
||||
|
||||
evt := env.seedEvent(t, wh.ID, payload)
|
||||
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
||||
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
|
||||
|
||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||
intruder := env.authCookies(t, intruderID, "intruder")
|
||||
@@ -853,7 +1210,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
||||
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
||||
|
||||
path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
|
||||
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
|
||||
"/replay"
|
||||
|
||||
assert.Equal(
|
||||
@@ -879,7 +1236,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
// The token and the action URL both come out of the rendered
|
||||
// page, so a typo in either the route pattern or the template
|
||||
// fails here.
|
||||
logsPath := "/source/" + wh.ID + "/logs"
|
||||
logsPath := "/hook/" + wh.ID + "/events"
|
||||
|
||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||
|
||||
@@ -887,7 +1244,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
action := regexp.MustCompile(
|
||||
`action="(/source/[^"]+/replay)"`,
|
||||
`action="(/hook/[^"]+/replay)"`,
|
||||
).FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, action, 2,
|
||||
@@ -912,6 +1269,59 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- /h/{uuid} receiver ---
|
||||
|
||||
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
|
||||
// the webhook page shows and posts to it through the production
|
||||
// router until the receiver rate limit refuses it. The URL has to
|
||||
// reach the receiver, and the limit has to apply to it.
|
||||
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 2
|
||||
|
||||
env := newTestEnvWithConfig(t, &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: limit,
|
||||
})
|
||||
|
||||
userID, _ := env.seedUser(t, "receiver", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "receiver")
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||
&database.Entrypoint{
|
||||
WebhookID: wh.ID,
|
||||
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
|
||||
Active: true,
|
||||
},
|
||||
).Error)
|
||||
|
||||
page := env.get("/hook/"+wh.ID, cookies)
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
|
||||
FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, shown, 2, "the webhook page should show the entrypoint URL",
|
||||
)
|
||||
|
||||
for i := range limit {
|
||||
assert.Equal(
|
||||
t, http.StatusOK,
|
||||
env.post(shown[1], url.Values{}, nil).Code,
|
||||
"request %d should reach the receiver", i,
|
||||
)
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests,
|
||||
env.post(shown[1], url.Values{}, nil).Code,
|
||||
"the receiver rate limit must apply to the entrypoint URL",
|
||||
)
|
||||
}
|
||||
|
||||
// metricsConfig is a Config differing from the routing default only
|
||||
// in the two /metrics credentials.
|
||||
func metricsConfig(
|
||||
|
||||
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
||||
//
|
||||
// URL is the third such field. NewRequest builds it as
|
||||
// scheme://host/path (interfaces.go:183), and on the receiver route
|
||||
// that path is /webhook/<uuid> in full — a write capability, not an
|
||||
// that path is /h/<uuid> in full — a write capability, not an
|
||||
// identifier. It is rebuilt here from the chi route pattern, on every
|
||||
// route, keeping the scheme and the host.
|
||||
//
|
||||
|
||||
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
|
||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||
)
|
||||
router.HandleFunc("/pages/login", handler)
|
||||
router.HandleFunc("/webhook/{uuid}", handler)
|
||||
router.HandleFunc("/h/{uuid}", handler)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
||||
// concrete path carries the entrypoint capability.
|
||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||
return sentryRequest(
|
||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
||||
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||
)
|
||||
assert.Equal(
|
||||
t, "http://example.com/webhook/{uuid}", event.Request.URL,
|
||||
t, "http://example.com/h/{uuid}", event.Request.URL,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
|
||||
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
concrete := "https://example.com/webhook/" + sentryReceiverUUID
|
||||
concrete := "https://example.com/h/" + sentryReceiverUUID
|
||||
|
||||
// A request with no chi routing context on it at all, which is
|
||||
// what an event captured outside the router would carry.
|
||||
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{URL: concrete}
|
||||
event.Transaction = "POST /webhook/" +
|
||||
event.Transaction = "POST /h/" +
|
||||
sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(
|
||||
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{
|
||||
URL: "/webhook/" + sentryReceiverUUID,
|
||||
URL: "/h/" + sentryReceiverUUID,
|
||||
}
|
||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
||||
event.Transaction = "/h/" + sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||
require.NotNil(t, scrubbed)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
w := env.get("/settings", nil)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
|
||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
||||
}
|
||||
+11
-1
@@ -22,6 +22,16 @@
|
||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||
# 67s, that is the datum to revisit the org figure with.
|
||||
#
|
||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||
# -race every test binary and every link costs a few hundred MB, so the
|
||||
# defaults (one per core) add up to several GB on a many-core host.
|
||||
#
|
||||
# No -v: the Docker build cuts each step's log off at 2 MiB, and verbose output
|
||||
# from the whole suite passes that before a failure is printed. Without it, go
|
||||
# test prints one result line per package and, for a package that fails,
|
||||
# everything its tests wrote, application log lines included.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -29,7 +39,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -timeout 90s ./...
|
||||
go test -race -p 4 -parallel 8 -timeout 90s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
||||
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
||||
{{if .User}}
|
||||
<a href="/hooks" class="btn-secondary">Back to webhooks</a>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Sign in</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -16,8 +16,7 @@
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text">Webhooks</a>
|
||||
<a href="/settings" class="btn-text">Settings</a>
|
||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
@@ -25,10 +24,14 @@
|
||||
</svg>
|
||||
{{.User.Username}}
|
||||
</a>
|
||||
{{/* An error page can be served before a form token is issued,
|
||||
and a logout without one is refused. */}}
|
||||
{{if .CSRFToken}}
|
||||
<form method="POST" action="/pages/logout" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text">Logout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Login</a>
|
||||
{{end}}
|
||||
@@ -39,13 +42,14 @@
|
||||
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex flex-col gap-2">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||
{{if .CSRFToken}}
|
||||
<form method="POST" action="/pages/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||
{{end}}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Settings - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Settings}}
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||
<div class="mb-6">
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||
@@ -14,9 +14,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
</form>
|
||||
@@ -24,6 +24,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "webhook_stats" .}}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Entrypoints -->
|
||||
<div class="card">
|
||||
@@ -39,7 +41,7 @@
|
||||
|
||||
<!-- Add entrypoint form -->
|
||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||
@@ -57,20 +59,20 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 mt-1">
|
||||
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
||||
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
|
||||
<!-- Hidden until app.js reveals it; without the
|
||||
script the URL above stays selectable. -->
|
||||
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||
@@ -98,7 +100,7 @@
|
||||
|
||||
<!-- Add target form -->
|
||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||
@@ -151,14 +153,14 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||
</form>
|
||||
@@ -182,7 +184,7 @@
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||
{{if .BodyTruncated}}
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Deliveries}}
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{{if .Status.Terminal}}
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
||||
@@ -139,11 +139,11 @@
|
||||
{{if or .HasPrev .HasNext}}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
{{if .HasPrev}}
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
{{end}}
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||
<a href="/sources/new" class="btn-primary">
|
||||
<a href="/hooks/new" class="btn-primary">
|
||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
@@ -17,7 +17,7 @@
|
||||
{{if .Webhooks}}
|
||||
<div class="grid gap-4">
|
||||
{{range .Webhooks}}
|
||||
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
||||
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||
@@ -42,7 +42,7 @@
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
||||
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/sources/new" class="space-y-6">
|
||||
<form method="POST" action="/hooks/new" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||
<a href="/sources" class="btn-secondary">Cancel</a>
|
||||
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<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>
|
||||
</div>
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{{define "webhook_stats"}}
|
||||
<!-- Statistics pane at the top of the webhook page. -->
|
||||
<div class="card mb-6">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
||||
</div>
|
||||
{{with .Stats}}
|
||||
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
||||
<div>
|
||||
<span class="text-gray-500">Entrypoints</span>
|
||||
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
||||
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Targets</span>
|
||||
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
||||
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Deliveries in progress</span>
|
||||
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Last event</span>
|
||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Retention</span>
|
||||
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<table class="w-full text-center text-gray-900">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<th></th>
|
||||
<th class="py-2 font-medium">Lifetime</th>
|
||||
<th class="py-2 font-medium">Within retention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Events</td>
|
||||
<td class="py-2">{{.Lifetime.Events}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Events}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
||||
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||
<td class="py-2">{{.Lifetime.Failures}}</td>
|
||||
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table class="w-full text-center text-gray-900">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<th></th>
|
||||
<th class="py-2 font-medium">Last 10 minutes</th>
|
||||
<th class="py-2 font-medium">Last 24 hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Events</td>
|
||||
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
||||
<td class="py-2">{{.Last24Hours.Events}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
||||
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
||||
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
||||
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user