1 Commits
Author SHA1 Message Date
clawbot 46aefde3cc Use one recovery routine at start and on every sweep tick (closes #542)
check / check (push) Canceled after 0s
Start-up recovery and the 60-second sweep were two parallel sets of
functions doing the same job. recoverDeliveries now does it, run once at
start and then on every tick from one goroutine. A retrying delivery is
scheduled after what is left of its backoff, now if nothing is left, so
backoffElapsed is gone. The run at start still takes every pending
delivery and opens a webhook database whose file is missing; a tick
still takes only pending deliveries untouched for 15 minutes, at most
500, and skips a missing file. A retrying delivery is taken before its
event is read. The README describes one routine.

Model: opus-5-5
2026-10-15 18:27:03 +00:00
98 changed files with 4568 additions and 4616 deletions
+86 -104
View File
@@ -1579,20 +1579,15 @@ did not arrive on the URL and counts in neither.
A delivery destination for events. Each target defines where and how events A delivery destination for events. Each target defines where and how events
should be forwarded. should be forwarded.
| Field | Type | Description | | Field | Type | Description |
| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook | | `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name | | `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) | | `active` | boolean | Whether deliveries are enabled (default: true) |
| `url` | string | Where an `http` or `slack` target sends each event. It is a credential: the target list shows only its scheme and host, and only the target edit form shows it whole | | `config` | JSON text | Type-specific configuration |
| `headers` | JSON text | An `http` target's extra request headers, a JSON object of name to value | | `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
| `timeout_seconds` | integer | An `http` target's per-request timeout; 0 uses the shared 30-second client timeout |
| `forward_query` | boolean | Whether an `http` target is sent the query string each event arrived with |
| `archive_expiry` | integer | How long a `database` target keeps each archived event, as a Go `time.Duration` (nanoseconds); 0 keeps them forever |
| `archive_rotation` | string | How often a `database` target starts a new archive file: `none`, `monthly`, `daily` or `hourly` |
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries. **Relations:** Belongs to Webhook. Has many Deliveries.
@@ -1603,10 +1598,10 @@ should be forwarded.
failed delivery with exponential backoff, up to that many attempts in all, failed delivery with exponential backoff, up to that many attempts in all,
behind a per-target circuit breaker. behind a per-target circuit breaker.
- **`slack`** — Post the event as a formatted message to a Slack-compatible - **`slack`** — Post the event as a formatted message to a Slack-compatible
incoming webhook URL, its `url`. It is built on the same HTTP core as `http` incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
and honours `max_attempts` identically, circuit breaker included. See the core as `http` and honours `max_attempts` identically, circuit breaker
Slack target section under "Per-Webhook Event Databases" for the message included. See the Slack target section under "Per-Webhook Event Databases" for
format. the message format.
- **`database`** — Archive the full event as a row into the target's own archive - **`database`** — Archive the full event as a row into the target's own archive
database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for
long-term retention, with an optional creation-validated expiry (default: keep long-term retention, with an optional creation-validated expiry (default: keep
@@ -1622,28 +1617,29 @@ should be forwarded.
- **`log`** — Write the event to the application log (stdout). Useful for - **`log`** — Write the event to the application log (stdout). Useful for
debugging. debugging.
Each type uses only its own settings, and the ones it does not use stay empty: The `config` field stores type-specific configuration as JSON (e.g., destination
`http` uses `url`, `headers`, `timeout_seconds` and `forward_query`; `slack` URL, custom headers, timeout settings).
uses `url`; `database` uses `archive_expiry` and `archive_rotation`; `log` uses
none. The add target form, the new webhook form and the target edit form check
every setting before it is saved, and each setting is a column of its own, so
nothing that reads a target parses its settings or has to handle one it cannot
read.
An `http` target POSTs the event to its `url`. Its `headers` are applied last, **`http` target configuration:**
so they win over the event's own forwarded headers.
`forward_query` is off by default, and the target URL is then sent exactly as | Key | Type | Description |
| -------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `url` | string | Destination the event is POSTed to |
| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers |
| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout |
| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not |
`forwardQuery` is off by default, and the target URL is then sent exactly as
configured. On, each delivery appends the event's query string to the target configured. On, each delivery appends the event's query string to the target
URL, joined with `&` when the URL already has a query string of its own; a URL, joined with `&` when the URL already has a query string of its own; a
replayed delivery and a resubmitted event's deliveries do the same. Both target replayed delivery and a resubmitted event's deliveries do the same. Both target
forms offer it as "Pass the query string on to this target", and the target list forms offer it as "Pass the query string on to this target", and the target list
shows it when it is on. shows it when it is on.
`timeout_seconds` is capped at **300 seconds**, and the form rejects anything `timeout` is capped at **300 seconds**, and the form rejects anything above it
above it rather than substituting the cap. A delivery attempt holds one of the rather than substituting the cap. A delivery attempt holds one of the bounded
bounded pool's workers for its whole duration, so an unbounded timeout would let pool's workers for its whole duration, so an unbounded timeout would let a
a single unresponsive destination stall the queue. single unresponsive destination stall the queue.
`headers` rejects the names the delivery path or `net/http` writes regardless of `headers` rejects the names the delivery path or `net/http` writes regardless of
what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`,
@@ -1717,7 +1713,7 @@ The event log and the event's own page show the query string with the rest of
the request. The event log leaves out one larger than 32 KiB, as it does request the request. The event log leaves out one larger than 32 KiB, as it does request
headers, and links to the event's page, which shows it whole. The `database` and headers, and links to the event's page, which shows it whole. The `database` and
`log` targets carry it with the rest of the event. An `http` target receives it `log` targets carry it with the rest of the event. An `http` target receives it
only when its `forward_query` setting is on. only when its `forwardQuery` setting is on.
#### Delivery #### Delivery
@@ -1996,10 +1992,11 @@ target UUID keeps the file name unique. A webhook named `Orders (EU)` with a
target named `Long-term archive` archives into target named `Long-term archive` archives into
`archive-orders-eu-long-term-archive-{target_uuid}.db`. `archive-orders-eu-long-term-archive-{target_uuid}.db`.
A target's `archive_rotation` is `none`, the default, which keeps the one file, An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`)
or `monthly`, `daily` or `hourly`. A target that rotates writes each event to a is `none`, the default, which keeps the one file, or `monthly`, `daily` or
file named for the period of the event's receive time, in UTC, put before the `hourly`. A target that rotates writes each event to a file named for the period
`.db`: `archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, of the event's receive time, in UTC, put before the `.db`:
`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly,
`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly `…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly
its period's events, and the first event of a new period starts the next file, its period's events, and the first event of a new period starts the next file,
so a finished period's file can be moved away like any archive. A changed so a finished period's file can be moved away like any archive. A changed
@@ -2034,11 +2031,12 @@ archiving without stopping the service; a moved or removed archive file is
recreated automatically on the next write, from the SQL files in recreated automatically on the next write, from the SQL files in
`internal/db/migrations/archive/`. A zero-length archive file is written to as a `internal/db/migrations/archive/`. A zero-length archive file is written to as a
new archive: SQLite opens it as an empty database, so it holds nothing to lose. new archive: SQLite opens it as an empty database, so it holds nothing to lose.
A target's `archive_expiry` is checked by the form that saves it — the default, An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is
`never` on the forms and 0 in the column, keeps rows forever — and rows older validated when the target is created — the default (unset or the literal
than the expiry are pruned each time the archive is (re)opened. An archive write `never`) keeps rows forever — and rows older than the expiry are pruned each
failure is never silent success: the delivery records a failed attempt with the time the archive is (re)opened. An archive write failure is never silent
error and is marked failed. success: the delivery records a failed attempt with the error and is marked
failed.
Because reopens only happen on writes, an archive whose target has stopped Because reopens only happen on writes, an archive whose target has stopped
receiving events would never be pruned. A background **archive sweeper** closes receiving events would never be pruned. A background **archive sweeper** closes
@@ -2049,7 +2047,8 @@ traffic. The sweep never creates an archive — a target whose archive file does
not yet exist is skipped, not initialised — it takes the same per-target lock not yet exist is skipped, not initialised — it takes the same per-target lock
the write path uses, so it can never interleave with a write, and it leaves the the write path uses, so it can never interleave with a write, and it leaves the
archive closed afterwards so the move-the-file-away workflow keeps working. archive closed afterwards so the move-the-file-away workflow keeps working.
Archives with the expiry `never` are not touched by the sweep at all. Archives with no expiry, or the expiry `never`, are not touched by the sweep at
all.
For a target with several files, the write path prunes only the file it writes For a target with several files, the write path prunes only the file it writes
to, and the sweep prunes every one of them, taking the target's lock for one to, and the sweep prunes every one of them, taking the target's lock for one
@@ -2101,15 +2100,15 @@ writes go on meanwhile, since under WAL a reader never blocks a writer. Until
the open file is closed its `-wal` cannot be checkpointed past what the download the open file is closed its `-wal` cannot be checkpointed past what the download
reads, so a long download lets that `-wal` grow. reads, so a long download lets that `-wal` grow.
Deleting a webhook releases its archives: its cached archive writers are dropped Deleting a webhook releases its archives: the delivery engine's cached archive
and their file handles closed, so nothing lingers after the webhook is gone. The writers are dropped and their file handles closed, so nothing lingers after the
archive **files themselves are deliberately left on disk**. Unlike the event webhook is gone. The archive **files themselves are deliberately left on disk**.
database — per-webhook working storage that is hard-deleted with the webhook — Unlike the event database — per-webhook working storage that is hard-deleted
an archive is long-term storage an operator may still want to keep or move away with the webhook — an archive is long-term storage an operator may still want to
for offline retention, and destroying it as a side effect of deleting a webhook keep or move away for offline retention, and destroying it as a side effect of
would be unrecoverable. Removing an `archive-….db` is the operator's call. deleting a webhook would be unrecoverable. Removing an `archive-….db` is the
Deleting a `database` target releases its writer the same way, and for the same operator's call. Deleting a `database` target releases its writer the same way,
reason leaves its file alone. and for the same reason leaves its file alone.
The **Slack target type** sends webhook events as formatted messages to any The **Slack target type** sends webhook events as formatted messages to any
Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other
@@ -2117,8 +2116,9 @@ compatible services). Each message includes event metadata (HTTP method, content
type, timestamp, body size) and the payload pretty-printed in a code block. JSON type, timestamp, body size) and the payload pretty-printed in a code block. JSON
payloads are automatically formatted with indentation for readability; non-JSON payloads are automatically formatted with indentation for readability; non-JSON
payloads are shown as raw text. Large payloads are truncated to keep messages payloads are shown as raw text. Large payloads are truncated to keep messages
reasonable. The target's `url` is the Slack/Mattermost incoming webhook reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook
endpoint. endpoint. That is the JSON key; the error text for a missing one reads
`webhook_url is required`, which is the message, not the key.
The database uses the The database uses the
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver.
@@ -2153,8 +2153,12 @@ External Service
1. Look up Entrypoint by UUID — 404 if unknown, 1. Look up Entrypoint by UUID — 404 if unknown,
410 if inactive 410 if inactive
2. Read the body under the 1 MB cap 2. Read the body under the 1 MB cap
3. Hand the full request, as an Event, to 3. Capture full request as Event
Engine.StoreEvent 4. Create Delivery records for each active Target
5. Build self-contained delivery.Task structs
(target config + event data inline for
bodies < 16 KiB)
6. Notify Engine via channel (no DB read needed)
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
@@ -2163,13 +2167,6 @@ External Service
│ (worker │ │ (worker │
│ pool) │ │ pool) │
└──────┬───────┘ └──────┬───────┘
4. Store the Event, a pending Delivery for each
active Target, and the totals, in one
transaction
5. Build a delivery.Task for each Delivery
(target settings + event data, the body
inline when it is under 16 KiB)
6. Queue the tasks on the delivery channel
│ │
┌── bounded worker pool (N workers) ──┐ ┌── bounded worker pool (N workers) ──┐
▼ ▼ ▼ ▼ ▼ ▼
@@ -2183,12 +2180,6 @@ External Service
└────────────┘ └────────────┘
``` ```
A **Resubmit** stores its copy of an event through the same `Engine.StoreEvent`,
and a **Replay** adds one delivery of a stored event through
`Engine.CreateReplay`, which writes the delivery and its target's totals in one
transaction and queues it. Those two methods are the only code that creates a
delivery; the handlers read and check the request, then make the one call.
### Bounded Worker Pool ### Bounded Worker Pool
The delivery engine uses a **fixed-size worker pool** (default: 10 workers) to The delivery engine uses a **fixed-size worker pool** (default: 10 workers) to
@@ -2198,9 +2189,8 @@ preventing goroutine explosions regardless of queue depth.
**Architecture:** **Architecture:**
- **Channels as queues:** Two buffered channels serve as bounded queues: a - **Channels as queues:** Two buffered channels serve as bounded queues: a
delivery channel (the tasks of newly stored deliveries and of the `pending` delivery channel (new tasks from the webhook handler) and a retry channel
deliveries the recovery routine hands back) and a retry channel (tasks from (tasks from backoff timers). Both are buffered to 10,000.
backoff timers). Both are buffered to 10,000.
- **Fan-out via channel, not goroutines:** When an event arrives with multiple - **Fan-out via channel, not goroutines:** When an event arrives with multiple
targets, each `delivery.Task` is sent to the delivery channel. Workers pick targets, each `delivery.Task` is sent to the delivery channel. Workers pick
them up and process them — no goroutine-per-target. them up and process them — no goroutine-per-target.
@@ -2278,8 +2268,8 @@ re-inject it to every currently active target.
`http` and `slack` targets with `max_attempts` > 1 are protected by a `http` and `slack` targets with `max_attempts` > 1 are protected by a
**per-target circuit breaker** that prevents hammering a down target with **per-target circuit breaker** that prevents hammering a down target with
repeated failed delivery attempts. The circuit breaker is in-memory only and repeated failed delivery attempts. The circuit breaker is in-memory only and
resets on restart (which is fine — the recovery routine rescans the database at resets on restart (which is fine — startup recovery rescans the database
start anyway). anyway).
**States:** **States:**
@@ -3019,12 +3009,6 @@ webhooker/
├── cmd/webhooker/ ├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/ ├── internal/
│ ├── archive/
│ │ ├── archive.go # The archive writer of each database target, eviction, rename, and the rename lock
│ │ ├── writer.go # Archive file lifecycle and pruning
│ │ ├── rotation.go # Archive rotation and file names
│ │ ├── download.go # Archive download as gzipped JSON
│ │ └── sweeper.go # Periodic pruning of idle archives
│ ├── banner/ │ ├── banner/
│ │ └── banner.go # Ruled block for the one credential shown in the clear │ │ └── banner.go # Ruled block for the one credential shown in the clear
│ ├── resetpw/ │ ├── resetpw/
@@ -3064,21 +3048,24 @@ webhooker/
│ │ └── logfield.go # Encoded-byte budget for client-supplied log values │ │ └── logfield.go # Encoded-byte budget for client-supplied log values
│ ├── delivery/ │ ├── delivery/
│ │ ├── engine.go # Event-driven delivery engine (channel + timer based) │ │ ├── engine.go # Event-driven delivery engine (channel + timer based)
│ │ ├── store.go # Storing an event with its deliveries, and a replay, then queueing them
│ │ ├── circuit_breaker.go # Per-target circuit breaker for http/slack targets with retries │ │ ├── circuit_breaker.go # Per-target circuit breaker for http/slack targets with retries
│ │ ├── target.go # Target interface, Task, Scheduler │ │ ├── target.go # Target interface, Task, Scheduler
│ │ ├── target_http.go # HTTP target (retries, circuit breaker) │ │ ├── target_http.go # HTTP target (retries, circuit breaker)
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
│ │ ├── target_database.go # Database archive target, writing through internal/archive │ │ ├── target_database.go # Database archive target
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
│ │ ├── target_database_rotation.go # Archive rotation and file names
│ │ ├── target_database_export.go # Archive download as gzipped JSON
│ │ ├── target_log.go # Log target (stdout) │ │ ├── target_log.go # Log target (stdout)
│ │ ├── target_config_view.go # Masked target settings for templates │ │ ├── target_config_view.go # Masked target config for templates
│ │ ├── archive_sweeper.go # Periodic pruning of idle archives
│ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges │ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges
│ │ ├── url_mask.go # Strips credentials from *url.Error │ │ ├── url_mask.go # Strips credentials from *url.Error
│ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport) │ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport)
│ ├── handlers/ │ ├── handlers/
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering │ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
│ │ ├── auth.go # Login, logout handlers │ │ ├── auth.go # Login, logout handlers
│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target settings │ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target config
│ │ ├── event_resubmit.go # Event resubmit: new event, all currently active targets │ │ ├── event_resubmit.go # Event resubmit: new event, all currently active targets
│ │ ├── entrypoint_view.go # Masked entrypoint view for templates │ │ ├── entrypoint_view.go # Masked entrypoint view for templates
│ │ ├── event_log_view.go # Event log projection, byte-capped in SQL │ │ ├── event_log_view.go # Event log projection, byte-capped in SQL
@@ -3093,7 +3080,7 @@ webhooker/
│ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal │ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal
│ │ ├── event_log.go # Event log page: loaders, filters, delivery views │ │ ├── event_log.go # Event log page: loaders, filters, delivery views
│ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle │ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle
│ │ ├── target_create.go # Target create, each type's settings from the form │ │ ├── target_create.go # Target create, per-type config builders
│ │ ├── target_delete.go # Target delete │ │ ├── target_delete.go # Target delete
│ │ ├── target_toggle.go # Target toggle │ │ ├── target_toggle.go # Target toggle
│ │ ├── shared.go # Helpers shared by several handlers │ │ ├── shared.go # Helpers shared by several handlers
@@ -3161,26 +3148,21 @@ Components are wired via Uber fx in this order:
10. `metrics.NewRegistry` — The registry `/metrics` serves 10. `metrics.NewRegistry` — The registry `/metrics` serves
11. `metrics.New` — The delivery collectors, registered on that registry 11. `metrics.New` — The delivery collectors, registered on that registry
12. `middleware.New` — HTTP middleware 12. `middleware.New` — HTTP middleware
13. `delivery.New` — Event-driven delivery engine, which also stores each event 13. `delivery.New` — Event-driven delivery engine
with its deliveries and creates replay deliveries 14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
14. `archive.NewWriters` — The database targets' archive writers, and the lock 15. `delivery.Engine` → `delivery.Notifier` — interface bridge
every rename of their files runs under 16. `delivery.Engine` → `delivery.Archives` — interface bridge so deleting or
15. `archive.NewSweeper` — Periodic pruning of idle archives
16. `archive.Writers` → `archive.Archives` — interface bridge so deleting or
renaming a webhook or target reaches its archive files renaming a webhook or target reaches its archive files
17. `delivery.Engine` → `delivery.CircuitBreakers` — interface bridge so the 17. `server.New` — HTTP server and router
pages can show a target whose deliveries are paused
18. `server.New` — HTTP server and router
The server starts via The server starts via
`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *archive.Sweeper) {})`, `fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper) {})`,
which triggers the fx lifecycle hooks in dependency order. The handlers take which triggers the fx lifecycle hooks in dependency order. The
`*delivery.Engine` itself: the receiver and resubmit handlers store an event `delivery.Notifier` interface allows the webhook handler to send self-contained
through `StoreEvent`, and the replay handler adds a delivery through `delivery.Task` slices to the engine without a direct package dependency. Each
`CreateReplay`. Each task the engine queues carries the target's settings and task carries all target config and event data inline (for bodies under 16 KiB,
the event's data, and the body too when it is under 16 KiB `delivery.MaxInlineBodySize`), so the engine can deliver without reading from
(`delivery.MaxInlineBodySize`); a larger body is read from the event row when any database — it only writes to record results.
the attempt runs.
### Middleware Stack ### Middleware Stack
@@ -3343,7 +3325,7 @@ order under a **5 second budget** (`fx.StopTimeout` in `cmd/webhooker/main.go`).
That budget covers the whole sequence, not each hook. The order, read off the fx That budget covers the whole sequence, not each hook. The order, read off the fx
stop-hook log: stop-hook log:
1. `archive.Sweeper` 1. `ArchiveSweeper`
2. `RetentionReaper` 2. `RetentionReaper`
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
seconds**) and by what the hooks before it left, then a Sentry flush if seconds**) and by what the hooks before it left, then a Sentry flush if
@@ -3386,7 +3368,7 @@ skipped when that leaves too little to be worth attempting — so a full-length
drain means Sentry events are dropped rather than the database close being drain means Sentry events are dropped rather than the database close being
skipped. skipped.
This does not make the database close unconditional. A slow `archive.Sweeper` or This does not make the database close unconditional. A slow `ArchiveSweeper` or
`RetentionReaper` is enough to cut the shutdown short, not only one that `RetentionReaper` is enough to cut the shutdown short, not only one that
consumes the whole budget: what they spend comes out of the drain first, so consumes the whole budget: what they spend comes out of the drain first, so
after 2 seconds of theirs a request still in flight gets 1 second to finish, and after 2 seconds of theirs a request still in flight gets 1 second to finish, and
+10 -9
View File
@@ -10,7 +10,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -50,7 +49,7 @@ import (
// before the server may already have spent. // before the server may already have spent.
// //
// This does not make the database close unconditional: the // This does not make the database close unconditional: the
// archive.Sweeper and RetentionReaper hooks run before the server. // ArchiveSweeper and RetentionReaper hooks run before the server.
// What they spend comes out of the drain first, but past 3s it comes // What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget. // out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second const stopTimeout = 5 * time.Second
@@ -189,8 +188,8 @@ func newApp() *fx.App {
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
// The plain logger the session, the middleware, the // The plain logger the session, the middleware and the
// webhook database manager and the archive writers take. // webhook database manager take.
func(l *logger.Logger) *slog.Logger { return l.Get() }, func(l *logger.Logger) *slog.Logger { return l.Get() },
config.New, config.New,
database.New, database.New,
@@ -209,12 +208,14 @@ func newApp() *fx.App {
// disagree about a destination. // disagree about a destination.
delivery.NewGuard, delivery.NewGuard,
delivery.New, delivery.New,
archive.NewWriters, delivery.NewArchiveSweeper,
archive.NewSweeper, // Wire *delivery.Engine as delivery.Notifier so the
// Wire *archive.Writers as archive.Archives so deleting // webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting
// or renaming a webhook or target reaches its archive // or renaming a webhook or target reaches its archive
// files. // files.
func(w *archive.Writers) archive.Archives { return w }, func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.CircuitBreakers so // Wire *delivery.Engine as delivery.CircuitBreakers so
// the pages can show a target whose deliveries are // the pages can show a target whose deliveries are
// paused. // paused.
@@ -226,7 +227,7 @@ func newApp() *fx.App {
*server.Server, *server.Server,
*delivery.Engine, *delivery.Engine,
*database.RetentionReaper, *database.RetentionReaper,
*archive.Sweeper, *delivery.ArchiveSweeper,
) { ) {
}, },
), ),
+1 -1
View File
@@ -252,7 +252,7 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and // can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme. // the worst case is not necessarily at either extreme.
// //
// Nor does the hook start on a full budget: the archive.Sweeper and // Nor does the hook start on a full budget: the ArchiveSweeper and
// RetentionReaper hooks run before it, and whatever they spent is // RetentionReaper hooks run before it, and whatever they spent is
// gone. The outer sweep walks every amount they can spend. Once they // gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend // have eaten into the headroom themselves, the hook must spend
-446
View File
@@ -1,446 +0,0 @@
// Package archive manages the SQLite archive files the database target
// writes: how they are named and rotated, the writers that open, write,
// close and reopen them, pruning the rows past a target's expiry,
// renaming a target's files and moving them back when that fails, the
// gzipped JSON export, the idle sweep, and the locks that keep a rename
// apart from archive writes, from other edits and from exports.
package archive
import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
)
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see FileName). RenameLock returns the lock every rename runs under
// (see Writers.RenameLock).
//
// It is an interface so that the handlers' tests can stand in for
// Writers.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no writer.
type Archives interface {
RenameLock() *sync.Mutex
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// WritersParams holds the fx dependencies for Writers.
type WritersParams struct {
fx.In
Database *database.Database
DBManager *database.WebhookDBManager
Logger *slog.Logger
}
// Writers holds the archive writer of each database target, and the
// lock every rename of archive files runs under. The database target
// writes an event through it, the handlers rename and evict through
// it, the archive sweeper prunes through it, and the delivery engine
// evicts every writer when it stops. See archiveWriter for the
// close/reopen, auto-recreate, and expiry semantics.
type Writers struct {
db *database.Database
dbMgr *database.WebhookDBManager
log *slog.Logger
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// it reads the stored names and lists the files they give, and
// again for each file while it finds the file under the names
// stored then and opens it. RenameLock returns it.
renameMu sync.Mutex
}
// NewWriters builds the archive writers. It opens no archive file:
// each target's writer is made on its first use.
func NewWriters(params WritersParams) *Writers {
return &Writers{
db: params.Database,
dbMgr: params.DBManager,
log: params.Logger,
}
}
// RenameLock implements Archives. It returns the lock every rename of
// archive files runs under: see renameMu for who holds it and for how
// long. Archive writes and Rename itself do not take it.
func (ws *Writers) RenameLock() *sync.Mutex {
return &ws.renameMu
}
// FileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func FileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Write writes the full event of a delivery to a database target as
// a row into the target's archive database, honouring the target's
// ArchiveExpiry and ArchiveRotation. With rotation, the event goes to
// the file for the period of its receive time.
func (ws *Writers) Write(d *database.Delivery) error {
webhookID := d.Event.WebhookID
if webhookID == "" {
return errArchiveMissingWebhookID
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see delivery's Engine.hydrateEvent), and goes
// to the file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := ws.writerFor(d.TargetID)
if err != nil {
return err
}
row := archivedEvent{
EventID: d.Event.ID,
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
}
return w.write(
row, d.Target.ArchiveExpiry,
archivePeriod(d.Target.ArchiveRotation, receivedAt),
)
}
// Rename implements Archives. It renames every one of a database
// target's archive files to FileName(webhookName, targetName,
// targetID), each keeping the period in its name. It goes through the
// target's writer, so the move holds the lock that the target's
// archive writes and the idle sweep take, and later writes use the
// new name. It never replaces a file: if one already has a new name,
// the error is ErrNameTaken.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (ws *Writers) Rename(
targetID, webhookName, targetName string,
) error {
w, err := ws.writerFor(targetID)
if err != nil {
return err
}
return w.rename(FileName(webhookName, targetName, targetID))
}
// EvictTarget implements Archives. It drops a database target's
// archive writer from the registry and closes its handle, so a
// deleted target does not leave a writer (and an open archive handle
// within its debounce window) alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (ws *Writers) EvictTarget(targetID string) {
ws.mu.Lock()
w, ok := ws.writers[targetID]
if ok {
delete(ws.writers, targetID)
}
ws.mu.Unlock()
if !ok {
return
}
w.evict()
ws.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// EvictWebhook implements Archives. It evicts, exactly as EvictTarget
// does, the writer of every database target of a webhook. The archive
// files themselves are left on disk — they are long-term storage the
// operator owns.
func (ws *Writers) EvictWebhook(webhookID string) {
ws.mu.Lock()
var gone []*archiveWriter
for targetID, w := range ws.writers {
if w.webhookID == webhookID {
delete(ws.writers, targetID)
gone = append(gone, w)
}
}
ws.mu.Unlock()
for _, w := range gone {
w.evict()
ws.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// EvictAll evicts every cached archive writer, exactly as EvictTarget
// does for one target. The delivery engine calls it at shutdown, once
// its workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
func (ws *Writers) EvictAll() {
ws.mu.Lock()
writers := ws.writers
ws.writers = nil
ws.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
func (ws *Writers) writerFor(
targetID string,
) (*archiveWriter, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
var err error
w, err = ws.newWriter(targetID)
if err != nil {
return nil, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
ws.writers[targetID] = w
}
// A delivery claims the entry: even if the idle sweep created
// it moments ago, it now belongs to the registry proper and
// the sweep must leave it in place when it finishes.
w.sweepOwned = false
return w, nil
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (ws *Writers) sweepWriterFor(
targetID string,
) (*archiveWriter, bool, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if ok {
return w, false, nil
}
w, err := ws.newWriter(targetID)
if err != nil {
return nil, false, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
w.sweepOwned = true
ws.writers[targetID] = w
return w, true, nil
}
// releaseSweepWriter drops a registry entry that the idle sweep
// created, so a sweep leaves the registry exactly as it found it.
//
// The entry is removed only if it is still the very writer the
// sweep installed and no delivery has claimed it in the meantime
// (writerFor clears sweepOwned when it hands a writer to the
// write path). Both conditions are evaluated under the registry
// lock, so an eviction that raced the sweep — which removes the
// entry outright — simply finds nothing left to do here, and a
// delivery that adopted the writer keeps a registered, evictable
// one.
func (ws *Writers) releaseSweepWriter(
targetID string, w *archiveWriter,
) {
ws.mu.Lock()
defer ws.mu.Unlock()
cur, ok := ws.writers[targetID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(ws.writers, targetID)
}
// newWriter builds the writer for a database target's archive. Its
// path is the one Path gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive files.
func (ws *Writers) newWriter(
targetID string,
) (*archiveWriter, error) {
if ws.dbMgr == nil {
return nil, errArchiveNoDataDir
}
var target database.Target
err := ws.db.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
Path(ws.dbMgr, &target.Webhook, &target),
ws.log,
)
w.webhookID = target.WebhookID
return w, nil
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped.
func (ws *Writers) sweepArchive(
targetID string, expiry time.Duration,
) error {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return err
}
if created {
defer ws.releaseSweepWriter(targetID, w)
}
return w.sweepExpired(expiry)
}
-757
View File
@@ -1,757 +0,0 @@
package archive_test
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
const (
// testContentType is the event content type used in tests.
testContentType = "application/json"
// eventQuery is the query string an archived event carries.
eventQuery = "a=1&b=2"
)
func archiveTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
}
// openArchiveDBForRead opens an archive file read-only so a
// test can inspect the rows the writer persisted.
func openArchiveDBForRead(
t *testing.T, path string,
) *gorm.DB {
t.Helper()
sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf("file:%s?mode=ro", path),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
return gdb
}
// archiveFileSuffixes returns the archive file itself and the
// SQLite sidecars that accompany an open database. A test that
// asserts no archive was created has to check all of them.
func archiveFileSuffixes() []string {
return []string{"", "-wal", "-shm"}
}
// removeArchiveFiles simulates an operator moving the archive
// away by deleting the SQLite file and its sidecar files.
func removeArchiveFiles(t *testing.T, path string) {
t.Helper()
for _, suffix := range []string{
"", "-wal", "-shm", "-journal",
} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("removing %s%s: %v", path, suffix, err)
}
}
}
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
// is in the form GORM stores created_at in: the layout of
// time.Time.String, with no " m=+…" monotonic clock reading after it.
func assertStoredTime(t *testing.T, text string) {
t.Helper()
_, err := time.Parse(
"2006-01-02 15:04:05.999999999 -0700 MST", text,
)
assert.NoError(t, err, "stored as %q", text)
}
// newEvent returns an event received now with the given body, as the
// webhook handler stores it.
func newEvent(body string) database.Event {
return database.Event{
BaseModel: database.BaseModel{
ID: uuid.New().String(),
CreatedAt: time.Now(),
},
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: testContentType,
}
}
// databaseTargetDelivery returns a delivery of event to a database
// target, as the database target hands it to Writers.Write.
func databaseTargetDelivery(
event database.Event, tgt *database.Target,
) *database.Delivery {
return &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
Event: event,
Target: database.Target{
Name: tgt.Name,
Type: database.TargetTypeDatabase,
ArchiveExpiry: tgt.ArchiveExpiry,
ArchiveRotation: tgt.ArchiveRotation,
},
}
}
func TestArchiveWriter_WritesRow(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
row := archive.ExportArchivedEvent{
EventID: "ev-1",
WebhookID: "wh-1",
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X":"Y"}`,
Body: `{"hello":"world"}`,
ContentType: "application/json",
}
require.NoError(t, w.Write(row, 0))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "ev-1", got[0].EventID)
assert.Equal(t, "wh-1", got[0].WebhookID)
assert.Equal(t, "ep-1", got[0].EntrypointID)
assert.Equal(t, row.Method, got[0].Method)
assert.Equal(t, row.ContentType, got[0].ContentType)
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
assert.False(t, got[0].ArchivedAt.IsZero())
var archivedAt string
require.NoError(t, w.DB().Raw(
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
).Row().Scan(&archivedAt))
assertStoredTime(t, archivedAt)
}
func TestArchiveWriter_RecreatesAfterRemoval(
t *testing.T,
) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
assert.FileExists(t, path)
// The operator moves the archive away while the handle is
// still open.
removeArchiveFiles(t, path)
require.NoFileExists(t, path)
// The next write recreates the file with a fresh schema and
// only the new row.
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "b", got[0].EventID)
}
// TestArchiveHandleIsWAL closes the last gap in the durability
// evidence: the main and per-webhook tiers each assert their journal
// mode on a live handle, and the archive tier gets its settings from
// the same code path but nothing checked the running file.
func TestArchiveHandleIsWAL(t *testing.T) {
t.Parallel()
w := archive.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive-wal.db"),
archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
var mode string
row := w.DB().Raw("pragma journal_mode").Row()
require.NoError(t, row.Scan(&mode))
assert.Equal(t, "wal", strings.ToLower(mode))
var busy string
row = w.DB().Raw("pragma busy_timeout").Row()
require.NoError(t, row.Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestStatArchive proves Stat finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := archive.Stat(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := archive.Stat(path)
require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = archive.Stat(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
const debounce = 2 * time.Second
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), debounce,
)
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
// Two writes inside the debounce window trigger only the
// initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "c"}, 0,
))
// A write after the window elapses closes and reopens once.
assert.Equal(t, 2, w.Reopens())
}
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
old := archive.ExportArchivedEvent{
EventID: "old",
ArchivedAt: time.Now().Add(-2 * time.Hour),
}
fresh := archive.ExportArchivedEvent{
EventID: "fresh",
ArchivedAt: time.Now(),
}
require.NoError(t, w.DB().Create(&old).Error)
require.NoError(t, w.DB().Create(&fresh).Error)
// Reopening with a one-hour expiry prunes the old row.
require.NoError(t, w.Reopen(time.Hour))
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "fresh", got[0].EventID)
}
func TestParseArchiveExpiry(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{"empty", "", 0, false},
{"explicit never", "never", 0, false},
{"hour", "1h", time.Hour, false},
{"thirty days", "720h", 720 * time.Hour, false},
{"minutes", "30m", 30 * time.Minute, false},
{"unparseable", "nonsense", 0, true},
{"days are not a Go unit", "7d", 0, true},
{"zero duration", "0s", 0, true},
{"bare zero", "0", 0, true},
{"negative duration", "-5h", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := archive.ParseExpiry(tc.in)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
archive.FileName(tc.webhook, tc.target, id),
)
})
}
}
// TestWrite_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, 0, "")
second := env.addDatabaseTarget(t, first.WebhookID, 0, "")
event := newEvent(`{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
require.NoError(t, env.writers.Write(
databaseTargetDelivery(event, tgt),
))
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(first, tgt)))
require.FileExists(t, oldPath)
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := newEvent(`{"n":2}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(second, tgt)))
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(first, tgt)),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := newEvent(`{"n":2}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(second, tgt)),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Archive"),
)
event := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := archive.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
))
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, entry := range entries {
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
"%s is not under the old name", entry.Name())
}
assert.Equal(t, oldPath, w.Path())
}
// TestRenameLock_HeldByOneEditAtATime holds the rename lock, as an edit
// does from loading the stored names until it has saved them. While it
// is held, the edit's rename and an archive write still finish, since
// neither takes it, and the event ends up under the new name whichever
// ran first. A second edit waits for the lock until the first lets go.
func TestRenameLock_HeldByOneEditAtATime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "")
event := newEvent(`{"n":1}`)
env.writers.RenameLock().Lock()
finished := make(chan error, 2)
go func() {
finished <- env.writers.Rename(tgt.ID, "Orders", "Long Term")
}()
go func() {
finished <- env.writers.Write(databaseTargetDelivery(event, tgt))
}()
for range 2 {
select {
case err := <-finished:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("a rename or a write waited for the rename lock")
}
}
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.Equal(t, []string{event.ID}, archivedEventIDs(t, newPath))
secondEdit := make(chan struct{})
go func() {
env.writers.RenameLock().Lock()
close(secondEdit)
env.writers.RenameLock().Unlock()
}()
select {
case <-secondEdit:
t.Fatal("a second edit took the rename lock while it was held")
case <-time.After(100 * time.Millisecond):
}
env.writers.RenameLock().Unlock()
select {
case <-secondEdit:
case <-time.After(10 * time.Second):
t.Fatal("a second edit did not get the rename lock once released")
}
}
-280
View File
@@ -1,280 +0,0 @@
package archive
import (
"context"
"log/slog"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
// an evicted archive writer. It carries the Err prefix rather
// than this file's usual Export one because it is a sentinel
// error.
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
// ExportArchivedEvent aliases the archive row type so black-box
// tests can construct and read archive rows.
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
// NewExportArchiveWriter builds an archive writer for tests,
// optionally overriding the reopen debounce (a non-positive
// debounce keeps the production default).
func NewExportArchiveWriter(
path string, log *slog.Logger, debounce time.Duration,
) *ExportArchiveWriter {
w := newArchiveWriter(path, log)
if debounce > 0 {
w.debounce = debounce
}
return &ExportArchiveWriter{w: w}
}
// Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration,
) error {
return e.w.write(row, expiry, "")
}
// WritePeriod archives a row through the writer, into the file for
// period.
func (e *ExportArchiveWriter) WritePeriod(
row ExportArchivedEvent, expiry time.Duration, period string,
) error {
return e.w.write(row, expiry, period)
}
// Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry)
}
// Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen(
expiry time.Duration,
) error {
return e.w.reopen(e.w.path, expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
}
// DB returns the writer's current open handle for row
// inspection in tests.
func (e *ExportArchiveWriter) DB() *gorm.DB {
return e.w.db
}
// Path returns the archive file the writer owns.
func (e *ExportArchiveWriter) Path() string {
return e.w.path
}
// OpenExisting opens the archive without permitting creation,
// the way the idle sweep does.
func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration,
) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
}
// SweepExpired runs an idle sweep of the archive.
func (e *ExportArchiveWriter) SweepExpired(
expiry time.Duration,
) error {
return e.w.sweepExpired(expiry)
}
// Evict marks the writer evicted and closes its handle, exactly
// as leaving the registry does.
func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
e.w.mu.Lock()
defer e.w.mu.Unlock()
return e.w.db != nil
}
// Same reports whether both wrappers refer to the very same
// underlying archive writer, so a test can prove a registry entry
// is the writer it was handed rather than a replacement.
func (e *ExportArchiveWriter) Same(
other *ExportArchiveWriter,
) bool {
return other != nil && e.w == other.w
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
func (ws *Writers) ExportArchiveWriterFor(
targetID string,
) *ExportArchiveWriter {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
return nil
}
return &ExportArchiveWriter{w: w}
}
// ExportHasArchiveWriter reports whether the registry currently
// caches an archive writer for a target.
func (ws *Writers) ExportHasArchiveWriter(
targetID string,
) bool {
ws.mu.Lock()
defer ws.mu.Unlock()
_, ok := ws.writers[targetID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// returns false when no writer is cached.
func (ws *Writers) ExportArchiveHandleOpen(
targetID string,
) bool {
ws.mu.Lock()
w, ok := ws.writers[targetID]
ws.mu.Unlock()
if !ok {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
return w.db != nil
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would.
func (ws *Writers) ExportEnsureArchiveWriter(
targetID string,
) (string, error) {
w, err := ws.writerFor(targetID)
if err != nil {
return "", err
}
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (ws *Writers) ExportSweepWriterFor(
targetID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return nil, false, err
}
return &ExportArchiveWriter{w: w}, created, nil
}
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (ws *Writers) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
) {
ws.releaseSweepWriter(targetID, w.w)
}
// NewTestSweeper builds a Sweeper backed by the given main
// database and archive writers, without the fx lifecycle.
// Intended for tests.
func NewTestSweeper(
db *database.Database,
writers *Writers,
log *slog.Logger,
) *Sweeper {
return &Sweeper{
db: db,
writers: writers,
log: log,
interval: time.Hour,
}
}
// ExportSweep runs a single archive sweep synchronously for
// tests.
func (s *Sweeper) ExportSweep(ctx context.Context) {
s.sweep(ctx)
}
// ExportStart starts the sweeper's background loop for tests.
func (s *Sweeper) ExportStart() {
s.start()
}
// ExportRegisterHooks registers the sweeper's real fx lifecycle
// hooks on a lifecycle supplied by a test, so a test can drive
// the exact OnStart/OnStop functions the application runs and
// hand OnStart the kind of context fx actually supplies.
func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) {
s.registerHooks(lc)
}
// ExportStop stops the sweeper's background loop for tests.
func (s *Sweeper) ExportStop(ctx context.Context) error {
return s.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
// that never observes cancellation and returns only when release
// is closed. It stands in for a prune stuck on a locked archive.
func (s *Sweeper) ExportWedgeLoop(
release <-chan struct{},
) {
s.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (s *Sweeper) ExportSetInterval(d time.Duration) {
s.interval = d
}
+1 -1
View File
@@ -28,7 +28,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
WebhookID: webhookID, WebhookID: webhookID,
Name: "leaky-target", Name: "leaky-target",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: "https://hooks.example/secret", Config: `{"webhookUrl":"https://hooks.example/secret"}`,
MaxAttempts: 1, MaxAttempts: 1,
} }
target.ID = uuid.New().String() target.ID = uuid.New().String()
+2 -3
View File
@@ -79,9 +79,8 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
// The delivery engine: the recovery routine, which reads the // The delivery engine: recovery and the retry sweep, the sweep for
// retrying deliveries and, on a tick of the sweep, the pending ones // stranded pending deliveries, and the queue depth count.
// untouched for a while; and the queue depth count.
assertPlanUses(t, db, dry.Where( assertPlanUses(t, db, dry.Where(
"status = ?", database.DeliveryStatusRetrying, "status = ?", database.DeliveryStatusRetrying,
).Find(&deliveries), byStatus) ).Find(&deliveries), byStatus)
+7 -18
View File
@@ -29,10 +29,8 @@ func marshalModel(t *testing.T, v any) string {
// these tags the first handler that marshals a model serialises the // these tags the first handler that marshals a model serialises the
// secret with it. Each field below is a live credential: // secret with it. Each field below is a live credential:
// //
// - Target.URL is an incoming-webhook URL whose path segments are // - Target.Config holds an incoming-webhook URL whose path segments
// the bearer token. // are the bearer token.
// - Target.Headers holds header values, routinely authorization
// tokens.
// - APIKey.Key is a bearer token outright. // - APIKey.Key is a bearer token outright.
// - Setting.Value holds the session encryption key. // - Setting.Value holds the session encryption key.
// - User.Password holds the Argon2 hash, and was already tagged. // - User.Password holds the Argon2 hash, and was already tagged.
@@ -46,20 +44,11 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
model any model any
}{ }{
{ {
name: "target url", name: "target config",
model: database.Target{ model: database.Target{
Name: keptField, Name: keptField,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: "https://h/s/" + marker, Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1,
},
},
{
name: "target headers",
model: database.Target{
Name: keptField,
Type: database.TargetTypeHTTP,
Headers: map[string]string{"Authorization": marker},
MaxAttempts: 1, MaxAttempts: 1,
}, },
}, },
@@ -98,10 +87,10 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
} }
} }
// TestWebhookMarshalsNoTargetURL covers the nested case: a webhook // TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
// marshalled with its targets preloaded must not carry the credential // marshalled with its targets preloaded must not carry the credential
// through the association either. // through the association either.
func TestWebhookMarshalsNoTargetURL(t *testing.T) { func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
t.Parallel() t.Parallel()
const marker = "QQNESTEDMARKERQQ" const marker = "QQNESTEDMARKERQQ"
@@ -110,7 +99,7 @@ func TestWebhookMarshalsNoTargetURL(t *testing.T) {
Name: keptField, Name: keptField,
Targets: []database.Target{{ Targets: []database.Target{{
Name: "slack", Name: "slack",
URL: "https://h/s/" + marker, Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}}, }},
}) })
+8 -33
View File
@@ -1,7 +1,5 @@
package database package database
import "time"
// TargetType represents the type of delivery target // TargetType represents the type of delivery target
type TargetType string type TargetType string
@@ -13,9 +11,7 @@ const (
TargetTypeSlack TargetType = "slack" TargetTypeSlack TargetType = "slack"
) )
// Target represents a delivery target for a webhook. Its settings // Target represents a delivery target for a webhook
// are the fields from URL to ArchiveRotation; each type uses some of
// them, and the ones a type does not use stay empty.
type Target struct { type Target struct {
BaseModel BaseModel
@@ -24,35 +20,14 @@ type Target struct {
Type TargetType `gorm:"not null" json:"type"` Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// URL is where an http or slack target sends each event. // Configuration fields (JSON stored based on type).
// //
// json:"-" because the URL is the target's credential: a Slack // json:"-" because the blob holds the target's credential — a
// incoming-webhook URL, or an http destination whose path segments // Slack incoming-webhook URL, or an http destination whose path
// are the secret. delivery.TargetView is the masking barrier for // segments are the secret. delivery.TargetView is the masking
// the HTML path; this tag is the barrier for any handler that // barrier for the HTML path; this tag is the barrier for any
// marshals the model itself. // handler that marshals the model itself.
URL string `json:"-"` Config string `gorm:"type:text" json:"-"` // JSON configuration
// Headers is an http target's extra request headers, name to
// value, stored as JSON text. json:"-" for the same reason as URL:
// a header value is routinely an authorization token.
Headers map[string]string `gorm:"serializer:json" json:"-"`
// TimeoutSeconds is an http target's per-request timeout; 0 uses
// the delivery engine's shared client timeout.
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
// ForwardQuery passes each event's query string on to an http
// target, appended to URL. Off, URL is sent exactly as stored.
ForwardQuery bool `json:"forwardQuery,omitempty"`
// ArchiveExpiry is how long a database target keeps each archived
// event; 0 keeps them forever.
ArchiveExpiry time.Duration `json:"archiveExpiry,omitempty"`
// ArchiveRotation is how often a database target starts a new
// archive file: none, monthly, daily or hourly.
ArchiveRotation string `json:"archiveRotation,omitempty"`
// MaxAttempts is how many times an HTTP or Slack target attempts // MaxAttempts is how many times an HTTP or Slack target attempts
// each delivery in all, at least 1. 1 is a single attempt with no // each delivery in all, at least 1. 1 is a single attempt with no
+1 -20
View File
@@ -16,7 +16,7 @@ func newHTTPTarget(maxAttempts int) *database.Target {
WebhookID: uuid.New().String(), WebhookID: uuid.New().String(),
Name: "target", Name: "target",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: "https://example.com/", Config: `{"url":"https://example.com/"}`,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
} }
} }
@@ -42,22 +42,3 @@ func TestTargetCreate_AcceptsOneAttempt(t *testing.T) {
require.NoError(t, db.Create(newHTTPTarget(1)).Error) require.NoError(t, db.Create(newHTTPTarget(1)).Error)
} }
// TestTargetHeaders_StoredAsJSON reads the headers column back as
// text, to show it holds the header map as JSON.
func TestTargetHeaders_StoredAsJSON(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
target := newHTTPTarget(1)
target.Headers = map[string]string{"Authorization": "Bearer x"}
require.NoError(t, db.Create(target).Error)
var headers string
require.NoError(t, db.Raw(
"SELECT headers FROM targets WHERE id = ?", target.ID,
).Scan(&headers).Error)
assert.JSONEq(t, `{"Authorization":"Bearer x"}`, headers)
}
+2 -3
View File
@@ -184,9 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// A missing database has nothing to reap. The delivery // A missing database has nothing to reap. Restart recovery
// engine's recovery routine reports a lost one at start (see // reports a lost one (see WebhookDBManager.GetDB).
// WebhookDBManager.GetDB).
if !r.dbManager.DBExists(wh.ID) { if !r.dbManager.DBExists(wh.ID) {
continue continue
} }
+7 -12
View File
@@ -87,18 +87,13 @@ func TestMainSchemaHoldsEveryModel(t *testing.T) {
Active: true, Active: true,
}) })
assertRoundTrip(t, db, &database.Target{ assertRoundTrip(t, db, &database.Target{
BaseModel: newBaseModel(), BaseModel: newBaseModel(),
WebhookID: uuid.New().String(), WebhookID: uuid.New().String(),
Name: "target", Name: "target",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Active: true, Active: true,
URL: "https://example.com/", Config: `{"url":"https://example.com/"}`,
Headers: map[string]string{"X-Api-Key": "key"}, MaxAttempts: 3,
TimeoutSeconds: 30,
ForwardQuery: true,
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: "daily",
MaxAttempts: 3,
}) })
} }
+1 -1
View File
@@ -137,7 +137,7 @@ func TestPerWebhookEventDatabaseFilesAreOwnerOnly(t *testing.T) {
} }
// TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier. // TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier.
// internal/archive builds that path and opens it through OpenSQLite, // internal/delivery builds that path and opens it through OpenSQLite,
// the same single open path exercised here, so the mode is settled for // the same single open path exercised here, so the mode is settled for
// all three tiers in one place. // all three tiers in one place.
func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) { func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) {
@@ -1,5 +1,5 @@
-- Migration 001: the schema of a database target's archive file. The -- Migration 001: the schema of a database target's archive file. The
-- archivedEvent model in internal/archive must agree with it. -- archivedEvent model in internal/delivery must agree with it.
CREATE TABLE archived_events ( CREATE TABLE archived_events (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
+1 -6
View File
@@ -79,12 +79,7 @@ CREATE TABLE targets (
name text NOT NULL, name text NOT NULL,
type text NOT NULL, type text NOT NULL,
active numeric DEFAULT true, active numeric DEFAULT true,
url text, config text,
headers text,
timeout_seconds integer,
forward_query numeric,
archive_expiry integer,
archive_rotation text,
max_attempts integer NOT NULL, max_attempts integer NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_webhooks_targets CONSTRAINT fk_webhooks_targets
@@ -1,4 +1,4 @@
package archive package delivery
import ( import (
"context" "context"
@@ -15,18 +15,18 @@ import (
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
// SweeperParams holds the fx dependencies for the // ArchiveSweeperParams holds the fx dependencies for the
// Sweeper. // ArchiveSweeper.
type SweeperParams struct { type ArchiveSweeperParams struct {
fx.In fx.In
Config *config.Config Config *config.Config
Database *database.Database Database *database.Database
Writers *Writers Engine *Engine
Logger *logger.Logger Logger *logger.Logger
} }
// Sweeper periodically prunes expired rows from the // ArchiveSweeper periodically prunes expired rows from the
// archive databases of database targets that carry a positive // archive databases of database targets that carry a positive
// expiry. // expiry.
// //
@@ -40,9 +40,9 @@ type SweeperParams struct {
// It reuses Config.RetentionSweepInterval rather than // It reuses Config.RetentionSweepInterval rather than
// introducing a second interval: this is a retention sweep with // introducing a second interval: this is a retention sweep with
// the same semantics as the event retention reaper. // the same semantics as the event retention reaper.
type Sweeper struct { type ArchiveSweeper struct {
db *database.Database db *database.Database
writers *Writers eng *Engine
log *slog.Logger log *slog.Logger
interval time.Duration interval time.Duration
@@ -54,16 +54,16 @@ type Sweeper struct {
wg sync.WaitGroup wg sync.WaitGroup
} }
// NewSweeper creates the archive sweeper and registers // NewArchiveSweeper creates the archive sweeper and registers
// its fx lifecycle hooks. The background sweep loop starts on // its fx lifecycle hooks. The background sweep loop starts on
// OnStart and stops cleanly on OnStop via context cancellation. // OnStart and stops cleanly on OnStop via context cancellation.
func NewSweeper( func NewArchiveSweeper(
lc fx.Lifecycle, lc fx.Lifecycle,
params SweeperParams, params ArchiveSweeperParams,
) *Sweeper { ) *ArchiveSweeper {
s := &Sweeper{ s := &ArchiveSweeper{
db: params.Database, db: params.Database,
writers: params.Writers, eng: params.Engine,
log: params.Logger.Get(), log: params.Logger.Get(),
interval: params.Config.RetentionSweepInterval, interval: params.Config.RetentionSweepInterval,
} }
@@ -77,7 +77,7 @@ func NewSweeper(
// lifecycle. The start hook's context is deliberately ignored // lifecycle. The start hook's context is deliberately ignored
// (see start for why the background loop must not inherit it); // (see start for why the background loop must not inherit it);
// the stop hook's context is honoured (see stop). // the stop hook's context is honoured (see stop).
func (s *Sweeper) registerHooks(lc fx.Lifecycle) { func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
//nolint:contextcheck // Not passing the hook context is //nolint:contextcheck // Not passing the hook context is
// the point: see start. // the point: see start.
@@ -103,7 +103,7 @@ func (s *Sweeper) registerHooks(lc fx.Lifecycle) {
// goroutine must outlive the startup phase, so its lifetime is // goroutine must outlive the startup phase, so its lifetime is
// bounded by OnStop instead: stop cancels this context and waits // bounded by OnStop instead: stop cancels this context and waits
// on the WaitGroup. // on the WaitGroup.
func (s *Sweeper) start() { func (s *ArchiveSweeper) start() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel s.cancel = cancel
@@ -121,7 +121,7 @@ func (s *Sweeper) start() {
// exit, bounded by the stop hook's context: a prune wedged on a // exit, bounded by the stop hook's context: a prune wedged on a
// locked archive must not hang the process past fx's stop // locked archive must not hang the process past fx's stop
// timeout. // timeout.
func (s *Sweeper) stop(ctx context.Context) error { func (s *ArchiveSweeper) stop(ctx context.Context) error {
s.log.Info("archive sweeper stopping") s.log.Info("archive sweeper stopping")
if s.cancel != nil { if s.cancel != nil {
@@ -140,7 +140,7 @@ func (s *Sweeper) stop(ctx context.Context) error {
return nil return nil
} }
func (s *Sweeper) run(ctx context.Context) { func (s *ArchiveSweeper) run(ctx context.Context) {
defer s.wg.Done() defer s.wg.Done()
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.interval)
@@ -164,7 +164,7 @@ func (s *Sweeper) run(ctx context.Context) {
// A failure for one target is logged and the sweep continues, // A failure for one target is logged and the sweep continues,
// matching how the write path already treats a prune error as // matching how the write path already treats a prune error as
// non-fatal. // non-fatal.
func (s *Sweeper) sweep(ctx context.Context) { func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target var targets []database.Target
err := s.db.DB(). err := s.db.DB().
@@ -199,20 +199,31 @@ func (s *Sweeper) sweep(ctx context.Context) {
} }
} }
// sweepTarget prunes the archive of a single database target. A // sweepTarget prunes the archive of a single database target.
// target with an ArchiveExpiry of 0 keeps its archive forever and is // A missing, empty, or "never" expiry parses as a zero duration
// skipped entirely, so that archive is never pruned. // and is skipped entirely, so those archives are never pruned.
func (s *Sweeper) sweepTarget(target *database.Target) { func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
expiry := target.ArchiveExpiry expiry, err := parseArchiveExpiry(target.Config)
if err != nil {
s.log.Error(
"archive sweep: invalid database target config",
"webhook_id", target.WebhookID,
"target_id", target.ID,
"error", err,
)
return
}
if expiry <= 0 { if expiry <= 0 {
return return
} }
if s.writers == nil { if s.eng == nil || s.eng.dbTarget == nil {
return return
} }
err := s.writers.sweepArchive(target.ID, expiry) err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
if err == nil { if err == nil {
return return
} }
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bytes" "bytes"
@@ -16,15 +16,14 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
@@ -38,18 +37,6 @@ const (
// sweepConcurrentWrites is how many deliveries the // sweepConcurrentWrites is how many deliveries the
// concurrent write-plus-sweep test races against the sweep. // concurrent write-plus-sweep test races against the sweep.
sweepConcurrentWrites = 20 sweepConcurrentWrites = 20
// hookStopTimeout bounds how long a lifecycle test waits for
// the sweeper's OnStop hook to return before declaring the
// shutdown hung.
hookStopTimeout = 10 * time.Second
// wedgeStopTimeout is the stop timeout a wedged-shutdown test
// hands OnStop, standing in for fx's StopTimeout. The test
// asserts only that the hook returns at all, and allows it
// hookStopTimeout — forty times this budget — to do so, so no
// assertion here races the wall clock.
wedgeStopTimeout = 250 * time.Millisecond
) )
// archiveTestWebhookName is the name of every webhook // archiveTestWebhookName is the name of every webhook
@@ -58,12 +45,12 @@ const (
const archiveTestWebhookName = "Sweep Test!" const archiveTestWebhookName = "Sweep Test!"
// archiveEnv bundles the pieces an archive test drives: a main // archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, the archive // configuration database holding webhooks and targets, a delivery
// writers, the archive sweeper, and the data directory the archive // engine owning the archive writer registry, the archive sweeper,
// files live in. // and the data directory the archive files live in.
type archiveEnv struct { type archiveEnv struct {
sweeper *archive.Sweeper sweeper *delivery.ArchiveSweeper
writers *archive.Writers eng *delivery.Engine
mainDB *database.Database mainDB *database.Database
dataDir string dataDir string
} }
@@ -79,37 +66,39 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = mainDB.Close() }) t.Cleanup(func() { _ = mainDB.Close() })
writers := archive.NewWriters(archive.WritersParams{ eng := delivery.NewTestEngineWithDB(
Database: mainDB, mainDB,
DBManager: databasetest.NewWebhookDBManager(t, dataDir), databasetest.NewWebhookDBManager(t, dataDir),
Logger: log, log,
}) &http.Client{Timeout: 5 * time.Second},
1,
)
return &archiveEnv{ return &archiveEnv{
sweeper: archive.NewTestSweeper( sweeper: delivery.NewTestArchiveSweeper(
mainDB, writers, log, mainDB, eng, log,
), ),
writers: writers, eng: eng,
mainDB: mainDB, mainDB: mainDB,
dataDir: dataDir, dataDir: dataDir,
} }
} }
// archivePath returns where the writers keep a database target's // archivePath returns where the engine keeps a database target's
// archive file, for the names seedDatabaseTarget gave it. // archive file, for the names seedDatabaseTarget gave it.
func (env *archiveEnv) archivePath(tgt *database.Target) string { func (env *archiveEnv) archivePath(tgt *database.Target) string {
return filepath.Join( return filepath.Join(
env.dataDir, env.dataDir,
archive.FileName( delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID, archiveTestWebhookName, tgt.Name, tgt.ID,
), ),
) )
} }
// seedDatabaseTarget creates a webhook with one database target with // seedDatabaseTarget creates a webhook with one database target
// the given archive expiry and rotation, and returns the target. // carrying the given target config JSON, and returns the target.
func (env *archiveEnv) seedDatabaseTarget( func (env *archiveEnv) seedDatabaseTarget(
t *testing.T, expiry time.Duration, rotation string, t *testing.T, configJSON string,
) *database.Target { ) *database.Target {
t.Helper() t.Helper()
@@ -124,27 +113,23 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error, Create(wh).Error,
) )
return env.addDatabaseTarget(t, wh.ID, expiry, rotation) return env.addDatabaseTarget(t, wh.ID, configJSON)
} }
// addDatabaseTarget creates one more database target on an // addDatabaseTarget creates one more database target on an
// existing webhook and returns it. // existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget( func (env *archiveEnv) addDatabaseTarget(
t *testing.T, t *testing.T, webhookID, configJSON string,
webhookID string,
expiry time.Duration,
rotation string,
) *database.Target { ) *database.Target {
t.Helper() t.Helper()
tgt := &database.Target{ tgt := &database.Target{
WebhookID: webhookID, WebhookID: webhookID,
Name: "Archive", Name: "Archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Active: true, Active: true,
ArchiveExpiry: expiry, Config: configJSON,
ArchiveRotation: rotation, MaxAttempts: 1,
MaxAttempts: 1,
} }
require.NoError( require.NoError(
t, t,
@@ -192,7 +177,7 @@ func seedArchiveFile(
require.NoError(t, migrations.Apply(gdb, migrations.Archive)) require.NoError(t, migrations.Apply(gdb, migrations.Archive))
for i, at := range archivedAt { for i, at := range archivedAt {
row := archive.ExportArchivedEvent{ row := delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i), EventID: fmt.Sprintf("ev-%d", i),
WebhookID: webhookID, WebhookID: webhookID,
Method: http.MethodPost, Method: http.MethodPost,
@@ -212,7 +197,7 @@ func archivedEventIDs(
) []string { ) []string {
t.Helper() t.Helper()
var rows []archive.ExportArchivedEvent var rows []delivery.ExportArchivedEvent
rdb := openArchiveDBForRead(t, path) rdb := openArchiveDBForRead(t, path)
require.NoError(t, rdb.Order("event_id").Find(&rows).Error) require.NoError(t, rdb.Order("event_id").Find(&rows).Error)
@@ -249,7 +234,7 @@ func countArchivedRows(path string) (int64, error) {
var count int64 var count int64
err = gdb.Model(&archive.ExportArchivedEvent{}). err = gdb.Model(&delivery.ExportArchivedEvent{}).
Count(&count).Error Count(&count).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -258,55 +243,6 @@ func countArchivedRows(path string) (int64, error) {
return count, nil return count, nil
} }
// recordingLifecycle is a minimal fx.Lifecycle that records the
// hooks a component registers, so a test can invoke the real
// OnStart/OnStop functions with a context of its choosing.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(h fx.Hook) {
l.hooks = append(l.hooks, h)
}
// requireStopHookExpires drives hook.OnStop with a stop context
// that expires while a wedged goroutine is still running, and
// requires the hook to return the deadline error naming
// component instead of blocking on the WaitGroup forever.
func requireStopHookExpires(
t *testing.T, hook fx.Hook, component string,
) {
t.Helper()
stopCtx, cancel := context.WithTimeout(
context.Background(), wedgeStopTimeout,
)
defer cancel()
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
stopErr = hook.OnStop(stopCtx)
}()
select {
case <-stopped:
case <-time.After(hookStopTimeout):
t.Fatal(
"OnStop did not return: it discarded the stop " +
"context and is waiting on a wedged goroutine " +
"that will never observe cancellation",
)
}
require.ErrorIs(t, stopErr, context.DeadlineExceeded)
require.ErrorContains(t, stopErr, component)
}
// TestArchiveSweeper_LoopOutlivesStartHookContext is the // TestArchiveSweeper_LoopOutlivesStartHookContext is the
// regression test for a sweeper that never swept. fx calls // regression test for a sweeper that never swept. fx calls
// OnStart with a context carrying the application's start // OnStart with a context carrying the application's start
@@ -327,7 +263,7 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
@@ -381,7 +317,7 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
@@ -390,16 +326,16 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
// the deletion path does. The target row is deliberately left // the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the target before // in place: this is the tick that listed the target before
// the deletion committed. // the deletion committed.
_, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) _, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
env.writers.EvictTarget(tgt.ID) env.eng.EvictTarget(tgt.ID)
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must never re-register a writer for a target "+ "a sweep must never re-register a writer for a target "+
"whose registry entry has already been released", "whose registry entry has already been released",
) )
@@ -415,14 +351,14 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, tgt,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
@@ -431,7 +367,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive", "the sweep must still prune an idle archive",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must release the registry entry it created", "the sweep must release the registry entry it created",
) )
} }
@@ -448,27 +384,29 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a delivery's writer must stay registered", "a delivery's writer must stay registered",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must not drop a writer a delivery owns", "a sweep must not drop a writer a delivery owns",
) )
} }
@@ -502,12 +440,12 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
sweepWriter, created, err := env.writers.ExportSweepWriterFor( sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID, tgt.ID,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -517,34 +455,36 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) )
// The delivery lands mid-sweep and adopts the entry. // The delivery lands mid-sweep and adopts the entry.
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.writers.ExportArchiveWriterFor(tgt.ID) adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, adopted) require.NotNil(t, adopted)
require.True( require.True(
t, sweepWriter.Same(adopted), t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer", "the delivery must have adopted the sweep's writer",
) )
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery leaves the archive handle open", "the delivery leaves the archive handle open",
) )
// The sweep finishes. // The sweep finishes.
env.writers.ExportReleaseSweepWriter(tgt.ID, sweepWriter) env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
require.True( require.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a writer adopted by a delivery during a sweep must "+ "a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable", "stay registered, or its open handle is unreachable",
) )
env.writers.EvictTarget(tgt.ID) env.eng.EvictTarget(tgt.ID)
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the adopted writer must still be evictable", "the adopted writer must still be evictable",
) )
assert.False( assert.False(
@@ -555,8 +495,8 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a // TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
// failure for one target does not abort the sweep for the // failure for one target does not abort the sweep for the
// others: an unreadable archive has to be logged and stepped // others: an unparseable expiry and an unreadable archive both
// over. // have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure( func TestArchiveSweep_ContinuesAfterPerTargetFailure(
t *testing.T, t *testing.T,
) { ) {
@@ -564,16 +504,21 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
env := setupArchiveTest(t) env := setupArchiveTest(t)
// Seeded first so the sweep reaches it before the healthy // Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order. // target: targets come back in insertion order.
corrupt := env.seedDatabaseTarget(t, time.Hour, "") badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows(
t, badConfig, time.Now().Add(-48*time.Hour),
)
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
env.archivePath(corrupt), env.archivePath(corrupt),
[]byte("this is not a sqlite database"), []byte("this is not a sqlite database"),
0o600, 0o600,
)) ))
healthy := env.seedDatabaseTarget(t, time.Hour, "") healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows( healthyPath := env.seedArchiveRows(
t, healthy, t, healthy,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
@@ -603,7 +548,7 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "archive-absent.db") path := filepath.Join(dir, "archive-absent.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -630,7 +575,7 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
@@ -669,12 +614,12 @@ func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -709,28 +654,30 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
require.NoError(t, env.writers.Write(d)) env.eng.ExportDeliverDatabase(webhookDB, d)
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the delivery must leave the archive handle open", "the delivery must leave the archive handle open",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.True( require.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the delivery's registry entry must survive the sweep", "the delivery's registry entry must survive the sweep",
) )
assert.False( assert.False(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the sweep must leave the archive closed", "the sweep must leave the archive closed",
) )
} }
@@ -746,7 +693,7 @@ func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -774,8 +721,8 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
var errorLines bytes.Buffer var errorLines bytes.Buffer
sweeper := archive.NewTestSweeper( sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.writers, env.mainDB, env.eng,
slog.New(slog.NewTextHandler( slog.New(slog.NewTextHandler(
&errorLines, &errorLines,
&slog.HandlerOptions{Level: slog.LevelError}, &slog.HandlerOptions{Level: slog.LevelError},
@@ -794,29 +741,36 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
} }
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so an archive with an // no-op for the default retention policy, so archives with no
// expiry of 0, which keeps it forever, is left alone. // expiry (or the literal "never") behave exactly as before.
func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) for _, configJSON := range []string{
`{"expiry":"never"}`,
`{"expiry":""}`,
"",
} {
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, tgt,
time.Now().Add(-10000*time.Hour), time.Now().Add(-10000*time.Hour),
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.Equal( assert.Equal(
t, []string{sweepRowOld}, archivedEventIDs(t, path), t, []string{sweepRowOld}, archivedEventIDs(t, path),
"an expiry of 0 must keep rows forever", "config %q must keep rows forever", configJSON,
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"an expiry of 0 must leave no registry entry behind", "config %q must leave no registry entry behind",
) configJSON,
)
}
} }
// TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the // TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the
@@ -837,7 +791,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
seedUnmigratedArchive(t, path) seedUnmigratedArchive(t, path)
@@ -877,7 +831,7 @@ func archiveTableExists(t *testing.T, path string) bool {
return openArchiveDBForRead(t, path). return openArchiveDBForRead(t, path).
Migrator(). Migrator().
HasTable(&archive.ExportArchivedEvent{}) HasTable(&delivery.ExportArchivedEvent{})
} }
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
@@ -889,7 +843,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -904,7 +858,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
} }
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind", "the sweep must leave no registry entry behind",
) )
} }
@@ -920,9 +874,9 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -939,7 +893,7 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
@@ -967,16 +921,23 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t)
// The deliveries are seeded up front, on the test's own
// goroutine: the seed helpers assert, and testify assertions
// must not run off the test goroutine.
deliveries := make( deliveries := make(
[]*database.Delivery, 0, sweepConcurrentWrites, []*database.Delivery, 0, sweepConcurrentWrites,
) )
for range sweepConcurrentWrites { for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`)
deliveries = append( deliveries = append(
deliveries, deliveries,
databaseTargetDelivery(newEvent(`{"n":1}`), tgt), seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
) )
} }
@@ -988,7 +949,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
defer wg.Done() defer wg.Done()
for _, d := range deliveries { for _, d := range deliveries {
_ = env.writers.Write(d) env.eng.ExportDeliverDatabase(webhookDB, d)
} }
}() }()
@@ -1012,7 +973,7 @@ func TestArchiveSweeper_StopsCleanly(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, "") tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, tgt, time.Now().Add(-48*time.Hour),
) )
+19 -3
View File
@@ -46,7 +46,12 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
t.Run(target, func(t *testing.T) { t.Run(target, func(t *testing.T) {
t.Parallel() t.Parallel()
client := engine.ExportClientForRequest(5, nil) cfg := &delivery.HTTPTargetConfig{
URL: target,
Timeout: 5,
}
client := engine.ExportClientForRequest(cfg, nil)
require.NotSame(t, engine.ExportClient(), client, require.NotSame(t, engine.ExportClient(), client,
"a per-target timeout must yield a "+ "a per-target timeout must yield a "+
@@ -96,7 +101,11 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
client := engine.ExportClientForRequest(0, nil) cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/hook",
}
client := engine.ExportClientForRequest(cfg, nil)
assert.Same(t, engine.ExportClient(), client, assert.Same(t, engine.ExportClient(), client,
"without a per-target timeout the shared client "+ "without a per-target timeout the shared client "+
@@ -114,8 +123,15 @@ func TestClientForRequest_HeadersKeepSSRFGuard(t *testing.T) {
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/with-headers",
Headers: map[string]string{
"X-Api-Key": "configured",
},
}
client := engine.ExportClientForRequest( client := engine.ExportClientForRequest(
0, []string{probeHeaderName}, cfg, []string{"X-Api-Key"},
) )
require.NotNil(t, client.CheckRedirect, require.NotNil(t, client.CheckRedirect,
+168 -132
View File
@@ -1,5 +1,5 @@
// Package delivery stores each event with its deliveries and delivers // Package delivery manages asynchronous event delivery
// them, asynchronously, to configured targets. // to configured targets.
package delivery package delivery
import ( import (
@@ -13,7 +13,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
@@ -23,11 +22,10 @@ import (
const ( const (
// deliveryChannelSize is the buffer size for the delivery // deliveryChannelSize is the buffer size for the delivery
// channel. The Tasks of newly stored deliveries, and of the // channel. New Tasks from the webhook handler are sent
// pending deliveries the recovery routine hands back, are // here. Workers drain this channel. Sized large enough
// sent here. Workers drain this channel. Sized large enough // that the webhook handler should never block under
// that no task should be dropped under normal load: one that // normal load.
// does not fit is left for the sweep.
deliveryChannelSize = 10000 deliveryChannelSize = 10000
// retryChannelSize is the buffer size for the retry // retryChannelSize is the buffer size for the retry
@@ -41,10 +39,10 @@ const (
// explosions regardless of queue depth. // explosions regardless of queue depth.
defaultWorkers = 10 defaultWorkers = 10
// sweepInterval is how often the recovery routine runs // retrySweepInterval is how often the recovery routine runs
// once it has run at start. Each of those runs is a tick of // once it has run at start. Each of those runs is a tick of
// the sweep. // the sweep.
sweepInterval = 60 * time.Second retrySweepInterval = 60 * time.Second
// pendingSweepMinAge is how long a delivery must have sat // pendingSweepMinAge is how long a delivery must have sat
// untouched at pending before the sweep will look at it. // untouched at pending before the sweep will look at it.
@@ -65,10 +63,10 @@ const (
pendingSweepMinAge = 15 * time.Minute pendingSweepMinAge = 15 * time.Minute
// pendingSweepBatch bounds how many stranded pending deliveries // pendingSweepBatch bounds how many stranded pending deliveries
// one tick of the sweep re-dispatches for one webhook. The sweep // one sweep of one webhook re-dispatches. The sweep runs every
// ticks every sweepInterval, so a larger backlog drains // retrySweepInterval, so a larger backlog drains across
// across successive ticks instead of arriving as one burst against // successive sweeps instead of arriving as one burst against a
// a database that was already struggling to accept writes. // database that was already struggling to accept writes.
pendingSweepBatch = 500 pendingSweepBatch = 500
// MaxInlineBodySize is the maximum event body size that // MaxInlineBodySize is the maximum event body size that
@@ -106,19 +104,12 @@ type Task struct {
WebhookID string WebhookID string
EntrypointID string EntrypointID string
// The target's fields, as they were when the task was made. TargetID string
TargetID string TargetName string
TargetName string TargetType database.TargetType
TargetType database.TargetType TargetConfig string
TargetURL string MaxAttempts int
TargetHeaders map[string]string
TimeoutSeconds int
ForwardQuery bool
ArchiveExpiry time.Duration
ArchiveRotation string
MaxAttempts int
// The event's fields.
Method string Method string
RawQuery string RawQuery string
Headers string Headers string
@@ -128,11 +119,37 @@ type Task struct {
AttemptNum int AttemptNum int
} }
// Notifier is the interface for notifying the delivery
// engine about new deliveries.
type Notifier interface {
Notify(tasks []Task)
}
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see ArchiveFileName).
//
// It is deliberately separate from Notifier: archiving lifecycle
// is not notification, and a small interface keeps the handlers
// package free of any dependency on the engine's internals while
// staying trivially fakeable in tests.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no engine state.
type Archives interface {
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// CircuitBreakers is how the handlers read a target's circuit // CircuitBreakers is how the handlers read a target's circuit
// breaker, so the webhook page and the event log can say that // breaker, so the webhook page and the event log can say that
// deliveries to the target are paused and until when. The handlers // deliveries to the target are paused and until when. Like Archives,
// hold the engine itself as well; they read breakers through this // it keeps the handlers free of the engine's internals and is
// interface so that a test can make one read as open. // trivially faked in tests.
type CircuitBreakers interface { type CircuitBreakers interface {
StateAndCooldown(targetID string) (CircuitState, time.Duration) StateAndCooldown(targetID string) (CircuitState, time.Duration)
} }
@@ -148,17 +165,15 @@ type EngineParams struct {
Logger *logger.Logger Logger *logger.Logger
SSRFGuard *Guard SSRFGuard *Guard
Metrics *metrics.Set Metrics *metrics.Set
Archives *archive.Writers
} }
// Engine processes queued deliveries in the background // Engine processes queued deliveries in the background
// using a bounded worker pool architecture. It also stores each // using a bounded worker pool architecture. It owns only
// event with its deliveries and creates replay deliveries (see // the cross-target machinery: the worker pool, the queue
// store.go). Beyond that it owns only the cross-target machinery: // and retry channels, the recovery routine, and the persistence
// the worker pool, the delivery and retry channels, the recovery // helpers and Scheduler that individual targets rely on.
// routine, and the persistence helpers and Scheduler that // Each target type owns its own delivery, including retries,
// individual targets rely on. Each target type owns its own // backoff, and circuit breaking.
// delivery, including retries, backoff, and circuit breaking.
type Engine struct { type Engine struct {
database *database.Database database *database.Database
dbManager *database.WebhookDBManager dbManager *database.WebhookDBManager
@@ -188,10 +203,9 @@ type Engine struct {
httpTarget *httpTarget httpTarget *httpTarget
slackTarget *slackTarget slackTarget *slackTarget
// archives is the database targets' archive writers. The // dbTarget is retained so the engine can reach the archive
// database target writes each event through them, and stop // writer registry for eviction, renames and the idle sweep.
// closes them. dbTarget *databaseTarget
archives *archive.Writers
// inflight is the set of deliveries this engine currently owns. // inflight is the set of deliveries this engine currently owns.
// The recovery routine re-dispatches only what it does not // The recovery routine re-dispatches only what it does not
@@ -215,7 +229,6 @@ func New(
workers: defaultWorkers, workers: defaultWorkers,
version: params.Globals.Version, version: params.Globals.Version,
mtr: params.Metrics, mtr: params.Metrics,
archives: params.Archives,
} }
e.initTargets(&http.Client{ e.initTargets(&http.Client{
@@ -228,6 +241,79 @@ func New(
return e return e
} }
// Notify signals the delivery engine that new deliveries
// are ready.
func (e *Engine) Notify(tasks []Task) {
for i := range tasks {
// Owned before it is queued, and until the worker that runs
// it returns. A task can sit in a 10000-deep channel for a
// long time on a healthy system, and nothing may re-send it
// while it waits. See inflight.go.
if !e.inflight.retainIdle(tasks[i].DeliveryID) {
e.log.Warn(
"delivery already in flight, not queued again",
"delivery_id", tasks[i].DeliveryID,
"event_id", tasks[i].EventID,
)
continue
}
select {
case e.deliveryCh <- tasks[i]:
default:
e.inflight.release(tasks[i].DeliveryID)
e.log.Warn(
"delivery channel full, "+
"task will be recovered by the sweep",
"delivery_id", tasks[i].DeliveryID,
"event_id", tasks[i].EventID,
)
}
}
}
// EvictWebhook implements Archives. The cached archive writer of
// every database target of the webhook is dropped from the
// registry and its file handle closed. The archive files
// themselves are left on disk — they are long-term storage the
// operator owns.
func (e *Engine) EvictWebhook(webhookID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evictWebhook(webhookID)
}
// EvictTarget implements Archives. It is EvictWebhook for a single
// database target, and leaves the archive file on disk the same
// way.
func (e *Engine) EvictTarget(targetID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evict(targetID)
}
// Rename implements Archives. It renames every one of a database
// target's archive files to ArchiveFileName(webhookName, targetName,
// targetID), each keeping the period in its name, under the lock the
// target's archive writes and the idle sweep take. It never replaces
// a file: if one already has a new name, the error is
// ErrArchiveNameTaken. The caller renames before it saves the new
// name: see databaseTarget.rename.
func (e *Engine) Rename(
targetID, webhookName, targetName string,
) error {
if e.dbTarget == nil {
return nil
}
return e.dbTarget.rename(targetID, webhookName, targetName)
}
// StateAndCooldown implements CircuitBreakers. It returns the state of // StateAndCooldown implements CircuitBreakers. It returns the state of
// the target's circuit breaker and, while the breaker is open, what is // the target's circuit breaker and, while the breaker is open, what is
// left of its cooldown; the cooldown is zero once that has passed and // left of its cooldown; the cooldown is zero once that has passed and
@@ -288,38 +374,6 @@ func (e *Engine) ScheduleRetry(
}) })
} }
// notify queues the tasks of deliveries already stored. StoreEvent and
// CreateReplay call it once their transaction has committed.
func (e *Engine) notify(tasks []Task) {
for i := range tasks {
// Owned before it is queued, and until the worker that runs
// it returns. A task can sit in a 10000-deep channel for a
// long time on a healthy system, and nothing may re-send it
// while it waits. See inflight.go.
if !e.inflight.retainIdle(tasks[i].DeliveryID) {
e.log.Warn(
"delivery already in flight, not queued again",
"delivery_id", tasks[i].DeliveryID,
"event_id", tasks[i].EventID,
)
continue
}
select {
case e.deliveryCh <- tasks[i]:
default:
e.inflight.release(tasks[i].DeliveryID)
e.log.Warn(
"delivery channel full, "+
"task will be recovered by the sweep",
"delivery_id", tasks[i].DeliveryID,
"event_id", tasks[i].EventID,
)
}
}
}
// userAgent is the User-Agent header of every http and slack // userAgent is the User-Agent header of every http and slack
// delivery request: the program name and the running build's // delivery request: the program name and the running build's
// version. // version.
@@ -409,7 +463,7 @@ func (e *Engine) stop(ctx context.Context) error {
return err return err
} }
e.archives.EvictAll() e.dbTarget.evictAll()
e.log.Info("delivery engine stopped") e.log.Info("delivery engine stopped")
@@ -467,10 +521,9 @@ func (e *Engine) processNewTask(
return return
} }
// The recovery routine's run at start can send and release this // Restart recovery can send and release this delivery before the
// delivery before StoreEvent or CreateReplay queues it. Ownership // receiver's Notify queues it. Ownership cannot refuse a delivery
// cannot refuse a delivery nobody holds, so the row decides whether // nobody holds, so the row decides whether it still needs sending.
// it still needs sending.
row, err := e.loadDelivery(webhookDB, task.DeliveryID) row, err := e.loadDelivery(webhookDB, task.DeliveryID)
if err != nil { if err != nil {
e.log.Error( e.log.Error(
@@ -652,11 +705,11 @@ func (e *Engine) abandonRetryForMissingTarget(
// runRecovery runs the recovery routine, recoverDeliveries, once when // runRecovery runs the recovery routine, recoverDeliveries, once when
// the engine starts and then on every tick of the sweep, every // the engine starts and then on every tick of the sweep, every
// sweepInterval, until the engine stops. // retrySweepInterval, until the engine stops.
func (e *Engine) runRecovery(ctx context.Context) { func (e *Engine) runRecovery(ctx context.Context) {
defer e.wg.Done() defer e.wg.Done()
ticker := time.NewTicker(sweepInterval) ticker := time.NewTicker(retrySweepInterval)
defer ticker.Stop() defer ticker.Stop()
e.recoverDeliveries(ctx, true) e.recoverDeliveries(ctx, true)
@@ -676,11 +729,11 @@ func (e *Engine) runRecovery(ctx context.Context) {
// sweep. // sweep.
// //
// For every webhook it finds the pending and retrying deliveries the // For every webhook it finds the pending and retrying deliveries the
// engine is not working on, such as those a stop or a crash cut off, a // engine is not working on: those a stop or a crash cut off, a full
// full channel dropped, or a failed bookkeeping write left behind. One // channel dropped, or a failed bookkeeping write left behind. One that
// that already has a successful attempt is marked delivered. A retrying // already has a successful attempt is marked delivered. A retrying one
// one is scheduled after what is left of its backoff, or now when // is scheduled after what is left of its backoff, or now when nothing
// nothing is left. A pending one goes to a worker now. // is left. A pending one goes to a worker now.
// //
// Ownership keeps it off a delivery the engine is working on, has // Ownership keeps it off a delivery the engine is working on, has
// queued, or holds a retry timer for, the same at start as on a tick: // queued, or holds a retry timer for, the same at start as on a tick:
@@ -895,7 +948,7 @@ func (e *Engine) recoverSingleRetry(
return return
} }
e.ScheduleRetry(buildTask( e.ScheduleRetry(buildRecoveryTask(
d, webhookID, &event, &target, attemptNum+1, d, webhookID, &event, &target, attemptNum+1,
), remaining) ), remaining)
@@ -914,11 +967,8 @@ func (e *Engine) recoverSingleRetry(
// //
// A delivery is created pending and leaves that state only when its // A delivery is created pending and leaves that state only when its
// outcome is written, so a pending row the engine does not own is one // outcome is written, so a pending row the engine does not own is one
// it stopped working on before then: for example one whose bookkeeping // whose bookkeeping write failed, whose attempt a stop cut off, or
// write failed, whose attempt a stop cut off, that was still queued // that was still queued when the engine last stopped.
// when the engine last stopped, whose task was dropped because the
// delivery channel was full (by notify or by queueRecovered), or whose
// worker could not open the webhook's database.
// //
// At start every pending delivery is taken, however recent, so one a // At start every pending delivery is taken, however recent, so one a
// stop cut off moments ago is sent again now. A tick takes only those // stop cut off moments ago is sent again now. A tick takes only those
@@ -1546,15 +1596,10 @@ func buildEventFromTask(task *Task) database.Event {
func buildTargetFromTask(task *Task) database.Target { func buildTargetFromTask(task *Task) database.Target {
target := database.Target{ target := database.Target{
Name: task.TargetName, Name: task.TargetName,
Type: task.TargetType, Type: task.TargetType,
URL: task.TargetURL, Config: task.TargetConfig,
Headers: task.TargetHeaders, MaxAttempts: task.MaxAttempts,
TimeoutSeconds: task.TimeoutSeconds,
ForwardQuery: task.ForwardQuery,
ArchiveExpiry: task.ArchiveExpiry,
ArchiveRotation: task.ArchiveRotation,
MaxAttempts: task.MaxAttempts,
} }
target.ID = task.TargetID target.ID = task.TargetID
@@ -1669,11 +1714,11 @@ func (e *Engine) countAttempts(
// answer cannot go stale between asking and acting. // answer cannot go stale between asking and acting.
// //
// Stamping updated_at is the same statement, and it is a cadence // Stamping updated_at is the same statement, and it is a cadence
// control rather than a claim: a tick of the sweep selects pending // control rather than a claim: the pending sweep selects on that
// deliveries on that column, so one handed out now is not selected // column, so a delivery handed out now is not selected again on the
// again on the next tick a minute later but after pendingSweepMinAge. // next tick a minute later but after pendingSweepMinAge. A delivery
// A delivery the database refuses to settle is therefore retried on // the database refuses to settle is therefore retried on that
// that interval instead of every tick. // interval instead of every tick.
// //
// A failed write is a refusal. It means the database is not accepting // A failed write is a refusal. It means the database is not accepting
// writes, which is the condition that stranded the delivery in the // writes, which is the condition that stranded the delivery in the
@@ -1818,11 +1863,7 @@ func (e *Engine) loadTarget(
return target, nil return target, nil
} }
// buildTask builds the task that carries delivery d of event to target func buildRecoveryTask(
// through the engine's channels, as attempt attemptNum. The body is
// carried only when it is under MaxInlineBodySize; a larger one is read
// from the event row when the attempt runs (see hydrateEvent).
func buildTask(
d *database.Delivery, d *database.Delivery,
webhookID string, webhookID string,
event *database.Event, event *database.Event,
@@ -1837,26 +1878,21 @@ func buildTask(
} }
return Task{ return Task{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: d.EventID, EventID: d.EventID,
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: event.EntrypointID, EntrypointID: event.EntrypointID,
TargetID: target.ID, TargetID: target.ID,
TargetName: target.Name, TargetName: target.Name,
TargetType: target.Type, TargetType: target.Type,
TargetURL: target.URL, TargetConfig: target.Config,
TargetHeaders: target.Headers, MaxAttempts: target.MaxAttempts,
TimeoutSeconds: target.TimeoutSeconds, Method: event.Method,
ForwardQuery: target.ForwardQuery, RawQuery: event.RawQuery,
ArchiveExpiry: target.ArchiveExpiry, Headers: event.Headers,
ArchiveRotation: target.ArchiveRotation, ContentType: event.ContentType,
MaxAttempts: target.MaxAttempts, Body: bodyPtr,
Method: event.Method, AttemptNum: attemptNum,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
AttemptNum: attemptNum,
} }
} }
@@ -1998,7 +2034,7 @@ func (e *Engine) sendRecoveredDeliveries(
continue continue
} }
task := buildTask( task := buildRecoveryTask(
&deliveries[i], webhookID, &event, &target, &deliveries[i], webhookID, &event, &target,
attempts[deliveries[i].ID]+1, attempts[deliveries[i].ID]+1,
) )
+144 -93
View File
@@ -3,6 +3,7 @@ package delivery_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -96,6 +97,17 @@ func iSeedWebhookDB(
return db return db
} }
func iHTTPConfig(url string) string {
cfg := delivery.HTTPTargetConfig{URL: url}
data, err := json.Marshal(cfg)
if err != nil {
panic("failed to marshal HTTPTargetConfig")
}
return string(data)
}
func iEngine( func iEngine(
t *testing.T, workers int, t *testing.T, workers int,
) *delivery.Engine { ) *delivery.Engine {
@@ -157,24 +169,24 @@ func iSeedDelivery(
func iTask( func iTask(
d database.Delivery, d database.Delivery,
event database.Event, event database.Event,
webhookID, targetID, name, targetURL string, webhookID, targetID, name, config string,
maxAttempts, attemptNum int, maxAttempts, attemptNum int,
body *string, body *string,
) delivery.Task { ) delivery.Task {
return delivery.Task{ return delivery.Task{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: webhookID, WebhookID: webhookID,
TargetID: targetID, TargetID: targetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: body, Body: body,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
} }
@@ -227,9 +239,10 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-target", ts.URL, 1, 1, &bodyStr, "test-target", cfg, 1, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -277,9 +290,10 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := iHTTPConfig(ts.URL)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"test-large", ts.URL, 1, 1, nil, "test-large", cfg, 1, 1, nil,
) )
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -299,16 +313,16 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
s := newISetup(t) s := newISetup(t)
task := delivery.Task{ task := delivery.Task{
DeliveryID: uuid.New().String(), DeliveryID: uuid.New().String(),
EventID: uuid.New().String(), EventID: uuid.New().String(),
WebhookID: uuid.New().String(), WebhookID: uuid.New().String(),
TargetID: uuid.New().String(), TargetID: uuid.New().String(),
TargetName: "test", TargetName: "test",
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: "http://localhost:9999", TargetConfig: iHTTPConfig("http://localhost:9999"),
MaxAttempts: 1, MaxAttempts: 1,
Body: nil, Body: nil,
AttemptNum: 1, AttemptNum: 1,
} }
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
@@ -349,6 +363,7 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
// The target row exists because the engine confirms a scheduled // The target row exists because the engine confirms a scheduled
// retry's target has not been deleted before it runs it. A retry // retry's target has not been deleted before it runs it. A retry
@@ -357,12 +372,12 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
// task. See https://git.eeqj.de/sneak/webhooker/issues/107. // task. See https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-target", t, s.MainDB, targetID, s.WebhookID, "retry-target",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-target", ts.URL, 5, 2, &bodyStr, "retry-target", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -395,9 +410,10 @@ func TestProcessRetryTask_SkipsNonRetryingDelivery(
) )
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig("http://localhost:1")
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"skip-target", "http://localhost:1", 5, 2, &bodyStr, "skip-target", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -444,14 +460,16 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-large", t, s.MainDB, targetID, s.WebhookID, "retry-large",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-large", ts.URL, 5, 2, nil, "retry-large", cfg, 5, 2, nil,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -492,7 +510,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
) )
task.TargetType = database.TargetTypeLog task.TargetType = database.TargetTypeLog
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, d.ID) iWaitForDelivered(t, s.WebhookDB, d.ID)
@@ -552,15 +570,16 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
s.Engine.ExportStart() s.Engine.ExportStart()
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test", t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-chan-test", ts.URL, 5, 2, &bodyStr, "retry-chan-test", cfg, 5, 2, &bodyStr,
) )
s.Engine.ExportRetryCh() <- task s.Engine.ExportRetryCh() <- task
@@ -666,7 +685,7 @@ func iCreateTarget(
mainDB *gorm.DB, mainDB *gorm.DB,
targetID, webhookID, name string, targetID, webhookID, name string,
targetType database.TargetType, targetType database.TargetType,
targetURL string, config string,
maxAttempts int, maxAttempts int,
) { ) {
t.Helper() t.Helper()
@@ -676,7 +695,7 @@ func iCreateTarget(
Name: name, Name: name,
Type: targetType, Type: targetType,
Active: true, Active: true,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
} }
target.ID = targetID target.ID = targetID
@@ -717,7 +736,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "retry-recovery", s.WebhookID, "retry-recovery",
database.TargetTypeHTTP, database.TargetTypeHTTP,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -777,7 +796,7 @@ func iSeedRetryingWithType(
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "mutated-target", targetType, s.WebhookID, "mutated-target", targetType,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -869,7 +888,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries(
) )
} }
func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries( func TestSweepSingleRetry_TypeNoLongerRetries(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -915,14 +934,14 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
targetID := uuid.New().String() targetID := uuid.New().String()
// A Slack incoming-webhook URL: the target's URL IS the // A Slack incoming-webhook URL: the target config IS the
// credential, which is what makes a leaked target row a // credential, which is what makes a leaked target row a
// disclosure rather than a curiosity. // disclosure rather than a curiosity.
hookURL := "https://hooks.slack.com/services/T00/B00/x" hookURL := "https://hooks.slack.com/services/T00/B00/x"
iCreateTarget(t, s.MainDB, targetID, iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "credential-bearing", s.WebhookID, "credential-bearing",
database.TargetTypeLog, hookURL, 5, database.TargetTypeLog, iHTTPConfig(hookURL), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -974,7 +993,7 @@ func TestRecoverSingleRetry_UnknownTargetType(
iAssertTerminallyFailed(t, s, deliveryID, unknown) iAssertTerminallyFailed(t, s, deliveryID, unknown)
} }
func TestRecoverSingleRetry_OnTick_UnknownTargetType( func TestSweepSingleRetry_UnknownTargetType(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -1122,9 +1141,8 @@ func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) {
} }
// TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a // TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while the recovery routine // webhook deleted from the web interface while restart recovery runs.
// runs. Its database file is gone, and recovery must not create it // Its database file is gone, and recovery must not create it again.
// again.
func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead( func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T, t *testing.T,
) { ) {
@@ -1167,7 +1185,7 @@ func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
assert.False(t, dbMgr.DBExists(webhookID)) assert.False(t, dbMgr.DBExists(webhookID))
} }
// --- HTTP target with custom headers --- // --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1189,6 +1207,16 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
cfg := delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{
"Authorization": "Bearer secret-token",
},
}
cfgJSON, err := json.Marshal(cfg)
require.NoError(t, err)
event := iSeedEvent( event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, t, s.WebhookDB, s.WebhookID,
`{"auth":"test"}`, `{"auth":"test"}`,
@@ -1203,12 +1231,9 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"auth-target", ts.URL, "auth-target", string(cfgJSON),
1, 1, &bodyStr, 1, 1, &bodyStr,
) )
task.TargetHeaders = map[string]string{
headerAuthorization: "Bearer secret-token",
}
s.Engine.ExportProcessNewTask( s.Engine.ExportProcessNewTask(
context.TODO(), &task, context.TODO(), &task,
@@ -1221,14 +1246,11 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
// --- HTTP delivery with custom timeout --- // --- HTTP delivery with custom timeout ---
// TestDeliverHTTP_TargetTimeout proves a task's TimeoutSeconds reaches
// the delivery through the engine. The destination answers after two
// seconds, inside the engine's own five-second client timeout, so only
// the task's one second can fail the delivery.
func TestDeliverHTTP_TargetTimeout(t *testing.T) { func TestDeliverHTTP_TargetTimeout(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) db := testWebhookDB(t)
e := iEngine(t, 1)
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
@@ -1240,30 +1262,31 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
event := iSeedEvent( cfg := delivery.HTTPTargetConfig{
t, s.WebhookDB, s.WebhookID, `{"timeout":"test"}`, URL: ts.URL,
) Timeout: 1,
targetID := uuid.New().String() }
d := iSeedDelivery( cfgJSON, err := json.Marshal(cfg)
t, s.WebhookDB, event.ID, targetID, require.NoError(t, err)
database.DeliveryStatusPending,
event, del := iSeedEventAndDelivery(
t, db, `{"timeout":"test"}`,
string(cfgJSON),
) )
bodyStr := event.Body task, d := iHTTPTaskAndDelivery(
task := iTask( event, del, "timeout-target",
d, event, s.WebhookID, targetID, string(cfgJSON), 1, 1,
"timeout-target", ts.URL, 1, 1, &bodyStr,
) )
task.TimeoutSeconds = 1
s.Engine.ExportProcessNewTask(context.TODO(), &task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, db, del.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
iAssertResultFailed(t, s.WebhookDB, d.ID) iAssertResultFailed(t, db, del.ID)
} }
// TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size // TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size
@@ -1301,6 +1324,8 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
)) ))
defer ts.Close() defer ts.Close()
cfgJSON := iHTTPConfig(ts.URL)
event := iSeedEvent( event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`, t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`,
) )
@@ -1314,7 +1339,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
bodyStr := event.Body bodyStr := event.Body
task := iTask( task := iTask(
del, event, s.WebhookID, targetID, del, event, s.WebhookID, targetID,
"cut-target", ts.URL, 1, 1, &bodyStr, "cut-target", cfgJSON, 1, 1, &bodyStr,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -1346,7 +1371,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
func iSeedEventAndDelivery( func iSeedEventAndDelivery(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
body string, body, _ string,
) (database.Event, database.Delivery) { ) (database.Event, database.Delivery) {
t.Helper() t.Helper()
@@ -1377,19 +1402,19 @@ func iSeedEventAndDelivery(
func iHTTPTaskAndDelivery( func iHTTPTaskAndDelivery(
event database.Event, event database.Event,
del database.Delivery, del database.Delivery,
name, targetURL string, name, config string,
maxAttempts, attemptNum int, maxAttempts, attemptNum int,
) (*delivery.Task, *database.Delivery) { ) (*delivery.Task, *database.Delivery) {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: del.ID, DeliveryID: del.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: event.WebhookID, WebhookID: event.WebhookID,
TargetID: del.TargetID, TargetID: del.TargetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
d := &database.Delivery{ d := &database.Delivery{
@@ -1400,7 +1425,7 @@ func iHTTPTaskAndDelivery(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
}, },
} }
@@ -1429,30 +1454,56 @@ func iAssertResultFailed(
assert.NotEmpty(t, result.Error) assert.NotEmpty(t, result.Error)
} }
// --- HTTP request with no URL --- // --- HTTP request with invalid config ---
// TestDeliverHTTP_NoURL proves an http target without a URL, which func TestDeliverHTTP_InvalidConfig(t *testing.T) {
// the forms never store, fails its delivery and records why.
func TestDeliverHTTP_NoURL(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := testWebhookDB(t)
e := iEngine(t, 1) e := iEngine(t, 1)
event, del := iSeedEventAndDelivery(t, db, `{"url":"none"}`) event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(event, del, "no-url", "", 1, 1) task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1,
)
e.ExportDeliverHTTP(context.TODO(), db, d, task) e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID, iAssertStatus(t, db, del.ID,
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
) )
iAssertResultFailed(t, db, del.ID)
} }
// --- notify batching --- // TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1467,7 +1518,7 @@ func TestNotify_MultipleTasks(t *testing.T) {
} }
} }
e.ExportNotify(tasks) e.Notify(tasks)
for i := range 5 { for i := range 5 {
select { select {
+27 -29
View File
@@ -29,7 +29,7 @@ const (
// OnStart before the caller may enqueue work. A worker pool // OnStart before the caller may enqueue work. A worker pool
// wrongly rooted in the already-done hook context has nothing // wrongly rooted in the already-done hook context has nothing
// but ctx.Done() ready in its select, so it is deterministically // but ctx.Done() ready in its select, so it is deterministically
// gone by the end of this window. Without the wait, notify would // gone by the end of this window. Without the wait, Notify would
// race the pool's very first select, in which a ready ctx.Done() // race the pool's very first select, in which a ready ctx.Done()
// and a ready deliveryCh are chosen between at random and a // and a ready deliveryCh are chosen between at random and a
// doomed pool still delivers. // doomed pool still delivers.
@@ -99,7 +99,7 @@ func requireStopHookExpires(
// the caller can drive OnStop too. // the caller can drive OnStop too.
// //
// Callers must not seed pending or retrying deliveries before // Callers must not seed pending or retrying deliveries before
// calling this: the recovery routine's run at start enqueues those, // calling this: restart recovery enqueues those during startup,
// which would put work in the queue while the pool is still // which would put work in the queue while the pool is still
// racing its first select. // racing its first select.
func startEngineViaHook( func startEngineViaHook(
@@ -178,11 +178,11 @@ func TestEngine_WorkersOutliveStartHookContext(t *testing.T) {
_ = lc.hooks[0].OnStop(context.Background()) _ = lc.hooks[0].OnStop(context.Background())
}) })
// Seeded only after the pool has settled, so the recovery // Seeded only after the pool has settled, so restart recovery
// routine's run at start cannot enqueue it. // cannot enqueue it during startup.
deliveryID, task := seedLogTask(t, s) deliveryID, task := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
} }
@@ -202,7 +202,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
// Let the pool prove it is running before stopping it, so a // Let the pool prove it is running before stopping it, so a
// fast OnStop cannot pass by stopping something already dead. // fast OnStop cannot pass by stopping something already dead.
firstID, firstTask := seedLogTask(t, s) firstID, firstTask := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{firstTask}) s.Engine.Notify([]delivery.Task{firstTask})
iWaitForDelivered(t, s.WebhookDB, firstID) iWaitForDelivered(t, s.WebhookDB, firstID)
var stopErr error var stopErr error
@@ -232,7 +232,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
// With every worker gone, a freshly notified task must sit // With every worker gone, a freshly notified task must sit
// untouched in the queue rather than being delivered. // untouched in the queue rather than being delivered.
secondID, secondTask := seedLogTask(t, s) secondID, secondTask := seedLogTask(t, s)
s.Engine.ExportNotify([]delivery.Task{secondTask}) s.Engine.Notify([]delivery.Task{secondTask})
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
@@ -309,12 +309,13 @@ func seedHeldDelivery(
)) ))
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String() targetID := uuid.New().String()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held", t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, ts.URL, maxAttempts, database.TargetTypeHTTP, cfg, maxAttempts,
) )
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
@@ -323,7 +324,7 @@ func seedHeldDelivery(
bodyStr := event.Body bodyStr := event.Body
return iTask( return iTask(
d, event, s.WebhookID, targetID, "held", ts.URL, d, event, s.WebhookID, targetID, "held", cfg,
maxAttempts, attemptNum, &bodyStr, maxAttempts, attemptNum, &bodyStr,
), arrived ), arrived
} }
@@ -344,8 +345,8 @@ func stopDuringAttempt(
require.NoError(t, lc.hooks[0].OnStop(context.Background())) require.NoError(t, lc.hooks[0].OnStop(context.Background()))
} }
// requireRequeued runs the recovery routine as at start on the stopped // requireRequeued runs restart recovery on the stopped engine and
// engine and requires it to queue task's attempt again on ch. // requires it to queue task's attempt again on ch.
func requireRequeued( func requireRequeued(
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task, t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
) { ) {
@@ -360,7 +361,7 @@ func requireRequeued(
assert.Equal(t, task.DeliveryID, got.DeliveryID) assert.Equal(t, task.DeliveryID, got.DeliveryID)
assert.Equal(t, task.AttemptNum, got.AttemptNum) assert.Equal(t, task.AttemptNum, got.AttemptNum)
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("recovery at start did not queue the delivery again") t.Fatal("restart recovery did not queue the delivery again")
} }
} }
@@ -370,8 +371,7 @@ func requireRequeued(
// no retries that was the delivery's only attempt, recorded as failed // no retries that was the delivery's only attempt, recorded as failed
// with "context canceled", and nothing ever sent it again. The // with "context canceled", and nothing ever sent it again. The
// delivery must instead stay pending, with no result and no count in // delivery must instead stay pending, with no result and no count in
// the attempt metrics, for the recovery routine to send at the next // the attempt metrics, for restart recovery to send.
// start.
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
t.Parallel() t.Parallel()
@@ -383,7 +383,7 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
t, s, 1, database.DeliveryStatusPending, 1, t, s, 1, database.DeliveryStatusPending, 1,
) )
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
stopDuringAttempt(t, lc, arrived) stopDuringAttempt(t, lc, arrived)
iAssertStatus( iAssertStatus(
@@ -399,8 +399,8 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression // TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
// for a target with retries, on its last attempt: the cancelled attempt // for a target with retries, on its last attempt: the cancelled attempt
// used to exhaust the retries and end the delivery failed. It must stay // used to exhaust the retries and end the delivery failed. It must stay
// retrying with only its earlier attempt recorded, and the recovery // retrying with only its earlier attempt recorded, and restart recovery
// routine at the next start must send that last attempt again. // must send that last attempt again.
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel() t.Parallel()
@@ -427,9 +427,9 @@ func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
// deliverToArchive gives the setup's webhook a database target, // deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns // runs one delivery to it through the running engine, and returns
// the target's archive file path. The archive writer holds the file // the target's ID and archive file path. The archive writer holds
// open afterwards. // the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) string { func deliverToArchive(t *testing.T, s iSetup) (string, string) {
t.Helper() t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
@@ -448,11 +448,11 @@ func deliverToArchive(t *testing.T, s iSetup) string {
task.TargetID = tgt.ID task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase task.TargetType = database.TargetTypeDatabase
s.Engine.ExportNotify([]delivery.Task{task}) s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID) iWaitForDelivered(t, s.WebhookDB, deliveryID)
return filepath.Join( return tgt.ID, filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
"archive-hook-archive-"+tgt.ID+".db", "archive-hook-archive-"+tgt.ID+".db",
) )
@@ -471,7 +471,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s) _, path := deliverToArchive(t, s)
require.FileExists( require.FileExists(
t, path+"-wal", t, path+"-wal",
"an open archive should have a -wal for the stop to remove", "an open archive should have a -wal for the stop to remove",
@@ -505,23 +505,21 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
lc := startEngineViaHook(t, s.Engine) lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s) targetID, _ := deliverToArchive(t, s)
release := make(chan struct{}) release := make(chan struct{})
t.Cleanup(func() { t.Cleanup(func() {
close(release) close(release)
s.Engine.ExportArchives().EvictWebhook(s.WebhookID) s.Engine.EvictWebhook(s.WebhookID)
}) })
s.Engine.ExportWedgeWorker(release) s.Engine.ExportWedgeWorker(release)
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
// Closing the last handle on an archive removes its -wal, so a require.True(
// -wal still there is a writer still open. t, s.Engine.ExportArchiveHandleOpen(targetID),
require.FileExists(
t, path+"-wal",
"a stop that timed out must not close archive writers", "a stop that timed out must not close archive writers",
) )
} }
+211 -59
View File
@@ -65,6 +65,20 @@ func testEngine(
) )
} }
func newHTTPTargetConfig(url string) string {
cfg := delivery.HTTPTargetConfig{URL: url}
data, err := json.Marshal(cfg)
if err != nil {
panic(
"failed to marshal HTTPTargetConfig: " +
err.Error(),
)
}
return string(data)
}
func seedEvent( func seedEvent(
t *testing.T, db *gorm.DB, body string, t *testing.T, db *gorm.DB, body string,
) database.Event { ) database.Event {
@@ -127,19 +141,19 @@ type httpDeliveryFixture struct {
func buildHTTPFixture( func buildHTTPFixture(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, targetURL string, targetID, name, config string,
maxAttempts, attemptNum int, maxAttempts, attemptNum int,
) httpDeliveryFixture { ) httpDeliveryFixture {
task := &delivery.Task{ task := &delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: event.WebhookID, WebhookID: event.WebhookID,
TargetID: targetID, TargetID: targetID,
TargetName: name, TargetName: name,
TargetType: database.TargetTypeHTTP, TargetType: database.TargetTypeHTTP,
TargetURL: targetURL, TargetConfig: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
AttemptNum: attemptNum, AttemptNum: attemptNum,
} }
d := &database.Delivery{ d := &database.Delivery{
@@ -150,7 +164,7 @@ func buildHTTPFixture(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: config,
MaxAttempts: maxAttempts, MaxAttempts: maxAttempts,
}, },
} }
@@ -218,7 +232,7 @@ func TestNotify_NonBlocking(t *testing.T) {
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
e.ExportNotify([]delivery.Task{ e.Notify([]delivery.Task{
{DeliveryID: "overflow-1"}, {DeliveryID: "overflow-1"},
{DeliveryID: "overflow-2"}, {DeliveryID: "overflow-2"},
}) })
@@ -229,7 +243,7 @@ func TestNotify_NonBlocking(t *testing.T) {
case <-done: case <-done:
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal( t.Fatal(
"notify blocked when delivery channel was full", "Notify blocked when delivery channel was full",
) )
} }
} }
@@ -261,8 +275,9 @@ func TestDeliverHTTP_Success(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, "test-http", ts.URL, 1, 1, dlv, event, targetID, "test-http", cfg, 1, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -306,9 +321,10 @@ func TestDeliverHTTP_Failure(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-fail", ts.URL, 1, 1, "test-http-fail", cfg, 1, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -335,7 +351,7 @@ func TestDeliverDatabase_ImmediateSuccess(
// The database target archives for real, so the engine needs // The database target archives for real, so the engine needs
// the target in the main database and a data directory. // the target in the main database and a data directory.
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t) tgt := env.seedDatabaseTarget(t, "")
event := seedEvent(t, db, `{"db":"target"}`) event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, tgt) d := seedDatabaseTargetDelivery(t, db, event, tgt)
@@ -436,9 +452,10 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-retry", ts.URL, 5, 1, "test-http-retry", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -493,12 +510,13 @@ func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
sched := &recordingScheduler{} sched := &recordingScheduler{}
for attempt := 1; attempt <= maxAttempts; attempt++ { for attempt := 1; attempt <= maxAttempts; attempt++ {
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-exhaust", ts.URL, maxAttempts, attempt, "test-http-exhaust", cfg, maxAttempts, attempt,
) )
e.ExportDeliverHTTPWithScheduler( e.ExportDeliverHTTPWithScheduler(
@@ -556,9 +574,10 @@ func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-single", ts.URL, 1, 1, "test-http-single", cfg, 1, 1,
) )
sched := &recordingScheduler{} sched := &recordingScheduler{}
@@ -605,9 +624,10 @@ func TestDeliverHTTP_SchedulesRetryOnFailure(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(ts.URL)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-http-schedule", ts.URL, 5, 1, "test-http-schedule", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -769,7 +789,7 @@ func seedConcurrencyTasks(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
n int, n int,
targetURL string, targetCfg string,
) ([]database.Delivery, []delivery.Task) { ) ([]database.Delivery, []delivery.Task) {
t.Helper() t.Helper()
@@ -795,21 +815,21 @@ func seedConcurrencyTasks(
Target: database.Target{ Target: database.Target{
Name: fmt.Sprintf("task-%d", i), Name: fmt.Sprintf("task-%d", i),
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: targetURL, Config: targetCfg,
MaxAttempts: 1, MaxAttempts: 1,
}, },
} }
tasks[i].ID = dlv.ID tasks[i].ID = dlv.ID
dtasks[i] = delivery.Task{ dtasks[i] = delivery.Task{
DeliveryID: tasks[i].ID, DeliveryID: tasks[i].ID,
EventID: tasks[i].EventID, EventID: tasks[i].EventID,
TargetID: tasks[i].TargetID, TargetID: tasks[i].TargetID,
TargetName: tasks[i].Target.Name, TargetName: tasks[i].Target.Name,
TargetType: tasks[i].Target.Type, TargetType: tasks[i].Target.Type,
TargetURL: tasks[i].Target.URL, TargetConfig: tasks[i].Target.Config,
MaxAttempts: 1, MaxAttempts: 1,
AttemptNum: 1, AttemptNum: 1,
} }
} }
@@ -845,8 +865,9 @@ func TestWorkerPool_BoundedConcurrency(t *testing.T) {
defer ts.Close() defer ts.Close()
e := testEngine(t, numWorkers) e := testEngine(t, numWorkers)
cfg := newHTTPTargetConfig(ts.URL)
tasks, dtasks := seedConcurrencyTasks( tasks, dtasks := seedConcurrencyTasks(
t, db, numTasks, ts.URL, t, db, numTasks, cfg,
) )
runConcurrencyWorkers( runConcurrencyWorkers(
@@ -927,9 +948,12 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg := newHTTPTargetConfig(
"http://will-not-be-called.invalid",
)
fix := buildHTTPFixture( fix := buildHTTPFixture(
dlv, event, targetID, dlv, event, targetID,
"test-cb-block", "http://will-not-be-called.invalid", 5, 1, "test-cb-block", cfg, 5, 1,
) )
e.ExportDeliverHTTP( e.ExportDeliverHTTP(
@@ -992,8 +1016,9 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
require.True(t, cb.Allow(), "the probe delivery should go through") require.True(t, cb.Allow(), "the probe delivery should go through")
require.Equal(t, delivery.CircuitHalfOpen, cb.State()) require.Equal(t, delivery.CircuitHalfOpen, cb.State())
const targetURL = "http://will-not-be-called.invalid" cfg := newHTTPTargetConfig(
"http://will-not-be-called.invalid",
)
sched := &recordingScheduler{} sched := &recordingScheduler{}
const queued, passes = 3, 4 const queued, passes = 3, 4
@@ -1015,7 +1040,7 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
fix := buildHTTPFixture( fix := buildHTTPFixture(
row, event, targetID, row, event, targetID,
"test-cb-half-open", targetURL, 5, 1, "test-cb-half-open", cfg, 5, 1,
) )
e.ExportDeliverHTTPWithScheduler( e.ExportDeliverHTTPWithScheduler(
@@ -1130,6 +1155,62 @@ func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
assert.Zero(t, cooldown, "half-open") assert.Zero(t, cooldown, "half-open")
} }
func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook",` +
`"headers":{"X-Token":"secret"}}`,
)
require.NoError(t, err)
assert.Equal(t, "https://example.com/hook", cfg.URL)
assert.Equal(t, "secret", cfg.Headers["X-Token"])
}
func TestParseHTTPConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig("")
assert.Error(t, err,
"empty config should return error",
)
}
func TestParseHTTPConfig_MissingURL(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"headers":{"X-Token":"secret"}}`,
)
assert.Error(t, err,
"config without URL should return error",
)
}
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1277,7 +1358,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
target := &database.Target{ cfg := &delivery.HTTPTargetConfig{
URL: ts.URL, URL: ts.URL,
Headers: map[string]string{ Headers: map[string]string{
"X-Target-Auth": "bearer xyz", "X-Target-Auth": "bearer xyz",
@@ -1292,7 +1373,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
} }
statusCode, _, _, err := e.ExportDoHTTPRequest( statusCode, _, _, err := e.ExportDoHTTPRequest(
context.TODO(), target, event, context.TODO(), cfg, event,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -1334,7 +1415,7 @@ func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
_, body, _, err := e.ExportDoHTTPRequest( _, body, _, err := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(),
&database.Target{URL: ts.URL}, &delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{}, &database.Event{},
) )
@@ -1386,9 +1467,9 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
var configured map[string]string cfg := &delivery.HTTPTargetConfig{}
if tc.configured != "" { if tc.configured != "" {
configured = map[string]string{ cfg.Headers = map[string]string{
headerContentType: tc.configured, headerContentType: tc.configured,
} }
} }
@@ -1407,7 +1488,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
Headers: string(inbound), Headers: string(inbound),
ContentType: tc.event, ContentType: tc.event,
}, },
configured, cfg,
"webhooker/dev", "webhooker/dev",
) )
@@ -1438,7 +1519,7 @@ func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
&database.Event{ &database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`, Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
}, },
nil, &delivery.HTTPTargetConfig{},
"webhooker/dev", "webhooker/dev",
) )
@@ -1456,7 +1537,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
// The database target archives for real, so the engine needs // The database target archives for real, so the engine needs
// the target in the main database and a data directory. // the target in the main database and a data directory.
env := setupArchiveTest(t) env := setupArchiveTest(t)
archive := env.seedDatabaseTarget(t) archive := env.seedDatabaseTarget(t, "")
tests := []struct { tests := []struct {
name string name string
@@ -1541,6 +1622,51 @@ func TestMaxInlineBodySize_Constant(t *testing.T) {
) )
} }
func TestParseSlackConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.ExportParseSlackConfig(
`{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}`,
)
require.NoError(t, err)
assert.Equal(t,
"https://hooks.slack.com/services/T00/B00/xxx",
cfg.WebhookURL,
)
}
func TestParseSlackConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseSlackConfig("")
assert.Error(t, err,
"empty config should return error",
)
}
func TestParseSlackConfig_MissingWebhookURL(
t *testing.T,
) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseSlackConfig(
`{"other":"field"}`,
)
assert.Error(t, err,
"config without webhook_url should return error",
)
}
func TestFormatSlackMessage_JSONBody(t *testing.T) { func TestFormatSlackMessage_JSONBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1637,11 +1763,11 @@ func TestFormatSlackMessage_LargeJSONTruncated(
} }
// buildSlackDelivery creates a database.Delivery // buildSlackDelivery creates a database.Delivery
// to a Slack target with the given webhook URL for testing. // with a Slack target config for testing.
func buildSlackDelivery( func buildSlackDelivery(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, name, webhookURL string, targetID, name, slackCfg string,
) *database.Delivery { ) *database.Delivery {
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
@@ -1651,7 +1777,7 @@ func buildSlackDelivery(
Target: database.Target{ Target: database.Target{
Name: name, Name: name,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: slackCfg,
MaxAttempts: 1, MaxAttempts: 1,
}, },
} }
@@ -1689,10 +1815,11 @@ func TestDeliverSlack_Success(t *testing.T) {
) )
defer ts.Close() defer ts.Close()
e, targetID, event, dlv := setupSlackTest(t, db) e, targetID, slackCfg, event, dlv :=
setupSlackTest(t, db, ts.URL)
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, "test-slack", ts.URL, dlv, event, targetID, "test-slack", slackCfg,
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -1711,8 +1838,9 @@ func TestDeliverSlack_Success(t *testing.T) {
func setupSlackTest( func setupSlackTest(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
serverURL string,
) ( ) (
*delivery.Engine, string, *delivery.Engine, string, string,
database.Event, database.Delivery, database.Event, database.Delivery,
) { ) {
t.Helper() t.Helper()
@@ -1720,6 +1848,13 @@ func setupSlackTest(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
cfgBytes, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: serverURL,
},
)
require.NoError(t, err)
event := seedEvent( event := seedEvent(
t, db, `{"action":"test","data":"value"}`, t, db, `{"action":"test","data":"value"}`,
) )
@@ -1729,7 +1864,7 @@ func setupSlackTest(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
return e, targetID, event, dlv return e, targetID, string(cfgBytes), event, dlv
} }
func assertSlackPayload( func assertSlackPayload(
@@ -1778,6 +1913,13 @@ func TestDeliverSlack_Failure(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: ts.URL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"test":true}`) event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -1787,7 +1929,7 @@ func TestDeliverSlack_Failure(t *testing.T) {
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, dlv, event, targetID,
"test-slack-fail", ts.URL, "test-slack-fail", string(slackCfg),
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -1801,10 +1943,7 @@ func TestDeliverSlack_Failure(t *testing.T) {
) )
} }
// TestDeliverSlack_NoURL proves a Slack target without a URL, which func TestDeliverSlack_InvalidConfig(t *testing.T) {
// the forms never store, fails its delivery rather than reporting it
// delivered.
func TestDeliverSlack_NoURL(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := testWebhookDB(t)
@@ -1825,6 +1964,7 @@ func TestDeliverSlack_NoURL(t *testing.T) {
Target: database.Target{ Target: database.Target{
Name: "test-slack-bad", Name: "test-slack-bad",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Config: `{"not_webhook_url":"missing"}`,
MaxAttempts: 1, MaxAttempts: 1,
}, },
} }
@@ -1862,6 +2002,13 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: ts.URL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"route":"slack"}`) event := seedEvent(t, db, `{"route":"slack"}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -1871,7 +2018,7 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, dlv.TargetID, dlv, event, dlv.TargetID,
"test-slack-route", ts.URL, "test-slack-route", string(slackCfg),
) )
task := &delivery.Task{ task := &delivery.Task{
@@ -1986,7 +2133,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
func buildSlackRetryDelivery( func buildSlackRetryDelivery(
dlv database.Delivery, dlv database.Delivery,
event database.Event, event database.Event,
targetID, webhookURL string, targetID, cfg string,
) *database.Delivery { ) *database.Delivery {
d := &database.Delivery{ d := &database.Delivery{
EventID: event.ID, EventID: event.ID,
@@ -1996,7 +2143,7 @@ func buildSlackRetryDelivery(
Target: database.Target{ Target: database.Target{
Name: "test-slack-retry", Name: "test-slack-retry",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: cfg,
MaxAttempts: 5, MaxAttempts: 5,
}, },
} }
@@ -2015,6 +2162,11 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"slack":"retry"}`) event := seedEvent(t, db, `{"slack":"retry"}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -2023,7 +2175,7 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
) )
d := buildSlackRetryDelivery( d := buildSlackRetryDelivery(
dlv, event, targetID, ts.URL, dlv, event, targetID, string(slackCfg),
) )
task := &delivery.Task{ task := &delivery.Task{
+6 -4
View File
@@ -69,6 +69,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
)) ))
defer ts.Close() defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String() targetID := uuid.New().String()
dbPath := s.DBMgr.DBPath(s.WebhookID) dbPath := s.DBMgr.DBPath(s.WebhookID)
@@ -84,7 +85,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 5, 1, &body, "leaky-target", cfg, 5, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -101,7 +102,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
// https://git.eeqj.de/sneak/webhooker/issues/107. // https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "leaky-target", t, s.MainDB, targetID, s.WebhookID, "leaky-target",
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
rd := iSeedDelivery( rd := iSeedDelivery(
@@ -110,7 +111,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
) )
rTask := iTask( rTask := iTask(
rd, event, s.WebhookID, targetID, rd, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 5, 2, &body, "leaky-target", cfg, 5, 2, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &rTask) s.Engine.ExportProcessRetryTask(context.TODO(), &rTask)
@@ -137,6 +138,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
)) ))
defer ts.Close() defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String() targetID := uuid.New().String()
event := iSeedEvent( event := iSeedEvent(
@@ -150,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
) )
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"leaky-target", ts.URL, 1, 1, &body, "leaky-target", cfg, 1, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
+20 -6
View File
@@ -93,6 +93,17 @@ func (s *tsSink) text(t *testing.T) string {
} }
} }
func tsSlackConfig(t *testing.T, url string) string {
t.Helper()
data, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: url},
)
require.NoError(t, err)
return string(data)
}
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt // tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
// rather than the write time, so an assertion on the rendered // rather than the write time, so an assertion on the rendered
// timestamp cannot pass by accident against "roughly now". // timestamp cannot pass by accident against "roughly now".
@@ -131,7 +142,7 @@ func tsSeedEvent(
// tsSeedTarget writes the slack target row into the main database. // tsSeedTarget writes the slack target row into the main database.
// The retry path confirms the target still exists before sending. // The retry path confirms the target still exists before sending.
func tsSeedTarget( func tsSeedTarget(
t *testing.T, mainDB *gorm.DB, webhookID, webhookURL string, t *testing.T, mainDB *gorm.DB, webhookID, config string,
) database.Target { ) database.Target {
t.Helper() t.Helper()
@@ -139,7 +150,7 @@ func tsSeedTarget(
WebhookID: webhookID, WebhookID: webhookID,
Name: "slack-sink", Name: "slack-sink",
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: webhookURL, Config: config,
Active: true, Active: true,
MaxAttempts: 1, MaxAttempts: 1,
} }
@@ -165,7 +176,7 @@ func tsTask(
TargetID: target.ID, TargetID: target.ID,
TargetName: target.Name, TargetName: target.Name,
TargetType: database.TargetTypeSlack, TargetType: database.TargetTypeSlack,
TargetURL: target.URL, TargetConfig: target.Config,
MaxAttempts: 1, MaxAttempts: 1,
Method: event.Method, Method: event.Method,
Headers: event.Headers, Headers: event.Headers,
@@ -215,7 +226,8 @@ func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
s := newISetup(t) s := newISetup(t)
sink := newTSSink(t) sink := newTSSink(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, sink.URL) cfg := tsSlackConfig(t, sink.URL)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
@@ -326,7 +338,8 @@ func TestFormatSlackMessageOverTaskReconstructedEvent(
s := newISetup(t) s := newISetup(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
@@ -397,7 +410,8 @@ func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
s := newISetup(t) s := newISetup(t)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery( d := iSeedDelivery(
+301 -31
View File
@@ -12,11 +12,16 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
) )
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
// an evicted archive writer. It carries the Err prefix rather
// than this file's usual Export one because it is a sentinel
// error.
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
// Exported constants for test access. // Exported constants for test access.
const ( const (
ExportDeliveryChannelSize = deliveryChannelSize ExportDeliveryChannelSize = deliveryChannelSize
@@ -77,10 +82,10 @@ func ExportIsForwardableHeader(name string) bool {
func ExportApplyRequestHeaders( func ExportApplyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
configured map[string]string, cfg *HTTPTargetConfig,
userAgent string, userAgent string,
) []string { ) []string {
return applyRequestHeaders(req, event, configured, userAgent) return applyRequestHeaders(req, event, cfg, userAgent)
} }
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
@@ -158,11 +163,6 @@ func (e *Engine) ExportUpdateDeliveryStatus(
return e.updateDeliveryStatus(webhookDB, d, "", status) return e.updateDeliveryStatus(webhookDB, d, "", status)
} }
// ExportNotify exposes notify.
func (e *Engine) ExportNotify(tasks []Task) {
e.notify(tasks)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
@@ -221,23 +221,37 @@ func (e *Engine) ExportSetSlackCircuitBreaker(
e.slackTarget.circuitBreakers.Store(targetID, cb) e.slackTarget.circuitBreakers.Store(targetID, cb)
} }
// ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig(
configJSON string,
) (*HTTPTargetConfig, error) {
return parseHTTPConfig(configJSON)
}
// ExportParseSlackConfig exposes parseSlackConfig.
func (e *Engine) ExportParseSlackConfig(
configJSON string,
) (*SlackTargetConfig, error) {
return parseSlackConfig(configJSON)
}
// ExportDoHTTPRequest exposes the http target's // ExportDoHTTPRequest exposes the http target's
// doHTTPRequest. // doHTTPRequest.
func (e *Engine) ExportDoHTTPRequest( func (e *Engine) ExportDoHTTPRequest(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) (int, string, int64, error) { ) (int, string, int64, error) {
return e.httpTarget.doHTTPRequest(ctx, target, event) return e.httpTarget.doHTTPRequest(ctx, cfg, event)
} }
// ExportClientForRequest exposes the http target's // ExportClientForRequest exposes the http target's
// clientForRequest. // clientForRequest.
func (e *Engine) ExportClientForRequest( func (e *Engine) ExportClientForRequest(
timeoutSeconds int, cfg *HTTPTargetConfig,
originScoped []string, originScoped []string,
) *http.Client { ) *http.Client {
return e.httpTarget.clientForRequest(timeoutSeconds, originScoped) return e.httpTarget.clientForRequest(cfg, originScoped)
} }
// ErrExportTooManyRedirects exposes the sentinel the redirect // ErrExportTooManyRedirects exposes the sentinel the redirect
@@ -380,8 +394,7 @@ func (e *Engine) ExportRetryCh() chan Task {
} }
// NewTestEngine creates an Engine for unit tests without // NewTestEngine creates an Engine for unit tests without
// database dependencies. Its archive writers have no data // database dependencies.
// directory, so a delivery to a database target fails.
func NewTestEngine( func NewTestEngine(
log *slog.Logger, log *slog.Logger,
client *http.Client, client *http.Client,
@@ -393,7 +406,6 @@ func NewTestEngine(
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
} }
e.initTargets(client) e.initTargets(client)
@@ -406,10 +418,9 @@ func NewTestEngineSmallRetry(
log *slog.Logger, log *slog.Logger,
) *Engine { ) *Engine {
e := &Engine{ e := &Engine{
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -417,8 +428,7 @@ func NewTestEngineSmallRetry(
} }
// NewTestEngineWithDB creates an Engine with a real // NewTestEngineWithDB creates an Engine with a real
// database and dbManager for integration tests, and archive // database and dbManager for integration tests.
// writers of its own over the same two.
func NewTestEngineWithDB( func NewTestEngineWithDB(
db *database.Database, db *database.Database,
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
@@ -434,22 +444,12 @@ func NewTestEngineWithDB(
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()), mtr: metrics.New(prometheus.NewRegistry()),
archives: archive.NewWriters(archive.WritersParams{
Database: db,
DBManager: dbMgr,
Logger: log,
}),
} }
e.initTargets(client) e.initTargets(client)
return e return e
} }
// ExportArchives returns the engine's archive writers.
func (e *Engine) ExportArchives() *archive.Writers {
return e.archives
}
// ExportSetMetrics substitutes the engine's metric set, so a test can // ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a registry it holds. // assert on collectors registered on a registry it holds.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
@@ -472,3 +472,273 @@ func NewTestCircuitBreaker(
cooldown: cooldown, cooldown: cooldown,
} }
} }
// ExportArchivedEvent aliases the archive row type so black-box
// tests can construct and read archive rows.
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
// NewExportArchiveWriter builds an archive writer for tests,
// optionally overriding the reopen debounce (a non-positive
// debounce keeps the production default).
func NewExportArchiveWriter(
path string, log *slog.Logger, debounce time.Duration,
) *ExportArchiveWriter {
w := newArchiveWriter(path, log)
if debounce > 0 {
w.debounce = debounce
}
return &ExportArchiveWriter{w: w}
}
// Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration,
) error {
return e.w.write(row, expiry, "")
}
// WritePeriod archives a row through the writer, into the file for
// period.
func (e *ExportArchiveWriter) WritePeriod(
row ExportArchivedEvent, expiry time.Duration, period string,
) error {
return e.w.write(row, expiry, period)
}
// Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry)
}
// Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen(
expiry time.Duration,
) error {
return e.w.reopen(e.w.path, expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
}
// DB returns the writer's current open handle for row
// inspection in tests.
func (e *ExportArchiveWriter) DB() *gorm.DB {
return e.w.db
}
// Path returns the archive file the writer owns.
func (e *ExportArchiveWriter) Path() string {
return e.w.path
}
// OpenExisting opens the archive without permitting creation,
// the way the idle sweep does.
func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration,
) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
}
// SweepExpired runs an idle sweep of the archive.
func (e *ExportArchiveWriter) SweepExpired(
expiry time.Duration,
) error {
return e.w.sweepExpired(expiry)
}
// Evict marks the writer evicted and closes its handle, exactly
// as leaving the registry does.
func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
e.w.mu.Lock()
defer e.w.mu.Unlock()
return e.w.db != nil
}
// Same reports whether both wrappers refer to the very same
// underlying archive writer, so a test can prove a registry entry
// is the writer it was handed rather than a replacement.
func (e *ExportArchiveWriter) Same(
other *ExportArchiveWriter,
) bool {
return other != nil && e.w == other.w
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor(
targetID string,
) *ExportArchiveWriter {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID]
if !ok {
return nil
}
return &ExportArchiveWriter{w: w}
}
// ExportHasArchiveWriter reports whether the database target
// type currently caches an archive writer for a target.
func (e *Engine) ExportHasArchiveWriter(
targetID string,
) bool {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen(
targetID string,
) bool {
e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID]
e.dbTarget.mu.Unlock()
if !ok {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
return w.db != nil
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter(
targetID string,
) (string, error) {
w, err := e.dbTarget.writerFor(targetID)
if err != nil {
return "", err
}
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor(
targetID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID)
if err != nil {
return nil, false, err
}
return &ExportArchiveWriter{w: w}, created, nil
}
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
) {
e.dbTarget.releaseSweepWriter(targetID, w.w)
}
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
// given main database and engine, without the fx lifecycle.
// Intended for tests.
func NewTestArchiveSweeper(
db *database.Database,
eng *Engine,
log *slog.Logger,
) *ArchiveSweeper {
return &ArchiveSweeper{
db: db,
eng: eng,
log: log,
interval: time.Hour,
}
}
// ExportSweep runs a single archive sweep synchronously for
// tests.
func (s *ArchiveSweeper) ExportSweep(ctx context.Context) {
s.sweep(ctx)
}
// ExportStart starts the sweeper's background loop for tests.
func (s *ArchiveSweeper) ExportStart() {
s.start()
}
// ExportRegisterHooks registers the sweeper's real fx lifecycle
// hooks on a lifecycle supplied by a test, so a test can drive
// the exact OnStart/OnStop functions the application runs and
// hand OnStart the kind of context fx actually supplies.
func (s *ArchiveSweeper) ExportRegisterHooks(lc fx.Lifecycle) {
s.registerHooks(lc)
}
// ExportStop stops the sweeper's background loop for tests.
func (s *ArchiveSweeper) ExportStop(ctx context.Context) error {
return s.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
// that never observes cancellation and returns only when release
// is closed. It stands in for a prune stuck on a locked archive.
func (s *ArchiveSweeper) ExportWedgeLoop(
release <-chan struct{},
) {
s.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (s *ArchiveSweeper) ExportSetInterval(d time.Duration) {
s.interval = d
}
// ExportParseArchiveExpiry exposes parseArchiveExpiry.
func ExportParseArchiveExpiry(
configJSON string,
) (time.Duration, error) {
return parseArchiveExpiry(configJSON)
}
+3 -3
View File
@@ -25,8 +25,8 @@ import "sync"
// In-memory state is sufficient because a data directory admits one // In-memory state is sufficient because a data directory admits one
// process: internal/datadir takes an flock on it at startup and a // process: internal/datadir takes an flock on it at startup and a
// second instance refuses to run. Deliveries owned by a process that // second instance refuses to run. Deliveries owned by a process that
// died are not in any successor's set, and the recovery routine's run // died are not in any successor's set, and restart recovery is what
// at start is what picks those up. // picks those up.
// //
// References are counted rather than held as a plain set because // References are counted rather than held as a plain set because
// ownership outlives the worker that took it. A target that schedules // ownership outlives the worker that took it. A target that schedules
@@ -61,7 +61,7 @@ func (s *inflightSet) retain(deliveryID string) {
// retainIdle takes the first reference on a delivery, and reports // retainIdle takes the first reference on a delivery, and reports
// whether it got it. It fails when the engine already owns the // whether it got it. It fails when the engine already owns the
// delivery, which is what makes two claimants — notify and the // delivery, which is what makes two claimants — Notify and the
// recovery routine, say — mutually exclusive rather than merely // recovery routine, say — mutually exclusive rather than merely
// atomic. // atomic.
func (s *inflightSet) retainIdle(deliveryID string) bool { func (s *inflightSet) retainIdle(deliveryID string) bool {
+40 -13
View File
@@ -2,6 +2,8 @@ package delivery_test
import ( import (
"context" "context"
"path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -64,6 +66,33 @@ func fDrain(e *delivery.Engine) []delivery.Task {
} }
} }
// TestArchiveHandleIsWAL closes the last gap in the durability
// evidence: the main and per-webhook tiers each assert their journal
// mode on a live handle, and the archive tier gets its settings from
// the same code path but nothing checked the running file.
func TestArchiveHandleIsWAL(t *testing.T) {
t.Parallel()
w := delivery.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive-wal.db"),
archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
var mode string
row := w.DB().Raw("pragma journal_mode").Row()
require.NoError(t, row.Scan(&mode))
assert.Equal(t, "wal", strings.ToLower(mode))
var busy string
row = w.DB().Raw("pragma busy_timeout").Row()
require.NoError(t, row.Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot // TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot
// see. The delivery is queued and untouched, so its row is arbitrarily // see. The delivery is queued and untouched, so its row is arbitrarily
// old and still perfectly healthy; only ownership distinguishes it // old and still perfectly healthy; only ownership distinguishes it
@@ -84,9 +113,9 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
) )
rAgePending(t, s.WebhookDB, d.ID) rAgePending(t, s.WebhookDB, d.ID)
// Queued exactly as StoreEvent queues it, and never dequeued: // Queued exactly as the receiver queues it, and never dequeued:
// no workers are running in this engine. // no workers are running in this engine.
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -109,7 +138,7 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
// TestRecoveryAndSweepDoNotDoubleDispatch drives the recovery routine's // TestRecoveryAndSweepDoNotDoubleDispatch drives the recovery routine's
// run at start and a tick of the sweep at once against one aged pending // run at start and a tick of the sweep at once against one aged pending
// row. The engine runs them one after the other, but notify can race // row. The engine runs them one after the other, but Notify can race
// either, and ownership is what keeps any two claimants from both // either, and ownership is what keeps any two claimants from both
// sending. Before ownership the two runs both dispatched the row. // sending. Before ownership the two runs both dispatched the row.
func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) { func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
@@ -224,7 +253,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
body := `{"released":true}` body := `{"released":true}`
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -248,11 +277,10 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
} }
// TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of // TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of
// https://git.eeqj.de/sneak/webhooker/issues/299. StoreEvent has // https://git.eeqj.de/sneak/webhooker/issues/299. The receiver has
// written a delivery, the recovery routine's run at start finds it // written a delivery, restart recovery finds it pending, sends it and
// pending, sends it and releases it, and only then does StoreEvent's // releases it, and only then does the receiver's Notify for it arrive.
// notify for it arrive. Nothing owns the delivery by then, so notify // Nothing owns the delivery by then, so Notify takes it.
// takes it.
func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) { func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -276,8 +304,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
) )
}() }()
// The recovery routine's run at start sends the delivery and lets // Restart recovery sends the delivery and lets it go.
// it go.
iWaitForDelivered(t, s.WebhookDB, d.ID) iWaitForDelivered(t, s.WebhookDB, d.ID)
require.Eventually( require.Eventually(
t, t,
@@ -289,7 +316,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
body := event.Body body := event.Body
s.Engine.ExportNotify([]delivery.Task{{ s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID, DeliveryID: d.ID,
EventID: event.ID, EventID: event.ID,
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
@@ -300,7 +327,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
EntrypointID: event.EntrypointID, EntrypointID: event.EntrypointID,
}}) }})
// notify took the delivery, and a worker releases it once it has // Notify took the delivery, and a worker releases it once it has
// run the task. // run the task.
require.Eventually( require.Eventually(
t, t,
+15 -12
View File
@@ -192,7 +192,7 @@ func mDeliverOK(t *testing.T, s iSetup) {
body := event.Body body := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-ok", ts.URL, 3, 1, &body, "metrics-ok", iHTTPConfig(ts.URL), 3, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -226,17 +226,18 @@ func mExhaustRetries(t *testing.T, s iSetup) {
) )
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL)
// The retry below is only run if its target still exists; see // The retry below is only run if its target still exists; see
// https://git.eeqj.de/sneak/webhooker/issues/107. // https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-fail", t, s.MainDB, targetID, s.WebhookID, "metrics-fail",
database.TargetTypeHTTP, ts.URL, 2, database.TargetTypeHTTP, cfg, 2,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-fail", ts.URL, 2, 1, &body, "metrics-fail", cfg, 2, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -250,7 +251,7 @@ func mExhaustRetries(t *testing.T, s iSetup) {
// deterministic and off the wall clock. // deterministic and off the wall clock.
second := iTask( second := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-fail", ts.URL, 2, 2, &body, "metrics-fail", cfg, 2, 2, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -288,6 +289,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
) )
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL)
// An attempt budget above the failure threshold, so the breaker // An attempt budget above the failure threshold, so the breaker
// rather than the budget is what stops the delivery. // rather than the budget is what stops the delivery.
@@ -297,12 +299,12 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
// see https://git.eeqj.de/sneak/webhooker/issues/107. // see https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-trip", t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
database.TargetTypeHTTP, ts.URL, maxAttempts, database.TargetTypeHTTP, cfg, maxAttempts,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", ts.URL, maxAttempts, 1, &body, "metrics-trip", cfg, maxAttempts, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -314,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-trip", ts.URL, maxAttempts, attempt, &body, "metrics-trip", cfg, maxAttempts, attempt, &body,
) )
s.Engine.ExportProcessRetryTask( s.Engine.ExportProcessRetryTask(
@@ -361,16 +363,17 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
) )
body := event.Body body := event.Body
cfg := iHTTPConfig(ts.URL)
maxAttempts := delivery.ExportDefaultFailureThreshold + 5 maxAttempts := delivery.ExportDefaultFailureThreshold + 5
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
database.TargetTypeHTTP, ts.URL, maxAttempts, database.TargetTypeHTTP, cfg, maxAttempts,
) )
first := iTask( first := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, 1, &body, "metrics-blocked", cfg, maxAttempts, 1, &body,
) )
s.Engine.ExportProcessNewTask(context.TODO(), &first) s.Engine.ExportProcessNewTask(context.TODO(), &first)
@@ -379,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
ExportDefaultFailureThreshold; attempt++ { ExportDefaultFailureThreshold; attempt++ {
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, attempt, &body, "metrics-blocked", cfg, maxAttempts, attempt, &body,
) )
s.Engine.ExportProcessRetryTask(context.TODO(), &task) s.Engine.ExportProcessRetryTask(context.TODO(), &task)
@@ -402,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
blocked := iTask( blocked := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"metrics-blocked", ts.URL, maxAttempts, "metrics-blocked", cfg, maxAttempts,
delivery.ExportDefaultFailureThreshold+1, &body, delivery.ExportDefaultFailureThreshold+1, &body,
) )
@@ -471,7 +474,7 @@ func TestDeliveryMetrics_QueueDepthGauges(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID, s.WebhookID, iCreateTarget(t, s.MainDB, targetID, s.WebhookID,
"queue-depth-target", database.TargetTypeHTTP, "queue-depth-target", database.TargetTypeHTTP,
"https://example.com/hook", 3, iHTTPConfig("https://example.com/hook"), 3,
) )
event := iSeedEvent( event := iSeedEvent(
+1 -2
View File
@@ -20,8 +20,7 @@ const queueDepthSampleInterval = 30 * time.Second
// to be seeded correctly at startup from rows written by a previous // to be seeded correctly at startup from rows written by a previous
// process, and would drift permanently on any transition that failed // process, and would drift permanently on any transition that failed
// to persist. Counting is the measurement that cannot go wrong, and // to persist. Counting is the measurement that cannot go wrong, and
// it is the same whole-database walk each tick of the sweep already // it is the same whole-database walk the retry sweep already makes.
// makes.
func (e *Engine) queueDepthSampler(ctx context.Context) { func (e *Engine) queueDepthSampler(ctx context.Context) {
defer e.wg.Done() defer e.wg.Done()
@@ -102,7 +102,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
iCreateTarget(t, mainDB.DB(), targetID, webhookID, iCreateTarget(t, mainDB.DB(), targetID, webhookID,
"queue-depth-gormlog-target", database.TargetTypeHTTP, "queue-depth-gormlog-target", database.TargetTypeHTTP,
"https://example.com/hook", 3, iHTTPConfig("https://example.com/hook"), 3,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -258,9 +258,9 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
} }
} }
// TestSweepSettlesStrandedPendingWithoutResending is the reconcile on a // TestSweepSettlesStrandedPendingWithoutResending is the sweep's own
// tick of the sweep: a stranded delivery holding a successful result is // version of the reconcile: a stranded delivery holding a successful
// settled where it stands, and the receiver hears nothing. // result is settled where it stands, and the receiver hears nothing.
func TestSweepSettlesStrandedPendingWithoutResending( func TestSweepSettlesStrandedPendingWithoutResending(
t *testing.T, t *testing.T,
) { ) {
@@ -312,9 +312,8 @@ func TestSweepSettlesStrandedPendingWithoutResending(
// TestFailedResultWriteLeavesDeliveryRecoverable is the rule the // TestFailedResultWriteLeavesDeliveryRecoverable is the rule the
// targets now follow: a bookkeeping write that fails must not advance // targets now follow: a bookkeeping write that fails must not advance
// the status, because pending and retrying are the states the recovery // the status, because pending and retrying are the states the sweeps
// routine recovers and delivered is a claim the database refused to // recover and delivered is a claim the database refused to record.
// record.
func TestFailedResultWriteLeavesDeliveryRecoverable( func TestFailedResultWriteLeavesDeliveryRecoverable(
t *testing.T, t *testing.T,
) { ) {
@@ -357,7 +356,7 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
Target: database.Target{ Target: database.Target{
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL, Config: iHTTPConfig(ts.URL),
MaxAttempts: 1, MaxAttempts: 1,
}, },
} }
@@ -448,7 +447,7 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
Target: database.Target{ Target: database.Target{
Name: "unwritable", Name: "unwritable",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL, Config: iHTTPConfig(ts.URL),
MaxAttempts: 3, MaxAttempts: 3,
}, },
} }
+1 -50
View File
@@ -56,7 +56,7 @@ func recoverySetup(t *testing.T) (iSetup, string) {
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "recovery", t, s.MainDB, targetID, s.WebhookID, "recovery",
database.TargetTypeHTTP, database.TargetTypeHTTP,
"http://example.com/hook", 20, iHTTPConfig("http://example.com/hook"), 20,
) )
return s, targetID return s, targetID
@@ -205,55 +205,6 @@ func TestRecovery_PendingOlderThanTheAgeBound(t *testing.T) {
} }
} }
// TestRecovery_TaskCarriesEveryTargetSetting: the task the recovery
// routine queues carries every one of the target's settings, all six
// set here.
// They are copied into the task one field at a time, and a field left
// out is sent empty without any error.
func TestRecovery_TaskCarriesEveryTargetSetting(t *testing.T) {
t.Parallel()
for _, run := range recoveryRuns() {
t.Run(run.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery")
target := database.Target{
WebhookID: s.WebhookID,
Name: "recovery",
Type: database.TargetTypeHTTP,
Active: true,
URL: "http://example.com/hook",
Headers: map[string]string{headerAuthorization: "Bearer t"},
TimeoutSeconds: 7,
ForwardQuery: true,
ArchiveExpiry: time.Hour,
ArchiveRotation: "daily",
MaxAttempts: 1,
}
require.NoError(t, s.MainDB.Create(&target).Error)
id := seedRecoveryDelivery(
t, s, target.ID, database.DeliveryStatusPending, 0, false,
)
rAgePending(t, s.WebhookDB, id)
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
task := requireQueued(t, s.Engine.ExportDeliveryCh())
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.URL, task.TargetURL)
assert.Equal(t, target.Headers, task.TargetHeaders)
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
})
}
}
// TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched // TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched
// a moment ago, as one a stop cut off is, is sent at start, which is how // a moment ago, as one a stop cut off is, is sent at start, which is how
// it reaches its target on the next start // it reaches its target on the next start
+17 -9
View File
@@ -85,14 +85,19 @@ func deliverWithProbeHeaders(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL,
Headers: map[string]string{
probeHeaderName: probeHeaderValue,
},
})
require.NoError(t, err)
body := event.Body body := event.Body
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"redirect-target", targetURL, 1, 1, &body, "redirect-target", string(cfg), 1, 1, &body,
) )
task.TargetHeaders = map[string]string{
probeHeaderName: probeHeaderValue,
}
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -304,7 +309,8 @@ func TestRedirectPolicy_StopsAtHopCap(t *testing.T) {
) )
client := engine.ExportClientForRequest( client := engine.ExportClientForRequest(
0, []string{probeHeaderName}, &delivery.HTTPTargetConfig{URL: srv.URL},
[]string{probeHeaderName},
) )
require.NotNil(t, client.CheckRedirect) require.NotNil(t, client.CheckRedirect)
@@ -344,7 +350,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
inbound, err := json.Marshal(map[string][]string{ inbound, err := json.Marshal(map[string][]string{
inboundHeaderName: {inboundHeaderValue}, inboundHeaderName: {inboundHeaderValue},
"Content-Type": {testContentType}, "Content-Type": {testContentType},
headerUserAgent: {"curl/8.7.1"}, "User-Agent": {"curl/8.7.1"},
"Host": {"inbound.example.com"}, "Host": {"inbound.example.com"},
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -363,9 +369,11 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
Headers: string(inbound), Headers: string(inbound),
ContentType: testContentType, ContentType: testContentType,
}, },
map[string]string{ &delivery.HTTPTargetConfig{
probeHeaderName: probeHeaderValue, Headers: map[string]string{
"Content-Type": testContentType, probeHeaderName: probeHeaderValue,
"Content-Type": testContentType,
},
}, },
"webhooker/dev", "webhooker/dev",
) )
-164
View File
@@ -1,164 +0,0 @@
package delivery
import (
"fmt"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// StoreEvent stores event with one pending delivery for each of its
// webhook's active targets, and adds them to the totals, in one
// transaction, then queues the deliveries. It returns how many
// deliveries it created.
//
// The receiver stores every event it accepts through here, and a
// resubmit stores its copy of a stored event, so every event is
// stored, retried, SSRF-guarded and circuit-broken the same way however
// it entered the system. CreateReplay is the only other place a
// delivery is created.
//
// The targets are the webhook's active ones as they stand now: a
// target created after a resubmitted event first arrived is included,
// and an inactive one is skipped. An event with no active target is
// stored with no deliveries.
func (e *Engine) StoreEvent(event *database.Event) (int, error) {
var targets []database.Target
err := e.database.DB().Where(
"webhook_id = ? AND active = ?", event.WebhookID, true,
).Find(&targets).Error
if err != nil {
return 0, fmt.Errorf("loading active targets: %w", err)
}
webhookDB, err := e.dbManager.GetDB(event.WebhookID)
if err != nil {
return 0, fmt.Errorf("getting webhook database: %w", err)
}
event.BodyBytes = int64(len(event.Body))
tasks := make([]Task, 0, len(targets))
err = webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Create(event).Error
if err != nil {
return fmt.Errorf("creating event: %w", err)
}
for i := range targets {
dlv, err := createDelivery(tx, event.ID, targets[i].ID, false)
if err != nil {
return err
}
tasks = append(tasks, buildTask(
dlv, event.WebhookID, event, &targets[i], 1,
))
}
err = database.AddEventTotals(tx, database.EventTotals{
Events: 1, LastEventAt: &event.CreatedAt,
})
if err != nil {
return err
}
// A resubmitted copy did not arrive on its entrypoint's URL, so
// it leaves the entrypoint's last event as it is.
if event.ResubmittedFromID != nil {
return nil
}
return database.AddEntrypointTotals(tx, database.EntrypointTotals{
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
})
})
if err != nil {
return 0, err
}
// Counted here, after the commit: an event exists once it is
// durably stored, which is what the delivery counters are compared
// against on a dashboard. A resubmitted event counts too, because
// it produces deliveries that the delivery side counts; the
// resubmit counter is what separates the two.
e.mtr.EventReceived()
e.notify(tasks)
return len(tasks), nil
}
// CreateReplay creates one more pending delivery of the stored event
// eventID to target, marked as a replay, adds it to the target's totals
// in the same transaction, and queues it. It returns the new
// delivery's id.
//
// The caller passes the target as it stands now and has already
// decided the replay may go ahead. The delivery being replayed is not
// touched.
func (e *Engine) CreateReplay(
webhookID, eventID string, target *database.Target,
) (string, error) {
webhookDB, err := e.dbManager.GetDB(webhookID)
if err != nil {
return "", fmt.Errorf("getting webhook database: %w", err)
}
event, err := e.loadEvent(webhookDB, eventID)
if err != nil {
return "", err
}
var dlv *database.Delivery
err = webhookDB.Transaction(func(tx *gorm.DB) error {
var err error
dlv, err = createDelivery(tx, event.ID, target.ID, true)
return err
})
if err != nil {
return "", err
}
e.notify([]Task{buildTask(dlv, webhookID, &event, target, 1)})
return dlv.ID, nil
}
// createDelivery writes a pending delivery of the event to the target
// on the caller's transaction, and adds it to the target's totals.
//
// Neither Event nor Target is set on the row, and the per-webhook
// database omits associations on every write in any case (see
// internal/database/event_db_isolation.go), so the target's settings
// are never copied into the event database.
func createDelivery(
tx *gorm.DB, eventID, targetID string, replay bool,
) (*database.Delivery, error) {
dlv := &database.Delivery{
EventID: eventID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Replay: replay,
}
err := tx.Create(dlv).Error
if err != nil {
return nil, fmt.Errorf(
"creating delivery for target %s: %w", targetID, err,
)
}
err = database.AddTargetTotals(tx, database.TargetTotals{
TargetID: targetID, Deliveries: 1,
})
if err != nil {
return nil, err
}
return dlv, nil
}
-386
View File
@@ -1,386 +0,0 @@
package delivery_test
import (
"net/http"
"strings"
"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"
"sneak.berlin/go/webhooker/internal/delivery"
)
// storeTestTarget creates an active http target of the setup's webhook
// with every one of a target's settings set, so a task that drops one
// of them differs from the target.
func storeTestTarget(
t *testing.T, s iSetup, name string,
) database.Target {
t.Helper()
target := database.Target{
WebhookID: s.WebhookID,
Name: name,
Type: database.TargetTypeHTTP,
Active: true,
URL: "http://93.184.216.34/" + name,
Headers: map[string]string{"X-Api-Key": "k"},
TimeoutSeconds: 7,
ForwardQuery: true,
ArchiveExpiry: time.Hour,
ArchiveRotation: rotationDaily,
MaxAttempts: 3,
}
require.NoError(t, s.MainDB.Create(&target).Error)
return target
}
// newStoreTestEvent returns an event of the setup's webhook as the
// receiver builds one, not yet stored.
func newStoreTestEvent(s iSetup, body string) *database.Event {
return &database.Event{
WebhookID: s.WebhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
RawQuery: "a=1&b=2",
Headers: `{"X-Test":["yes"]}`,
ContentType: testContentType,
Body: body,
}
}
// queuedTasks takes every task the engine has queued so far. The
// engine in these tests is never started, so nothing else takes them.
func queuedTasks(e *delivery.Engine) []delivery.Task {
var tasks []delivery.Task
for {
select {
case task := <-e.ExportDeliveryCh():
tasks = append(tasks, task)
default:
return tasks
}
}
}
// assertTaskFor proves task carries delivery deliveryID of event to
// target: every one of the target's settings and every event field.
// They are copied into the task one field at a time, and a field left
// out is sent empty without any error.
func assertTaskFor(
t *testing.T,
task delivery.Task,
deliveryID string,
event *database.Event,
target *database.Target,
) {
t.Helper()
assert.Equal(t, deliveryID, task.DeliveryID)
assert.Equal(t, event.ID, task.EventID)
assert.Equal(t, event.WebhookID, task.WebhookID)
assert.Equal(t, event.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Name, task.TargetName)
assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, target.URL, task.TargetURL)
assert.Equal(t, target.Headers, task.TargetHeaders)
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
assert.Equal(t, target.MaxAttempts, task.MaxAttempts)
assert.Equal(t, event.Method, task.Method)
assert.Equal(t, event.RawQuery, task.RawQuery)
assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType)
require.NotNil(t, task.Body)
assert.Equal(t, event.Body, *task.Body)
assert.Equal(t, 1, task.AttemptNum)
}
// storedDeliveries reads every delivery of an event.
func storedDeliveries(
t *testing.T, db *gorm.DB, eventID string,
) []database.Delivery {
t.Helper()
var deliveries []database.Delivery
require.NoError(t, db.Where("event_id = ?", eventID).
Find(&deliveries).Error)
return deliveries
}
// storedEventTotals reads the webhook's event totals.
func storedEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var totals database.EventTotals
require.NoError(t, db.First(&totals).Error)
return totals
}
// countRows counts the rows of model in db.
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var n int64
require.NoError(t, db.Model(model).Count(&n).Error)
return n
}
// TestStoreEvent_StoresDeliveriesToActiveTargets proves StoreEvent
// stores the event with one pending delivery to each active target and
// none to an inactive one, adds them to the totals, and queues one task
// per delivery.
func TestStoreEvent_StoresDeliveriesToActiveTargets(t *testing.T) {
t.Parallel()
s := newISetup(t)
targets := []database.Target{
storeTestTarget(t, s, "first"),
storeTestTarget(t, s, "second"),
}
off := storeTestTarget(t, s, "off")
require.NoError(t, s.MainDB.Model(&database.Target{}).
Where("id = ?", off.ID).Update("active", false).Error)
event := newStoreTestEvent(s, `{"hello":"world"}`)
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Equal(t, 2, created)
var stored database.Event
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
assert.Equal(t, event.Body, stored.Body)
assert.Equal(t, int64(len(event.Body)), stored.BodyBytes)
rows := storedDeliveries(t, s.WebhookDB, event.ID)
queued := queuedTasks(s.Engine)
require.Len(t, rows, 2)
require.Len(t, queued, 2)
deliveries := map[string]database.Delivery{}
for _, dlv := range rows {
deliveries[dlv.TargetID] = dlv
}
tasks := map[string]delivery.Task{}
for _, task := range queued {
tasks[task.TargetID] = task
}
for i := range targets {
dlv, ok := deliveries[targets[i].ID]
require.True(t, ok, "no delivery to %s", targets[i].Name)
assert.Equal(t, database.DeliveryStatusPending, dlv.Status)
assert.False(t, dlv.Replay)
assert.Equal(t, int64(1),
targetTotals(t, s.WebhookDB, targets[i].ID).Deliveries)
assertTaskFor(t, tasks[targets[i].ID], dlv.ID, event, &targets[i])
}
totals := storedEventTotals(t, s.WebhookDB)
assert.Equal(t, int64(1), totals.Events)
require.NotNil(t, totals.LastEventAt)
assert.True(t, totals.LastEventAt.Equal(stored.CreatedAt))
var entrypoint database.EntrypointTotals
require.NoError(t, s.WebhookDB.First(
&entrypoint, "entrypoint_id = ?", event.EntrypointID,
).Error)
assert.True(t, entrypoint.LastEventAt.Equal(stored.CreatedAt))
}
// TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint proves a
// resubmitted copy is counted among the webhook's events but leaves its
// entrypoint's last event as it is: it did not arrive on that URL.
func TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
sourceID := uuid.New().String()
event := newStoreTestEvent(s, `{"copy":true}`)
event.ResubmittedFromID = &sourceID
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Equal(t, 1, created)
assert.Len(t, queuedTasks(s.Engine), 1)
assert.Equal(t, int64(1), storedEventTotals(t, s.WebhookDB).Events)
assert.Zero(t, countRows(t, s.WebhookDB, &database.EntrypointTotals{}))
}
// TestStoreEvent_NoActiveTarget proves an event of a webhook with no
// active target is stored with no delivery and nothing queued.
func TestStoreEvent_NoActiveTarget(t *testing.T) {
t.Parallel()
s := newISetup(t)
event := newStoreTestEvent(s, `{"to":"nobody"}`)
created, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
assert.Zero(t, created)
assert.Equal(t, int64(1), countRows(t, s.WebhookDB, &database.Event{}))
assert.Empty(t, storedDeliveries(t, s.WebhookDB, event.ID))
assert.Empty(t, queuedTasks(s.Engine))
}
// TestStoreEvent_LargeBodyIsNotCarried proves a body at the inline
// limit is stored whole but left off the task, for the attempt to read
// from the event row.
func TestStoreEvent_LargeBodyIsNotCarried(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
event := newStoreTestEvent(
s, strings.Repeat("x", delivery.MaxInlineBodySize),
)
_, err := s.Engine.StoreEvent(event)
require.NoError(t, err)
tasks := queuedTasks(s.Engine)
require.Len(t, tasks, 1)
assert.Nil(t, tasks[0].Body)
var stored database.Event
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
assert.Equal(t, event.Body, stored.Body)
}
// TestStoreEvent_RollsBackWhenAWriteFails proves the event, its
// deliveries and its totals are one transaction: when the entrypoint
// totals write fails, the last one, after the event, its delivery, the
// target totals and the event totals were written, none of them is left
// and nothing is queued.
func TestStoreEvent_RollsBackWhenAWriteFails(t *testing.T) {
t.Parallel()
s := newISetup(t)
storeTestTarget(t, s, "only")
require.NoError(t,
s.WebhookDB.Migrator().DropTable(&database.EntrypointTotals{}))
_, err := s.Engine.StoreEvent(newStoreTestEvent(s, `{"lost":true}`))
require.Error(t, err)
assert.Zero(t, countRows(t, s.WebhookDB, &database.Event{}))
assert.Zero(t, countRows(t, s.WebhookDB, &database.Delivery{}))
assert.Zero(t, countRows(t, s.WebhookDB, &database.TargetTotals{}))
totals := storedEventTotals(t, s.WebhookDB)
assert.Zero(t, totals.Events)
assert.Nil(t, totals.LastEventAt)
assert.Empty(t, queuedTasks(s.Engine))
assert.Zero(t, s.Engine.ExportInflightHeld())
}
// TestCreateReplay_CreatesAndQueuesAReplay proves CreateReplay adds a
// new pending delivery marked as a replay, counts it in the target's
// totals, queues its task built from the target it was given and the
// stored event, and leaves the delivery it repeats as it was. The
// stored event has every event field set, so a task that drops one of
// them differs from the event.
func TestCreateReplay_CreatesAndQueuesAReplay(t *testing.T) {
t.Parallel()
s := newISetup(t)
target := storeTestTarget(t, s, "replayed")
event := newStoreTestEvent(s, `{"replay":"me"}`)
require.NoError(t, s.WebhookDB.Create(event).Error)
original := iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusFailed,
)
deliveryID, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
require.NoError(t, err)
replay := storedDelivery(t, s.WebhookDB, deliveryID)
assert.Equal(t, event.ID, replay.EventID)
assert.Equal(t, target.ID, replay.TargetID)
assert.Equal(t, database.DeliveryStatusPending, replay.Status)
assert.True(t, replay.Replay)
assert.Equal(t, database.DeliveryStatusFailed,
storedDelivery(t, s.WebhookDB, original.ID).Status)
assert.Equal(t, int64(1),
targetTotals(t, s.WebhookDB, target.ID).Deliveries)
tasks := queuedTasks(s.Engine)
require.Len(t, tasks, 1)
assertTaskFor(t, tasks[0], deliveryID, event, &target)
}
// TestCreateReplay_RollsBackWhenAWriteFails proves the replay delivery
// and its target totals are one transaction: when the totals write
// fails, after the delivery was written, no new delivery is left and
// nothing is queued.
func TestCreateReplay_RollsBackWhenAWriteFails(t *testing.T) {
t.Parallel()
s := newISetup(t)
target := storeTestTarget(t, s, "replayed")
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"replay":"me"}`)
iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusFailed,
)
require.NoError(t,
s.WebhookDB.Migrator().DropTable(&database.TargetTotals{}))
_, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
require.Error(t, err)
assert.Len(t, storedDeliveries(t, s.WebhookDB, event.ID), 1)
assert.Empty(t, queuedTasks(s.Engine))
assert.Zero(t, s.Engine.ExportInflightHeld())
}
// storedDelivery reads one delivery back.
func storedDelivery(
t *testing.T, db *gorm.DB, deliveryID string,
) database.Delivery {
t.Helper()
var dlv database.Delivery
require.NoError(t, db.First(&dlv, "id = ?", deliveryID).Error)
return dlv
}
+6 -3
View File
@@ -13,8 +13,8 @@ import (
// The engine provides one to each target so a target can own // The engine provides one to each target so a target can own
// its retries durably: it records the attempt, marks the // its retries durably: it records the attempt, marks the
// delivery retrying, and asks the Scheduler to deliver the // delivery retrying, and asks the Scheduler to deliver the
// next attempt after delay — exactly what the engine's // next attempt after delay — exactly what the engine does for
// recovery routine does for a retrying delivery. // its own restart recovery.
type Scheduler interface { type Scheduler interface {
ScheduleRetry(task Task, delay time.Duration) ScheduleRetry(task Task, delay time.Duration)
} }
@@ -94,13 +94,16 @@ func (e *Engine) initTargets(client *http.Client) {
client: client, client: client,
} }
dbT := &databaseTarget{eng: e}
e.httpTarget = httpT e.httpTarget = httpT
e.slackTarget = slackT e.slackTarget = slackT
e.dbTarget = dbT
e.targets = map[database.TargetType]Target{ e.targets = map[database.TargetType]Target{
database.TargetTypeHTTP: httpT, database.TargetTypeHTTP: httpT,
database.TargetTypeSlack: slackT, database.TargetTypeSlack: slackT,
database.TargetTypeDatabase: &databaseTarget{eng: e}, database.TargetTypeDatabase: dbT,
database.TargetTypeLog: &logTarget{eng: e}, database.TargetTypeLog: &logTarget{eng: e},
} }
} }
+136
View File
@@ -0,0 +1,136 @@
package delivery
import (
"encoding/json"
"errors"
"fmt"
"sneak.berlin/go/webhooker/internal/database"
)
// errUnknownTargetTypeForEdit is returned when a stored target has a
// type the edit form has no field set for.
var errUnknownTargetTypeForEdit = errors.New(
"unknown target type",
)
// TargetConfigForm is the UNMASKED projection of a target's stored
// configuration, for pre-filling the target edit form.
//
// It is the deliberate exception to the rule TargetView enforces
// everywhere else: TargetView exists so that no template can render
// a target's stored blob, because a destination URL's path segments
// and a header value are both routinely the credential. An operator
// cannot correct a value they cannot see, so the edit form — and
// only the edit form — is shown the full value.
//
// Everything that keeps that exception narrow lives at the call
// site: the route is behind RequireAuth and the webhook's ownership
// check, and its group sets NoCache so the rendered secret is not
// written to a shared cache. Do not reach for this type from any
// other page.
type TargetConfigForm struct {
// URL is the destination for an HTTP target and the webhook
// URL for a Slack target.
URL string
// Headers is the HTTP target's configured headers in the
// textarea representation, one "Name: value" per line.
Headers string
// Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset.
Timeout string
// ForwardQuery is the HTTP target's setting that passes each
// event's query string on to it.
ForwardQuery bool
// Expiry is the database (archive) target's row expiry.
Expiry string
// Rotation is the database (archive) target's rotation.
Rotation string
}
// NewTargetConfigForm parses a target's stored configuration into
// the edit form's fields.
//
// A configuration that does not parse is an error rather than a
// zero-valued form that silently looks like a target with no
// settings. The caller shows the operator that the stored value
// could not be read, so that saving the form is understood as
// replacing it rather than preserving it.
func NewTargetConfigForm(
t *database.Target,
) (TargetConfigForm, error) {
switch t.Type {
case database.TargetTypeHTTP:
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{
URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout),
ForwardQuery: cfg.ForwardQuery,
}, nil
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{URL: cfg.WebhookURL}, nil
case database.TargetTypeDatabase:
return databaseConfigForm(t.Config)
case database.TargetTypeLog:
// The log target takes no configuration.
return TargetConfigForm{}, nil
default:
return TargetConfigForm{}, fmt.Errorf(
"%w: %q", errUnknownTargetTypeForEdit, t.Type,
)
}
}
// databaseConfigForm parses an archive target's optional expiry and
// rotation. An absent, empty or never expiry yields an empty expiry,
// on which the edit form starts at never; saving it unchanged stores
// never, which means the same as an empty expiry. An absent rotation
// is empty too, and the form starts at none. An expiry that is set
// but not a valid duration, or a rotation that is not one of the
// four, is an error, not a blank field.
func databaseConfigForm(
configJSON string,
) (TargetConfigForm, error) {
if configJSON == "" {
return TargetConfigForm{}, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return TargetConfigForm{}, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return TargetConfigForm{}, err
}
form := TargetConfigForm{Rotation: cfg.Rotation}
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return form, nil
}
err = ValidateArchiveExpiry(cfg.Expiry)
if err != nil {
return TargetConfigForm{}, err
}
form.Expiry = cfg.Expiry
return form, nil
}
+75 -35
View File
@@ -5,21 +5,19 @@ import (
"strconv" "strconv"
"time" "time"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// configUnavailable is what a target's settings render as when // configUnavailable is what a target's configuration renders
// the target is of an unknown type, and what its URL renders as // as when it is absent, of an unknown type, or does not
// when it does not parse into a scheme and host. The stored URL is // parse. The stored blob is never shown as a fallback: it can
// never shown as a fallback: it can be a credential (a Slack // hold a credential (a Slack incoming webhook URL is a bearer
// incoming webhook URL is a bearer token) and a UI that prints it // token) and a UI that prints it leaks that credential into
// leaks that credential into browser history, screenshots and // browser history, screenshots and screen shares.
// screen shares.
const configUnavailable = "(unavailable)" const configUnavailable = "(unavailable)"
// ConfigField is one labelled, display-safe value derived // ConfigField is one labelled, display-safe value derived
// from a target's settings. // from a target's stored configuration.
type ConfigField struct { type ConfigField struct {
Label string Label string
Value string Value string
@@ -33,9 +31,9 @@ type ConfigField struct {
const deletedNameSuffix = " (deleted)" const deletedNameSuffix = " (deleted)"
// TargetView is the display-safe projection of a target for // TargetView is the display-safe projection of a target for
// the UI. It deliberately has no URL or header field, so no // the UI. It deliberately has no raw configuration field, so
// template — present or future — can render the stored // no template — present or future — can render the stored
// credential. // blob.
type TargetView struct { type TargetView struct {
ID string ID string
Name string Name string
@@ -62,7 +60,8 @@ func (v TargetView) DisplayName() string {
} }
// NewTargetViews projects targets for rendering, replacing // NewTargetViews projects targets for rendering, replacing
// each target's settings with named, display-safe fields. // each stored configuration blob with named, display-safe
// fields.
// //
// A soft-deleted row projects exactly as a live one does, minus // A soft-deleted row projects exactly as a live one does, minus
// the deleted marker on its name: masking is a property of the // the deleted marker on its name: masking is a property of the
@@ -91,8 +90,8 @@ func NewTargetViews(
} }
// targetConfigFields returns the display-safe fields for a // targetConfigFields returns the display-safe fields for a
// target's settings. A target of an unknown type gets the // target's configuration. Anything it cannot parse becomes
// neutral placeholder. // the neutral placeholder.
func targetConfigFields( func targetConfigFields(
t *database.Target, t *database.Target,
) []ConfigField { ) []ConfigField {
@@ -102,25 +101,36 @@ func targetConfigFields(
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return httpConfigFields(t) return httpConfigFields(t)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return databaseConfigFields(t) return databaseConfigFields(t.Config)
case database.TargetTypeLog: case database.TargetTypeLog:
// The log target takes no settings. // The log target takes no configuration.
return nil return nil
default: default:
return []ConfigField{{ return unavailableConfigFields()
Label: "Configuration",
Value: configUnavailable,
}}
} }
} }
// unavailableConfigFields is the neutral placeholder shown
// for a configuration that could not be presented.
func unavailableConfigFields() []ConfigField {
return []ConfigField{{
Label: "Configuration",
Value: configUnavailable,
}}
}
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target: its masked
// webhook URL and its number of delivery attempts. Only the // webhook URL and its number of delivery attempts. Only the
// masked URL is shown; the full URL is the credential. // masked URL is shown; the full URL is the credential.
func slackConfigFields(t *database.Target) []ConfigField { func slackConfigFields(t *database.Target) []ConfigField {
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return unavailableConfigFields()
}
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: MaskURL(t.URL), Value: cfg.MaskedWebhookURL(),
}, maxAttemptsField(t)} }, maxAttemptsField(t)}
} }
@@ -135,28 +145,33 @@ func slackConfigFields(t *database.Target) []ConfigField {
// whose path segments are the credential, and the field takes // whose path segments are the credential, and the field takes
// an arbitrary URL, so no segment can be assumed non-secret. // an arbitrary URL, so no segment can be assumed non-secret.
func httpConfigFields(t *database.Target) []ConfigField { func httpConfigFields(t *database.Target) []ConfigField {
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return unavailableConfigFields()
}
fields := []ConfigField{{ fields := []ConfigField{{
Label: "Destination URL", Label: "Destination URL",
Value: MaskURL(t.URL), Value: MaskURL(cfg.URL),
}} }}
if t.TimeoutSeconds > 0 { if cfg.Timeout > 0 {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Timeout", Label: "Timeout",
Value: strconv.Itoa(t.TimeoutSeconds) + "s", Value: strconv.Itoa(cfg.Timeout) + "s",
}) })
} }
if len(t.Headers) > 0 { if len(cfg.Headers) > 0 {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Headers", Label: "Headers",
Value: fmt.Sprintf( Value: fmt.Sprintf(
"%d configured", len(t.Headers), "%d configured", len(cfg.Headers),
), ),
}) })
} }
if t.ForwardQuery { if cfg.ForwardQuery {
fields = append(fields, ConfigField{ fields = append(fields, ConfigField{
Label: "Query string", Label: "Query string",
Value: "passed on to this target", Value: "passed on to this target",
@@ -169,7 +184,8 @@ func httpConfigFields(t *database.Target) []ConfigField {
} }
// maxAttemptsField describes a target's number of delivery // maxAttemptsField describes a target's number of delivery
// attempts. // attempts, which lives on the target row rather than in its
// configuration blob.
func maxAttemptsField(t *database.Target) ConfigField { func maxAttemptsField(t *database.Target) ConfigField {
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Delivery attempts",
@@ -179,11 +195,24 @@ func maxAttemptsField(t *database.Target) ConfigField {
// databaseConfigFields describes an archive target by its // databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when // expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever, and by its rotation. // the archive is kept forever, and by its rotation. An expiry
func databaseConfigFields(t *database.Target) []ConfigField { // that is set but not a valid duration, or a rotation that is
value := archive.ExpiryNever // not one of the four, is reported as unavailable rather than
if t.ArchiveExpiry > 0 { // echoed back.
value = plainDuration(t.ArchiveExpiry) func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON)
if err != nil {
return unavailableConfigFields()
}
rotation, err := parseArchiveRotation(configJSON)
if err != nil {
return unavailableConfigFields()
}
value := archiveExpiryNever
if expiry > 0 {
value = plainDuration(expiry)
} }
return []ConfigField{{ return []ConfigField{{
@@ -191,7 +220,7 @@ func databaseConfigFields(t *database.Target) []ConfigField {
Value: value, Value: value,
}, { }, {
Label: "Archive rotation", Label: "Archive rotation",
Value: t.ArchiveRotation, Value: rotation,
}} }}
} }
@@ -227,3 +256,14 @@ func plainDuration(d time.Duration) string {
return d.String() return d.String()
} }
// MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of
// them is shown: the field accepts an arbitrary URL, so no
// segment can be assumed non-secret. A URL that does not
// parse into a scheme and host yields the neutral
// placeholder, never the raw string.
func (c *SlackTargetConfig) MaskedWebhookURL() string {
return MaskURL(c.WebhookURL)
}
+118 -65
View File
@@ -35,17 +35,7 @@ const (
viewMaxAttempts = "Delivery attempts" viewMaxAttempts = "Delivery attempts"
) )
// The archive rotations a database target's settings can name. func TestMaskedWebhookURL(t *testing.T) {
const (
rotationNone = "none"
rotationMonthly = "monthly"
rotationDaily = "daily"
rotationHourly = "hourly"
)
// TestNewTargetViews_SlackMasksWebhookURL shows what a Slack target's
// URL renders as on the target list.
func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) {
t.Parallel() t.Parallel()
tests := map[string]struct { tests := map[string]struct {
@@ -89,24 +79,28 @@ func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() cfg := &delivery.SlackTargetConfig{
target.URL = tc.url WebhookURL: tc.url,
}
assert.Equal( assert.Equal(
t, tc.want, t, tc.want, cfg.MaskedWebhookURL(),
fieldMap(viewFor(t, target).Config)["Webhook URL"],
) )
}) })
} }
} }
// TestNewTargetViews_SlackNeverLeaksPath is the direct // TestMaskedWebhookURL_NeverLeaksPath is the direct
// expression of the rule: whatever the input, the masked // expression of the rule: whatever the input, the masked
// value never contains a path segment of it. // value never contains a path segment of it.
func TestNewTargetViews_SlackNeverLeaksPath(t *testing.T) { func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) {
t.Parallel() t.Parallel()
masked := fieldMap(viewFor(t, slackTarget()).Config)["Webhook URL"] cfg := &delivery.SlackTargetConfig{
WebhookURL: slackWebhookURL,
}
masked := cfg.MaskedWebhookURL()
assert.NotContains(t, masked, "T00000000") assert.NotContains(t, masked, "T00000000")
assert.NotContains(t, masked, "B00000000") assert.NotContains(t, masked, "B00000000")
@@ -172,10 +166,11 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
// share. // share.
func slackTarget() database.Target { func slackTarget() database.Target {
return database.Target{ return database.Target{
Name: slackTargetName, Name: slackTargetName,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Active: true, Active: true,
URL: slackWebhookURL, Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
} }
} }
@@ -227,12 +222,12 @@ func TestNewTargetViews_HTTP(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: viewExampleHook, Config: `{"url":"` + viewExampleHook + `",` +
TimeoutSeconds: 30, `"timeout":30,` +
Headers: map[string]string{headerAuthorization: "Bearer sekrit"}, `"headers":{"Authorization":"Bearer sekrit"},` +
ForwardQuery: true, `"forwardQuery":true}`,
MaxAttempts: 5, MaxAttempts: 5,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -262,7 +257,7 @@ func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: viewExampleHook, Config: `{"url":"` + viewExampleHook + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -285,7 +280,7 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: slackWebhookURL, Config: `{"url":"` + slackWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -309,20 +304,25 @@ func TestNewTargetViews_Database(t *testing.T) {
t.Parallel() t.Parallel()
tests := map[string]struct { tests := map[string]struct {
expiry time.Duration config string
want string want string
}{ }{
"never": {expiry: 0, want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"1h": {expiry: time.Hour, want: "1 hour"}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"12h": {expiry: 12 * time.Hour, want: "12 hours"}, "never literal": {
"24h": {expiry: 24 * time.Hour, want: "1 day"}, config: `{"expiry":"` + viewExpiryNever + `"}`,
"720h": {expiry: 720 * time.Hour, want: "30 days"}, want: viewExpiryNever,
"2160h": {expiry: 2160 * time.Hour, want: "90 days"}, },
"8760h": {expiry: 8760 * time.Hour, want: "365 days"}, "1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"36h": {expiry: 36 * time.Hour, want: "36 hours"}, "12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"1h30m": {expiry: 90 * time.Minute, want: "90 minutes"}, "24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"45s": {expiry: 45 * time.Second, want: "45 seconds"}, "720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"1.5s": {expiry: 1500 * time.Millisecond, want: "1.5s"}, "2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
} }
for name, tc := range tests { for name, tc := range tests {
@@ -330,9 +330,8 @@ func TestNewTargetViews_Database(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
ArchiveExpiry: tc.expiry, Config: tc.config,
ArchiveRotation: rotationNone,
}) })
assert.Equal( assert.Equal(
@@ -348,23 +347,33 @@ func TestNewTargetViews_Database(t *testing.T) {
} }
// TestNewTargetViews_DatabaseRotation proves the target list shows a // TestNewTargetViews_DatabaseRotation proves the target list shows a
// database target's rotation. // database target's rotation, none when it has none stored.
func TestNewTargetViews_DatabaseRotation(t *testing.T) { func TestNewTargetViews_DatabaseRotation(t *testing.T) {
t.Parallel() t.Parallel()
// Each stored config, and the rotation the list shows for it.
tests := map[string]string{
"": rotationNone,
`{"rotation":""}`: rotationNone,
}
for _, rotation := range []string{ for _, rotation := range []string{
rotationNone, rotationMonthly, rotationDaily, rotationHourly, rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} { } {
t.Run(rotation, func(t *testing.T) { tests[`{"rotation":"`+rotation+`"}`] = rotation
}
for config, want := range tests {
t.Run(config, func(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
ArchiveRotation: rotation, Config: config,
}) })
assert.Equal( assert.Equal(
t, rotation, fieldMap(view.Config)["Archive rotation"], t, want, fieldMap(view.Config)["Archive rotation"],
) )
}) })
} }
@@ -374,28 +383,72 @@ func TestNewTargetViews_Log(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ view := viewFor(t, database.Target{
Type: database.TargetTypeLog, Type: database.TargetTypeLog,
Config: "",
}) })
assert.Empty(t, view.Config) assert.Empty(t, view.Config)
} }
// TestNewTargetViews_UnknownType proves that a target of a type the // TestNewTargetViews_Unpresentable proves that no config the
// view does not know shows the neutral placeholder, and never its // view cannot present falls back to the stored blob.
// URL. func TestNewTargetViews_Unpresentable(t *testing.T) {
func TestNewTargetViews_UnknownType(t *testing.T) {
t.Parallel() t.Parallel()
view := viewFor(t, database.Target{ const blob = `{"webhookUrl":"https://hooks.slack.com` +
Type: database.TargetType("carrier-pigeon"), slackSecretPath + `"`
URL: slackWebhookURL,
})
assert.Equal( tests := map[string]database.Target{
t, "unknown target type": {
map[string]string{ Type: database.TargetType("carrier-pigeon"),
"Configuration": viewUnavailable, Config: blob,
}, },
fieldMap(view.Config), "unparseable json": {
) Type: database.TargetTypeSlack,
Config: blob,
MaxAttempts: 1,
},
"empty slack config": {
Type: database.TargetTypeSlack,
MaxAttempts: 1,
},
"slack config without url": {
Type: database.TargetTypeSlack,
Config: `{}`,
MaxAttempts: 1,
},
"unparseable http json": {
Type: database.TargetTypeHTTP,
Config: `{"url":`,
MaxAttempts: 1,
},
"unparseable archive json": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":`,
},
"invalid archive expiry": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`,
},
"invalid archive rotation": {
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
}
for name, target := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Configuration": viewUnavailable,
},
fieldMap(view.Config),
)
})
}
} }
+369 -4
View File
@@ -2,23 +2,83 @@ package delivery
import ( import (
"context" "context"
"fmt"
"strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// databaseTarget is a no-retry target that archives the full // databaseTarget is a no-retry target that archives the full
// inbound event into the target's own archive SQLite file, separate // inbound event into the target's own archive SQLite file, separate
// from the per-webhook event database. The event is already // from the per-webhook event database. The event is already
// persisted in the per-webhook event DB by the time delivery runs; // persisted in the per-webhook event DB by the time delivery runs;
// the database target additionally writes a durable long-term copy // the database target additionally writes a durable long-term copy
// through the engine's archive.Writers, and then records a single // into the file ArchiveFileName names, with a period added when the
// target rotates (see archivePeriodPath), and then records a single
// attempt whose outcome reflects whether the archive write // attempt whose outcome reflects whether the archive write
// succeeded. Package archive holds everything about the files // succeeded. See archiveWriter for the close/reopen, auto-recreate,
// themselves. // and expiry semantics.
type databaseTarget struct { type databaseTarget struct {
eng *Engine eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
}
// ArchiveFileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func ArchiveFileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
} }
// Deliver implements Target. It archives the event, then // Deliver implements Target. It archives the event, then
@@ -38,7 +98,7 @@ func (t *databaseTarget) Deliver(
) { ) {
start := time.Now() start := time.Now()
err := t.eng.archives.Write(d) err := t.archive(d)
elapsed := time.Since(start) elapsed := time.Since(start)
@@ -85,3 +145,308 @@ func (t *databaseTarget) Deliver(
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
} }
// archive writes the full event as a row into the target's
// archive database, honouring the optional per-target expiry and
// rotation parsed from the target config JSON. With rotation, the
// event goes to the file for the period of its receive time.
func (t *databaseTarget) archive(d *database.Delivery) error {
webhookID := d.Event.WebhookID
if webhookID == "" {
return errArchiveMissingWebhookID
}
expiry, err := parseArchiveExpiry(d.Target.Config)
if err != nil {
return err
}
rotation, err := parseArchiveRotation(d.Target.Config)
if err != nil {
return err
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see Engine.hydrateEvent), and goes to the
// file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := t.writerFor(d.TargetID)
if err != nil {
return err
}
row := archivedEvent{
EventID: d.Event.ID,
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
}
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
func (t *databaseTarget) writerFor(
targetID string,
) (*archiveWriter, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if !ok {
var err error
w, err = t.newWriter(targetID)
if err != nil {
return nil, err
}
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
t.writers[targetID] = w
}
// A delivery claims the entry: even if the idle sweep created
// it moments ago, it now belongs to the registry proper and
// the sweep must leave it in place when it finishes.
w.sweepOwned = false
return w, nil
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor(
targetID string,
) (*archiveWriter, bool, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
if err != nil {
return nil, false, err
}
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
w.sweepOwned = true
t.writers[targetID] = w
return w, true, nil
}
// releaseSweepWriter drops a registry entry that the idle sweep
// created, so a sweep leaves the registry exactly as it found it.
//
// The entry is removed only if it is still the very writer the
// sweep installed and no delivery has claimed it in the meantime
// (writerFor clears sweepOwned when it hands a writer to the
// write path). Both conditions are evaluated under the registry
// lock, so an eviction that raced the sweep — which removes the
// entry outright — simply finds nothing left to do here, and a
// delivery that adopted the writer keeps a registered, evictable
// one.
func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter,
) {
t.mu.Lock()
defer t.mu.Unlock()
cur, ok := t.writers[targetID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(t.writers, targetID)
}
// newWriter builds the writer for a database target's archive. Its
// path is the one ArchivePath gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive files.
func (t *databaseTarget) newWriter(
targetID string,
) (*archiveWriter, error) {
if t.eng.dbManager == nil {
return nil, errArchiveNoDataDir
}
var target database.Target
err := t.eng.database.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w.webhookID = target.WebhookID
return w, nil
}
// rename moves every one of a database target's archive files to the
// name for webhookName and targetName. It goes through the target's
// writer, so the move holds the lock that writes and the idle sweep
// take, and later writes use the new name.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict drops a database target's archive writer from the registry
// and closes its handle, so a deleted target does not leave a
// writer (and an open archive handle within its debounce window)
// alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (t *databaseTarget) evict(targetID string) {
t.mu.Lock()
w, ok := t.writers[targetID]
if ok {
delete(t.writers, targetID)
}
t.mu.Unlock()
if !ok {
return
}
w.evict()
t.eng.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// evictWebhook evicts, exactly as evict does, the writer of every
// database target of a webhook.
func (t *databaseTarget) evictWebhook(webhookID string) {
t.mu.Lock()
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict
// does for one target. The engine calls it at shutdown, once its
// workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
func (t *databaseTarget) evictAll() {
t.mu.Lock()
writers := t.writers
t.writers = nil
t.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive(
targetID string, expiry time.Duration,
) error {
w, created, err := t.sweepWriterFor(targetID)
if err != nil {
return err
}
if created {
defer t.releaseSweepWriter(targetID, w)
}
return w.sweepExpired(expiry)
}
@@ -1,6 +1,7 @@
package archive package delivery
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -17,10 +18,9 @@ import (
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
// ExpiryNever is the archive expiry, as the target forms take it, // archiveExpiryNever is the expiry sentinel (and default) that
// that disables pruning so archived rows are kept forever. It is // disables pruning so archived rows are kept forever.
// stored as an ArchiveExpiry of 0. const archiveExpiryNever = "never"
const ExpiryNever = "never"
// archiveReopenDebounce bounds how often an archive file is // archiveReopenDebounce bounds how often an archive file is
// closed and reopened. After each write the handle is closed // closed and reopened. After each write the handle is closed
@@ -71,14 +71,27 @@ var (
"archive writer has been evicted", "archive writer has been evicted",
) )
// ErrNameTaken is returned when an archive cannot be // ErrArchiveNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may // renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced. // be an archive with rows of its own, so it is never replaced.
ErrNameTaken = errors.New( ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name", "a file already has the archive's new name",
) )
) )
// databaseTargetConfig is the optional per-target JSON config
// for a database (archive) target.
type databaseTargetConfig struct {
// Expiry is a Go duration (e.g. "720h") after which
// archived rows are pruned, or "never" (the default) to
// keep them forever.
Expiry string `json:"expiry"`
// Rotation is none (the default), monthly, daily or hourly: see
// archivePeriod.
Rotation string `json:"rotation"`
}
// archivedEvent is one fully captured webhook event stored in a // archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a // database target's archive for long-term retention. It is a
// self-contained copy — independent of the per-webhook event // self-contained copy — independent of the per-webhook event
@@ -99,35 +112,78 @@ type archivedEvent struct {
ArchivedAt time.Time `gorm:"index"` ArchivedAt time.Time `gorm:"index"`
} }
// ParseExpiry reads a user-supplied archive expiry for a database // parseArchiveExpiry reads the optional expiry from a database
// target, as the target forms submit it, into the ArchiveExpiry the // target's config JSON. An empty config, an empty expiry, or
// target stores. Empty and "never" both mean keep forever, which is // the literal "never" all mean keep forever, returned as a zero
// 0; anything else must be a positive Go duration such as "720h". Any // duration. Any other value must parse as a positive Go
// other value is an error, so a bad expiry is refused at the form // duration; a set-but-invalid value (unparseable, zero, or
// rather than stored. // negative) is an error rather than a silent default, matching
func ParseExpiry(expiry string) (time.Duration, error) { // ValidateArchiveExpiry at target creation.
if expiry == "" || expiry == ExpiryNever { func parseArchiveExpiry(
configJSON string,
) (time.Duration, error) {
if configJSON == "" {
return 0, nil return 0, nil
} }
dur, err := time.ParseDuration(expiry) var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil { if err != nil {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"expiry must be %q or a Go duration "+ "parsing database target config: %w", err,
"such as \"720h\": %w", )
ExpiryNever, err, }
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return 0, nil
}
dur, err := time.ParseDuration(cfg.Expiry)
if err != nil {
return 0, fmt.Errorf(
"parsing archive expiry %q: %w", cfg.Expiry, err,
) )
} }
if dur <= 0 { if dur <= 0 {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, expiry, "%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
) )
} }
return dur, nil return dur, nil
} }
// ValidateArchiveExpiry checks a user-supplied archive expiry
// for a database target at configuration time. Valid values are
// empty, "never" (both meaning keep forever), or a positive Go
// duration such as "720h". Anything else is an error, so a bad
// expiry is rejected when the target is created rather than
// failing every subsequent delivery.
func ValidateArchiveExpiry(expiry string) error {
if expiry == "" || expiry == archiveExpiryNever {
return nil
}
dur, err := time.ParseDuration(expiry)
if err != nil {
return fmt.Errorf(
"expiry must be %q or a Go duration "+
"such as \"720h\": %w",
archiveExpiryNever, err,
)
}
if dur <= 0 {
return fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, expiry,
)
}
return nil
}
// archiveWriter owns one database target's archive SQLite files. // archiveWriter owns one database target's archive SQLite files.
// It serialises writes, and after each write closes and reopens // It serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so // the file (debounced to at most once per debounce window) so
@@ -138,7 +194,7 @@ func ParseExpiry(expiry string) (time.Duration, error) {
type archiveWriter struct { type archiveWriter struct {
mu sync.Mutex mu sync.Mutex
// path is the target's archive file as Path names it. A // path is the target's archive file as ArchivePath names it. A
// target that rotates writes to the files archivePeriodPath names // target that rotates writes to the files archivePeriodPath names
// for path and a period instead. // for path and a period instead.
path string path string
@@ -176,7 +232,7 @@ type archiveWriter struct {
// registry proper. // registry proper.
// //
// Unlike every other field here it is guarded by // Unlike every other field here it is guarded by
// Writers.mu, not by this writer's mu: it describes the // databaseTarget.mu, not by this writer's mu: it describes the
// registry entry rather than the file. // registry entry rather than the file.
sweepOwned bool sweepOwned bool
} }
@@ -443,7 +499,7 @@ func (w *archiveWriter) sweepFile(
// the next write creates its file under the new name. // the next write creates its file under the new name.
// //
// If a file already has one of the new names, nothing is moved and // If a file already has one of the new names, nothing is moved and
// the error is ErrNameTaken. If one file fails to move, those // the error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the // already moved are moved back before the error is returned, so the
// archive is never split across two names. // archive is never split across two names.
func (w *archiveWriter) rename(name string) error { func (w *archiveWriter) rename(name string) error {
@@ -481,7 +537,7 @@ func (w *archiveWriter) rename(name string) error {
for _, taken := range to { for _, taken := range to {
if fileExists(taken) { if fileExists(taken) {
return fmt.Errorf( return fmt.Errorf(
"%w: %s", ErrNameTaken, filepath.Base(taken), "%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
) )
} }
} }
@@ -534,7 +590,7 @@ func (w *archiveWriter) evict() {
// each row's archived time. It runs on every (re)open, so a // each row's archived time. It runs on every (re)open, so a
// steadily written archive is swept by its own write traffic. An // steadily written archive is swept by its own write traffic. An
// archive that goes idle receives no further reopens, which is // archive that goes idle receives no further reopens, which is
// why Sweeper exists to drive sweepExpired on a timer. // why ArchiveSweeper exists to drive sweepExpired on a timer.
// Failures are logged, not fatal: a prune error must not stop // Failures are logged, not fatal: a prune error must not stop
// archiving. // archiving.
func (w *archiveWriter) prune(expiry time.Duration) { func (w *archiveWriter) prune(expiry time.Duration) {
@@ -561,9 +617,9 @@ func (w *archiveWriter) prune(expiry time.Duration) {
} }
} }
// FileInfo is what the metadata of a database target's archive // ArchiveFileInfo is what the metadata of a database target's archive
// files says about them. // files says about them.
type FileInfo struct { type ArchiveFileInfo struct {
// Files counts the files. // Files counts the files.
Files int Files int
@@ -575,18 +631,18 @@ type FileInfo struct {
Written time.Time Written time.Time
} }
// Stat reads the metadata of a database target's archive // StatArchive reads the metadata of a database target's archive
// files, given the path Path gives it (see archiveFiles), and // files, given the path ArchivePath gives it (see archiveFiles), and
// of their -wal, without opening them. With no files, which is so // of their -wal, without opening them. With no files, which is so
// before the first write and after the operator moved them away, the // before the first write and after the operator moved them away, the
// error wraps fs.ErrNotExist. // error wraps fs.ErrNotExist.
func Stat(path string) (FileInfo, error) { func StatArchive(path string) (ArchiveFileInfo, error) {
files, err := archiveFiles(path) files, err := archiveFiles(path)
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
var info FileInfo var info ArchiveFileInfo
for _, file := range files { for _, file := range files {
db, err := os.Stat(file.path) db, err := os.Stat(file.path)
@@ -595,7 +651,7 @@ func Stat(path string) (FileInfo, error) {
} }
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
info.Files++ info.Files++
@@ -611,7 +667,7 @@ func Stat(path string) (FileInfo, error) {
} }
if err != nil { if err != nil {
return FileInfo{}, err return ArchiveFileInfo{}, err
} }
info.Size += wal.Size() info.Size += wal.Size()
@@ -622,7 +678,7 @@ func Stat(path string) (FileInfo, error) {
} }
if info.Files == 0 { if info.Files == 0 {
return FileInfo{}, fmt.Errorf( return ArchiveFileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist, "no archive file for %s: %w", path, fs.ErrNotExist,
) )
} }
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bytes" "bytes"
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
"sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
@@ -57,7 +57,7 @@ func (g *gormDefaultBuf) String() string {
// without exporting production code to carry it, and a logging // without exporting production code to carry it, and a logging
// detector is not worth a production symbol. What it detects is the // detector is not worth a production symbol. What it detects is the
// third gorm.Open in this service, at // third gorm.Open in this service, at
// internal/archive/writer.go — the archive writer, // internal/delivery/target_database_archive.go — the archive writer,
// whose type is unexported, so nothing outside this package can drive // whose type is unexported, so nothing outside this package can drive
// it. // it.
func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf { func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
@@ -85,7 +85,7 @@ func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
// writer's gorm.Open to the adapter. // writer's gorm.Open to the adapter.
// //
// Restore a bare &gorm.Config{} at // Restore a bare &gorm.Config{} at
// internal/archive/writer.go and this fails: the // internal/delivery/target_database_archive.go and this fails: the
// default logger prints the fully interpolated SELECT on every // default logger prints the fully interpolated SELECT on every
// ErrRecordNotFound, so the client-chosen event id below arrives whole // ErrRecordNotFound, so the client-chosen event id below arrives whole
// and unbounded on stdout, answering to no level the operator set. // and unbounded on stdout, answering to no level the operator set.
@@ -100,7 +100,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
gormDefault := captureArchiveGORMDefault(t) gormDefault := captureArchiveGORMDefault(t)
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive.db"), filepath.Join(t.TempDir(), "archive.db"),
slog.New(slog.NewTextHandler( slog.New(slog.NewTextHandler(
&captured, &slog.HandlerOptions{Level: slog.LevelDebug}, &captured, &slog.HandlerOptions{Level: slog.LevelDebug},
@@ -118,7 +118,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
value := strings.Repeat("\x01", archiveGORMFillBytes) + value := strings.Repeat("\x01", archiveGORMFillBytes) +
archiveGORMTailMarker archiveGORMTailMarker
var row archive.ExportArchivedEvent var row delivery.ExportArchivedEvent
err := w.DB().Where("event_id = ?", value).First(&row).Error err := w.DB().Where("event_id = ?", value).First(&row).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound) require.ErrorIs(t, err, gorm.ErrRecordNotFound)
@@ -1,6 +1,7 @@
package archive_test package delivery_test
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -12,8 +13,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// deliverTo archives one event to a database target, leaving the // deliverTo archives one event to a database target, leaving the
@@ -23,9 +24,12 @@ func deliverTo(
) { ) {
t.Helper() t.Helper()
event := newEvent(`{"archived":true}`) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
} }
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting // TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
@@ -37,28 +41,28 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, 0, "") first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, 0, "") second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, 0, "") other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} { for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
require.True( require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the writer should hold an open handle after a write", "the writer should hold an open handle after a write",
) )
} }
env.writers.EvictWebhook(first.WebhookID) env.eng.EvictWebhook(first.WebhookID)
for _, tgt := range []*database.Target{first, second} { for _, tgt := range []*database.Target{first, second} {
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"eviction should remove the registry entry", "eviction should remove the registry entry",
) )
assert.False( assert.False(
t, env.writers.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"eviction should close the archive handle", "eviction should close the archive handle",
) )
assert.FileExists( assert.FileExists(
@@ -68,7 +72,7 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
} }
assert.True( assert.True(
t, env.writers.ExportArchiveHandleOpen(other.ID), t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone", "another webhook's writer must be left alone",
) )
} }
@@ -80,21 +84,21 @@ func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, 0, "") doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, 0, "") kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed) deliverTo(t, env, doomed)
deliverTo(t, env, kept) deliverTo(t, env, kept)
env.writers.EvictTarget(doomed.ID) env.eng.EvictTarget(doomed.ID)
assert.False(t, env.writers.ExportHasArchiveWriter(doomed.ID)) assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists( assert.FileExists(
t, env.archivePath(doomed), t, env.archivePath(doomed),
"eviction must not delete the archive file", "eviction must not delete the archive file",
) )
assert.True( assert.True(
t, env.writers.ExportArchiveHandleOpen(kept.ID), t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone", "the other target's writer must be left alone",
) )
} }
@@ -108,21 +112,21 @@ func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
env := setupArchiveTest(t) env := setupArchiveTest(t)
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
env.writers.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
env.writers.EvictWebhook("no-such-webhook") env.eng.EvictWebhook("no-such-webhook")
env.writers.EvictTarget("no-such-target") env.eng.EvictTarget("no-such-target")
env.writers.EvictTarget("no-such-target") env.eng.EvictTarget("no-such-target")
}) })
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter("no-such-target"), t, env.eng.ExportHasArchiveWriter("no-such-target"),
"eviction must not create a writer", "eviction must not create a writer",
) )
} }
// evictTestRow builds an archive row for the eviction tests. // evictTestRow builds an archive row for the eviction tests.
func evictTestRow(eventID string) archive.ExportArchivedEvent { func evictTestRow(eventID string) delivery.ExportArchivedEvent {
return archive.ExportArchivedEvent{ return delivery.ExportArchivedEvent{
EventID: eventID, EventID: eventID,
WebhookID: "wh-evict", WebhookID: "wh-evict",
Method: http.MethodPost, Method: http.MethodPost,
@@ -143,7 +147,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive-evicted.db") path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -160,7 +164,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
err := w.Write(evictTestRow("ev-2"), 0) err := w.Write(evictTestRow("ev-2"), 0)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse writes", "an evicted writer must refuse writes",
) )
assert.NoFileExists( assert.NoFileExists(
@@ -183,7 +187,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "archive-evicted.db") path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0, path, archiveTestLogger(), 0,
) )
@@ -195,7 +199,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
err := w.SweepExpired(time.Hour) err := w.SweepExpired(time.Hour)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse an idle sweep", "an evicted writer must refuse an idle sweep",
) )
assert.False( assert.False(
@@ -226,7 +230,7 @@ const racingWriteGoroutines = 4
// against a hang, not a timing assumption: the first write after // against a hang, not a timing assumption: the first write after
// the eviction is refused. // the eviction is refused.
func startRacingWrites( func startRacingWrites(
w *archive.ExportArchiveWriter, w *delivery.ExportArchiveWriter,
) *racingWrites { ) *racingWrites {
r := &racingWrites{ r := &racingWrites{
started: make(chan struct{}, racingWriteGoroutines), started: make(chan struct{}, racingWriteGoroutines),
@@ -272,7 +276,7 @@ func (r *racingWrites) record(err error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if errors.Is(err, archive.ErrExportArchiveWriterEvicted) { if errors.Is(err, delivery.ErrExportArchiveWriterEvicted) {
r.sawEvicted = true r.sawEvicted = true
return return
@@ -310,13 +314,13 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
// Prime the registry so the test can hold the very writer the // Prime the registry so the test can hold the very writer the
// eviction is about to detach. // eviction is about to detach.
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID) w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
@@ -326,7 +330,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex. // eviction has to contend for the writer's mutex.
race.awaitFirstWrite() race.awaitFirstWrite()
env.writers.EvictWebhook(tgt.WebhookID) env.eng.EvictWebhook(tgt.WebhookID)
sawEvicted, otherErr := race.wait() sawEvicted, otherErr := race.wait()
@@ -341,7 +345,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted", "been evicted",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the registry entry must stay gone", "the registry entry must stay gone",
) )
} }
@@ -355,45 +359,45 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID)) require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.writers.EvictWebhook(tgt.WebhookID) env.eng.EvictWebhook(tgt.WebhookID)
// A fresh delivery for the same target gets a brand new // A fresh delivery for the same target gets a brand new
// writer from the registry, so archiving keeps working. // writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
assert.True( assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a later delivery should recreate the writer", "a later delivery should recreate the writer",
) )
} }
// TestEvictAll_WriteAfterIsRefused proves that EvictAll, which the // TestEngineStop_WriteAfterStopIsRefused proves the engine's stop
// delivery engine calls when it stops, closes each archive writer the // closes each archive writer the way deleting its webhook does: a
// way deleting its webhook does: a write that reaches a writer after // write that reaches a writer after the stop is refused, reopens
// it is refused, reopens nothing and adds no row. // nothing and adds no row.
func TestEvictAll_WriteAfterIsRefused(t *testing.T) { func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, "") tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID) w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
env.writers.EvictAll() require.NoError(t, env.eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0) err := w.Write(evictTestRow("ev-after-stop"), 0)
require.ErrorIs( require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted, t, err, delivery.ErrExportArchiveWriterEvicted,
"a write after the stop must be refused", "a write after the stop must be refused",
) )
assert.False( assert.False(
@@ -401,7 +405,7 @@ func TestEvictAll_WriteAfterIsRefused(t *testing.T) {
"a refused write must not reopen the archive", "a refused write must not reopen the archive",
) )
assert.False( assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the stop should empty the registry", "the stop should empty the registry",
) )
@@ -1,4 +1,4 @@
package archive package delivery
import ( import (
"compress/gzip" "compress/gzip"
@@ -28,25 +28,25 @@ import (
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " + const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'" "WHERE type = 'table' AND name = 'archived_events'"
// Path returns where a database target's archive file is: in // ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the // the data directory, beside the webhook's event database, under the
// name FileName gives it. // name ArchiveFileName gives it.
func Path( func ArchivePath(
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
webhook *database.Webhook, webhook *database.Webhook,
target *database.Target, target *database.Target,
) string { ) string {
return filepath.Join( return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)), filepath.Dir(dbMgr.DBPath(webhook.ID)),
FileName(webhook.Name, target.Name, target.ID), ArchiveFileName(webhook.Name, target.Name, target.ID),
) )
} }
// ExportFileName returns the name a database target's archive // ArchiveExportFileName returns the name a database target's archive
// downloads under: // downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names // archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in FileName and the time in UTC. // made safe as in ArchiveFileName and the time in UTC.
func ExportFileName( func ArchiveExportFileName(
webhookName, targetName string, at time.Time, webhookName, targetName string, at time.Time,
) string { ) string {
return "archive-" + archiveNamePart(webhookName) + "-" + return "archive-" + archiveNamePart(webhookName) + "-" +
@@ -54,7 +54,7 @@ func ExportFileName(
at.UTC().Format("20060102T150405Z") + ".json.gz" at.UTC().Format("20060102T150405Z") + ".json.gz"
} }
// Export is a database target's archive listed for download. It // ArchiveExport is a database target's archive listed for download. It
// opens one of the target's files at a time, only when its rows are // opens one of the target's files at a time, only when its rows are
// about to be written out, and closes it before it opens the next, so // about to be written out, and closes it before it opens the next, so
// an export holds at most one file open however many the target has. // an export holds at most one file open however many the target has.
@@ -66,7 +66,7 @@ func ExportFileName(
// is open, and the export does not see them. SQLite cannot checkpoint // is open, and the export does not see them. SQLite cannot checkpoint
// a -wal past an open snapshot, so the open file's -wal grows until the // a -wal past an open snapshot, so the open file's -wal grows until the
// export has written that file out. // export has written that file out.
type Export struct { type ArchiveExport struct {
// periods are the periods of the target's files when the export // periods are the periods of the target's files when the export
// was listed, "" for the file without one, in the order // was listed, "" for the file without one, in the order
// archiveFiles lists them. // archiveFiles lists them.
@@ -77,7 +77,7 @@ type Export struct {
// between. // between.
lock sync.Locker lock sync.Locker
// currentPath returns the path Path gives the target under // currentPath returns the path ArchivePath gives the target under
// the names stored for it now, which a rename may have changed since // the names stored for it now, which a rename may have changed since
// the export was listed. // the export was listed.
currentPath func() (string, error) currentPath func() (string, error)
@@ -103,12 +103,11 @@ type exportedName struct {
Name string `json:"name"` Name string `json:"name"`
} }
// NewExport lists a database target's archive files for export, // NewArchiveExport lists a database target's archive files for export,
// given the path Path gives it (see archiveFiles). It opens none // given the path ArchivePath gives it (see archiveFiles). It opens none
// of them. Its caller holds lock, which every rename of the target's // of them. Its caller holds lock, which every rename of the target's
// files runs under (Writers.RenameLock returns it), from reading the names // files runs under, from reading the names path is made of until it
// path is made of until it returns, so the files it lists are the ones // returns, so the files it lists are the ones those names give.
// those names give.
// //
// WriteGzipJSON, called without lock held, finds each file again by its // WriteGzipJSON, called without lock held, finds each file again by its
// period under the path currentPath gives, holding lock while it does // period under the path currentPath gives, holding lock while it does
@@ -116,18 +115,18 @@ type exportedName struct {
// file. A file that is gone by then, emptied by the sweep or moved // file. A file that is gone by then, emptied by the sweep or moved
// away, is skipped. The export never creates a file: with no files, it // away, is skipped. The export never creates a file: with no files, it
// has no rows. // has no rows.
func NewExport( func NewArchiveExport(
path string, path string,
lock sync.Locker, lock sync.Locker,
currentPath func() (string, error), currentPath func() (string, error),
log *slog.Logger, log *slog.Logger,
) (*Export, error) { ) (*ArchiveExport, error) {
files, err := archiveFiles(path) files, err := archiveFiles(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
x := &Export{lock: lock, currentPath: currentPath, log: log} x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
for _, file := range files { for _, file := range files {
x.periods = append(x.periods, file.period) x.periods = append(x.periods, file.period)
@@ -200,7 +199,7 @@ func openExportFile(
// closed once its rows are written, before the next is opened. When it // closed once its rows are written, before the next is opened. When it
// returns, no file is open. After an error the gzip stream is left // returns, no file is open. After an error the gzip stream is left
// unfinished, so what was written does not decompress as a whole file. // unfinished, so what was written does not decompress as a whole file.
func (x *Export) WriteGzipJSON( func (x *ArchiveExport) WriteGzipJSON(
ctx context.Context, ctx context.Context,
w io.Writer, w io.Writer,
webhook *database.Webhook, webhook *database.Webhook,
@@ -229,7 +228,7 @@ func (x *Export) WriteGzipJSON(
// openFile finds the target's archive file for period under the path // openFile finds the target's archive file for period under the path
// currentPath gives now, and opens it for the export, holding x.lock // currentPath gives now, and opens it for the export, holding x.lock
// for both. For a file that is gone, the error wraps fs.ErrNotExist. // for both. For a file that is gone, the error wraps fs.ErrNotExist.
func (x *Export) openFile( func (x *ArchiveExport) openFile(
ctx context.Context, period string, ctx context.Context, period string,
) (*exportFile, error) { ) (*exportFile, error) {
x.lock.Lock() x.lock.Lock()
@@ -259,7 +258,7 @@ func (f *exportFile) close() error {
// writeJSON writes head with archived_events added as its last key, // writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time. // the rows going into it one at a time.
func (x *Export) writeJSON( func (x *ArchiveExport) writeJSON(
ctx context.Context, w io.Writer, head []byte, ctx context.Context, w io.Writer, head []byte,
) error { ) error {
// head goes out without its closing brace, so that // head goes out without its closing brace, so that
@@ -287,7 +286,7 @@ func (x *Export) writeJSON(
// writeRows writes the archived rows of each file to w, one per line, // writeRows writes the archived rows of each file to w, one per line,
// separated by commas, opening each file in turn and closing it once // separated by commas, opening each file in turn and closing it once
// its rows are written. // its rows are written.
func (x *Export) writeRows(ctx context.Context, w io.Writer) error { func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
sep := "\n" sep := "\n"
for _, period := range x.periods { for _, period := range x.periods {
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"bufio" "bufio"
@@ -19,8 +19,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The webhook and the target the export tests' archives belong to. // The webhook and the target the export tests' archives belong to.
@@ -37,7 +37,7 @@ const binaryBody = "\xff\xfe\x00\x01binary\x80"
// writeExportTo writes export to w as the archive of the export tests' // writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z. // webhook and target, exported at 2026-10-02T12:03:04Z.
func writeExportTo( func writeExportTo(
t *testing.T, export *archive.Export, w io.Writer, t *testing.T, export *delivery.ArchiveExport, w io.Writer,
) error { ) error {
t.Helper() t.Helper()
@@ -57,7 +57,7 @@ func writeExportTo(
// listExport lists the archive at path for export, as the archive of a // listExport lists the archive at path for export, as the archive of a
// target whose names do not change. // target whose names do not change.
func listExport(t *testing.T, path string) *archive.Export { func listExport(t *testing.T, path string) *delivery.ArchiveExport {
t.Helper() t.Helper()
return newExport(t, path, &sync.Mutex{}, func() (string, error) { return newExport(t, path, &sync.Mutex{}, func() (string, error) {
@@ -73,10 +73,10 @@ func newExport(
path string, path string,
lock sync.Locker, lock sync.Locker,
currentPath func() (string, error), currentPath func() (string, error),
) *archive.Export { ) *delivery.ArchiveExport {
t.Helper() t.Helper()
export, err := archive.NewExport( export, err := delivery.NewArchiveExport(
path, lock, currentPath, archiveTestLogger(), path, lock, currentPath, archiveTestLogger(),
) )
require.NoError(t, err) require.NoError(t, err)
@@ -96,7 +96,7 @@ func exportArchive(t *testing.T, path string) map[string]any {
// decompressed and parsed. Reading to the end makes the gzip reader // decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished. // check that the stream was finished.
func writeExport( func writeExport(
t *testing.T, export *archive.Export, t *testing.T, export *delivery.ArchiveExport,
) map[string]any { ) map[string]any {
t.Helper() t.Helper()
@@ -157,11 +157,11 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody} bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies { for i, body := range bodies {
require.NoError(t, w.Write(archive.ExportArchivedEvent{ require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i), EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID, WebhookID: exportWebhookID,
EntrypointID: "ep-1", EntrypointID: "ep-1",
@@ -173,7 +173,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
}, 0)) }, 0))
} }
var stored []archive.ExportArchivedEvent var stored []delivery.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path). require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error) Order("id").Find(&stored).Error)
@@ -201,7 +201,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
// assertExportedRow checks that ev, from an export, holds every column // assertExportedRow checks that ev, from an export, holds every column
// of the stored row. // of the stored row.
func assertExportedRow( func assertExportedRow(
t *testing.T, row archive.ExportArchivedEvent, ev map[string]any, t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
) { ) {
t.Helper() t.Helper()
@@ -248,7 +248,7 @@ func TestArchiveExport_Empty(t *testing.T) {
require.NoError(t, os.WriteFile(noTable, nil, 0o600)) require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t, require.NoError(t,
archive.NewExportArchiveWriter(noRows, archiveTestLogger(), 0). delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0), Open(0),
) )
@@ -284,18 +284,18 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "listed"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
opened := &unlockHook{fn: func() { opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "during"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
}} }}
export := newExport(t, path, opened, func() (string, error) { export := newExport(t, path, opened, func() (string, error) {
return path, nil return path, nil
}) })
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "before-open"}, 0)) require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
assert.Equal(t, assert.Equal(t,
[]string{"listed", "before-open"}, []string{"listed", "before-open"},
@@ -305,7 +305,7 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
var stored int64 var stored int64
require.NoError(t, openArchiveDBForRead(t, path). require.NoError(t, openArchiveDBForRead(t, path).
Model(&archive.ExportArchivedEvent{}).Count(&stored).Error) Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(3), stored) assert.Equal(t, int64(3), stored)
} }
@@ -318,11 +318,11 @@ func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "archive-old.db") path := filepath.Join(dir, "archive-old.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
for _, period := range []string{"", dayPeriod, hourPeriod} { for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
)) ))
} }
@@ -353,19 +353,19 @@ func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db") path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Written in an order that is not the export's. // Written in an order that is not the export's.
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} { for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
)) ))
} }
export := listExport(t, path) export := listExport(t, path)
require.NoError(t, w.WritePeriod( require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
)) ))
events := exportedEvents(t, writeExport(t, export)) events := exportedEvents(t, writeExport(t, export))
@@ -434,13 +434,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
path := filepath.Join(dir, "archive-wh.db") path := filepath.Join(dir, "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10) random := make([]byte, 36<<10)
for hour := range 24 { for hour := range 24 {
_, _ = rand.Read(random) _, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(archive.ExportArchivedEvent{ require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random), Body: base64.StdEncoding.EncodeToString(random),
}, 0, fmt.Sprintf("2026-10-01-%02d", hour))) }, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
} }
@@ -489,7 +489,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "archive.db") path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes. // Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3) random := make([]byte, bodySize/4*3)
@@ -497,7 +497,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
for range rows { for range rows {
_, _ = rand.Read(random) _, _ = rand.Read(random)
require.NoError(t, w.Write(archive.ExportArchivedEvent{ require.NoError(t, w.Write(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random), Body: base64.StdEncoding.EncodeToString(random),
}, 0)) }, 0))
} }
@@ -565,7 +565,7 @@ func TestArchiveExportFileName(t *testing.T) {
assert.Equal(t, assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz", "archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
archive.ExportFileName( delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName, exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest), time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
), ),
@@ -1,6 +1,7 @@
package archive package delivery
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -40,10 +41,10 @@ type archiveFile struct {
period string period string
} }
// ValidateRotation checks a user-supplied archive rotation for // ValidateArchiveRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly, // a database target: empty or none (both meaning one file), monthly,
// daily or hourly. // daily or hourly.
func ValidateRotation(rotation string) error { func ValidateArchiveRotation(rotation string) error {
switch rotation { switch rotation {
case "", archiveRotationNone, archiveRotationMonthly, case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly: archiveRotationDaily, archiveRotationHourly:
@@ -53,10 +54,35 @@ func ValidateRotation(rotation string) error {
} }
} }
// parseArchiveRotation reads the rotation from a database target's
// config JSON. An empty config or an empty rotation is none.
func parseArchiveRotation(configJSON string) (string, error) {
if configJSON == "" {
return archiveRotationNone, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return "", fmt.Errorf("parsing database target config: %w", err)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return "", err
}
if cfg.Rotation == "" {
return archiveRotationNone, nil
}
return cfg.Rotation, nil
}
// archivePeriod returns the period, in UTC, that a rotation puts an // archivePeriod returns the period, in UTC, that a rotation puts an
// event received at receivedAt in: "2026-10" for monthly, // event received at receivedAt in: "2026-10" for monthly,
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none // "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
// or an empty rotation.
func archivePeriod(rotation string, receivedAt time.Time) string { func archivePeriod(rotation string, receivedAt time.Time) string {
switch rotation { switch rotation {
case archiveRotationMonthly: case archiveRotationMonthly:
@@ -71,7 +97,7 @@ func archivePeriod(rotation string, receivedAt time.Time) string {
} }
// archivePeriodPath returns the path of a database target's archive // archivePeriodPath returns the path of a database target's archive
// file for a period: path, as Path gives it, with "-" and the // file for a period: path, as ArchivePath gives it, with "-" and the
// period put before its ".db". The period "" gives path itself. // period put before its ".db". The period "" gives path itself.
func archivePeriodPath(path, period string) string { func archivePeriodPath(path, period string) string {
if period == "" { if period == "" {
@@ -81,23 +107,28 @@ func archivePeriodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db" return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
} }
// PathAt returns the archive file a database target writes an // ArchivePathAt returns the archive file a database target writes an
// event received at receivedAt to: Path's file, with the // event received at receivedAt to: ArchivePath's file, with the
// period in its name when the target rotates. // period in its name when the target rotates.
func PathAt( func ArchivePathAt(
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
webhook *database.Webhook, webhook *database.Webhook,
target *database.Target, target *database.Target,
receivedAt time.Time, receivedAt time.Time,
) string { ) (string, error) {
rotation, err := parseArchiveRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath( return archivePeriodPath(
Path(dbMgr, webhook, target), ArchivePath(dbMgr, webhook, target),
archivePeriod(target.ArchiveRotation, receivedAt), archivePeriod(rotation, receivedAt),
) ), nil
} }
// archiveFiles lists the database target's archive files that exist, // archiveFiles lists the database target's archive files that exist,
// given the path Path gives it: the file at path, then each // given the path ArchivePath gives it: the file at path, then each
// file archivePeriodPath names for path and a period, oldest period // file archivePeriodPath names for path and a period, oldest period
// first. Which rotation wrote a file does not matter, so the files of // first. Which rotation wrote a file does not matter, so the files of
// an earlier rotation setting are listed too. // an earlier rotation setting are listed too.
@@ -1,4 +1,4 @@
package archive_test package delivery_test
import ( import (
"os" "os"
@@ -9,17 +9,21 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The archive rotations. // The archive rotations, and the configs of a daily target and of one
// whose rotation is not one of the four.
const ( const (
rotationNone = "none" rotationNone = "none"
rotationMonthly = "monthly" rotationMonthly = "monthly"
rotationDaily = "daily" rotationDaily = "daily"
rotationHourly = "hourly" rotationHourly = "hourly"
dailyConfig = `{"rotation":"daily"}`
weeklyConfig = `{"rotation":"weekly"}`
) )
// The periods the tests archive into most: two days, and an hour of // The periods the tests archive into most: two days, and an hour of
@@ -36,30 +40,32 @@ func periodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db" return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
} }
// deliverReceivedAt archives, as a delivery to a database target // deliverReceivedAt delivers to a database target an event whose
// does, an event whose receive time is receivedAt, and returns the // receive time is receivedAt, and returns the event's id. The receive
// event's id. The receive time is what decides a rotated archive's // time is what decides a rotated archive's file, so setting it is how
// file, so setting it is how these tests move the clock across a // these tests move the clock across a period boundary.
// period boundary.
func (env *archiveEnv) deliverReceivedAt( func (env *archiveEnv) deliverReceivedAt(
t *testing.T, tgt *database.Target, receivedAt time.Time, t *testing.T, tgt *database.Target, receivedAt time.Time,
) string { ) string {
t.Helper() t.Helper()
event := newEvent(`{"n":1}`) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
event.CreatedAt = receivedAt event.CreatedAt = receivedAt
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
return event.ID return event.ID
} }
// TestWrite_RotatesAtEachPeriodBoundary writes, for each // TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
// rotation, an event received in the last second of a period and one // rotation, an event received in the last second of a period and one
// received in the first second of the next, and checks each lands in // received in the first second of the next, and checks each lands in
// the file named for its own period, in UTC. Rotation none keeps both // the file named for its own period, in UTC. Rotation none keeps both
// in the one file. // in the one file.
func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) { func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
berlin := time.FixedZone("CEST", 2*60*60) berlin := time.FixedZone("CEST", 2*60*60)
@@ -104,7 +110,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, tc.rotation) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
first := env.deliverReceivedAt(t, tgt, tc.before) first := env.deliverReceivedAt(t, tgt, tc.before)
@@ -123,7 +129,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
first := env.deliverReceivedAt(t, tgt, cases[0].before) first := env.deliverReceivedAt(t, tgt, cases[0].before)
second := env.deliverReceivedAt(t, tgt, cases[0].after) second := env.deliverReceivedAt(t, tgt, cases[0].after)
@@ -133,14 +139,14 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
}) })
} }
// TestWrite_EventWithoutReceiveTime proves an event whose // TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
// receive time is not known goes to the file for the time it is // receive time is not known goes to the file for the time it is
// archived, rather than to one for the year 1. // archived, rather than to one for the year 1.
func TestWrite_EventWithoutReceiveTime(t *testing.T) { func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationMonthly) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
path := env.archivePath(tgt) path := env.archivePath(tgt)
before := time.Now().UTC().Format("2006-01") before := time.Now().UTC().Format("2006-01")
@@ -158,20 +164,20 @@ func TestWrite_EventWithoutReceiveTime(t *testing.T) {
assert.NoFileExists(t, periodPath(path, "0001-01")) assert.NoFileExists(t, periodPath(path, "0001-01"))
} }
// TestWrite_RotationChangeKeepsOldFiles changes a target's // TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
// rotation from none to daily between two events, and checks the // rotation from none to daily between two events, and checks the
// second goes to the daily file while the first stays where it was. // second goes to the daily file while the first stays where it was.
func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) { func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, "")
path := env.archivePath(tgt) path := env.archivePath(tgt)
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
first := env.deliverReceivedAt(t, tgt, at) first := env.deliverReceivedAt(t, tgt, at)
tgt.ArchiveRotation = rotationDaily tgt.Config = dailyConfig
second := env.deliverReceivedAt(t, tgt, at) second := env.deliverReceivedAt(t, tgt, at)
assert.Equal(t, []string{first}, archivedEventIDs(t, path)) assert.Equal(t, []string{first}, archivedEventIDs(t, path))
@@ -189,7 +195,9 @@ func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, time.Hour, rotationDaily) tgt := env.seedDatabaseTarget(
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
)
path := env.archivePath(tgt) path := env.archivePath(tgt)
emptied := periodPath(path, dayPeriod) emptied := periodPath(path, dayPeriod)
kept := periodPath(path, nextDayPeriod) kept := periodPath(path, nextDayPeriod)
@@ -219,17 +227,17 @@ func TestRename_MovesEveryFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationNone) tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt) oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
unrotated := env.deliverReceivedAt(t, tgt, day) unrotated := env.deliverReceivedAt(t, tgt, day)
tgt.ArchiveRotation = rotationDaily tgt.Config = dailyConfig
first := env.deliverReceivedAt(t, tgt, day) first := env.deliverReceivedAt(t, tgt, day)
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
require.NoError(t, env.writers.Rename(tgt.ID, "Orders", "Long Term")) require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
newPath := filepath.Join( newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
@@ -261,7 +269,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, 0, rotationDaily) tgt := env.seedDatabaseTarget(t, dailyConfig)
oldPath := env.archivePath(tgt) oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
@@ -275,8 +283,8 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
require.ErrorIs( require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"), t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken, delivery.ErrArchiveNameTaken,
) )
assert.FileExists(t, periodPath(oldPath, dayPeriod)) assert.FileExists(t, periodPath(oldPath, dayPeriod))
@@ -284,7 +292,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
assert.NoFileExists(t, periodPath(newPath, dayPeriod)) assert.NoFileExists(t, periodPath(newPath, dayPeriod))
} }
// TestStatArchive_EveryFile proves Stat counts and adds up every // TestStatArchive_EveryFile proves StatArchive counts and adds up every
// one of a target's files, takes the latest write of any of them, and // one of a target's files, takes the latest write of any of them, and
// leaves out files whose names only look like the target's. // leaves out files whose names only look like the target's.
func TestStatArchive_EveryFile(t *testing.T) { func TestStatArchive_EveryFile(t *testing.T) {
@@ -316,7 +324,7 @@ func TestStatArchive_EveryFile(t *testing.T) {
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
} }
got, err := archive.Stat(path) got, err := delivery.StatArchive(path)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, len(files), got.Files) assert.Equal(t, len(files), got.Files)
assert.Equal(t, size, got.Size) assert.Equal(t, size, got.Size)
@@ -335,27 +343,34 @@ func TestArchivePathAt(t *testing.T) {
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC) at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
cases := map[string]string{ cases := map[string]string{
"": "", "": "",
rotationNone: "", `{"rotation":"` + rotationNone + `"}`: "",
rotationMonthly: "-2026-10", `{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
rotationDaily: "-2026-10-01", dailyConfig: "-2026-10-01",
rotationHourly: "-2026-10-01-19", `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
} }
for rotation, period := range cases { for config, period := range cases {
target := &database.Target{ target := &database.Target{
BaseModel: database.BaseModel{ID: "tgt-id"}, BaseModel: database.BaseModel{ID: "tgt-id"},
Name: "Archive", Name: "Archive",
ArchiveRotation: rotation, Config: config,
} }
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
require.NoError(t, err, config)
assert.Equal(t, assert.Equal(t,
filepath.Join( filepath.Join(
dataDir, "archive-orders-archive-tgt-id"+period+".db", dataDir, "archive-orders-archive-tgt-id"+period+".db",
), ),
archive.PathAt(dbMgr, webhook, target, at), rotation, got, config,
) )
} }
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
Config: weeklyConfig,
}, at)
require.Error(t, err)
} }
// TestValidateArchiveRotation accepts the four rotations, and empty, // TestValidateArchiveRotation accepts the four rotations, and empty,
@@ -366,10 +381,10 @@ func TestValidateArchiveRotation(t *testing.T) {
for _, ok := range []string{ for _, ok := range []string{
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly, "", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} { } {
require.NoError(t, archive.ValidateRotation(ok), ok) require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
} }
for _, bad := range []string{"weekly", "Daily", "hourly "} { for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, archive.ValidateRotation(bad), bad) require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
} }
} }
+622 -247
View File
@@ -1,69 +1,26 @@
package delivery_test package delivery_test
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
) )
// archiveTestWebhookName is the name of every webhook
// seedDatabaseTarget creates. It is not safe in a file name as it
// stands, so every archive file name made from it is made safe first.
const archiveTestWebhookName = "Sweep Test!"
// archiveEnv bundles the pieces a database target test drives: a main
// configuration database holding webhooks and targets, a delivery
// engine whose archive writers write into the data directory, and that
// directory.
type archiveEnv struct {
eng *delivery.Engine
mainDB *database.Database
dataDir string
}
func setupArchiveTest(t *testing.T) *archiveEnv {
t.Helper()
dataDir := t.TempDir()
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler))
require.NoError(t, err)
t.Cleanup(func() { _ = mainDB.Close() })
return &archiveEnv{
eng: delivery.NewTestEngineWithDB(
mainDB,
databasetest.NewWebhookDBManager(t, dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
),
mainDB: mainDB,
dataDir: dataDir,
}
}
func archiveTestLogger() *slog.Logger { func archiveTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler( return slog.New(slog.NewTextHandler(
os.Stderr, os.Stderr,
@@ -71,87 +28,8 @@ func archiveTestLogger() *slog.Logger {
)) ))
} }
// archivePath returns where the engine keeps a database target's
// archive file, for the names seedDatabaseTarget gave it.
func (env *archiveEnv) archivePath(tgt *database.Target) string {
return filepath.Join(
env.dataDir,
archive.FileName(archiveTestWebhookName, tgt.Name, tgt.ID),
)
}
// seedDatabaseTarget creates a webhook with one database target,
// which keeps its archive forever in one file, and returns the
// target.
func (env *archiveEnv) seedDatabaseTarget(
t *testing.T,
) *database.Target {
t.Helper()
wh := &database.Webhook{
UserID: uuid.New().String(),
Name: archiveTestWebhookName,
}
require.NoError(
t,
env.mainDB.DB().
Omit(clause.Associations).
Create(wh).Error,
)
tgt := &database.Target{
WebhookID: wh.ID,
Name: "Archive",
Type: database.TargetTypeDatabase,
Active: true,
ArchiveRotation: "none",
MaxAttempts: 1,
}
require.NoError(
t,
env.mainDB.DB().
Omit(clause.Associations).
Create(tgt).Error,
)
return tgt
}
// seedDatabaseTargetDelivery seeds a pending delivery of an event
// to a database target and returns the in-memory delivery the
// target handler is invoked with.
func seedDatabaseTargetDelivery(
t *testing.T,
webhookDB *gorm.DB,
event database.Event,
tgt *database.Target,
) *database.Delivery {
t.Helper()
dlv := seedDelivery(
t, webhookDB, event.ID, tgt.ID,
database.DeliveryStatusPending,
)
d := &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: tgt.Name,
Type: database.TargetTypeDatabase,
ArchiveExpiry: tgt.ArchiveExpiry,
ArchiveRotation: tgt.ArchiveRotation,
},
}
d.ID = dlv.ID
return d
}
// openArchiveDBForRead opens an archive file read-only so a // openArchiveDBForRead opens an archive file read-only so a
// test can inspect the rows the database target archived. // test can inspect the rows the writer persisted.
func openArchiveDBForRead( func openArchiveDBForRead(
t *testing.T, path string, t *testing.T, path string,
) *gorm.DB { ) *gorm.DB {
@@ -174,45 +52,26 @@ func openArchiveDBForRead(
return gdb return gdb
} }
// countArchivedRows counts the rows in an archive file without // archiveFileSuffixes returns the archive file itself and the
// asserting anything, so it is safe to poll from an // SQLite sidecars that accompany an open database. A test that
// assert.Eventually condition (which runs off the test // asserts no archive was created has to check all of them.
// goroutine, where testify assertions must not be used). func archiveFileSuffixes() []string {
func countArchivedRows(path string) (int64, error) { return []string{"", "-wal", "-shm"}
sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=ro", path),
)
if err != nil {
return 0, err
}
defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
if err != nil {
return 0, err
}
var count int64
err = gdb.Table("archived_events").Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
} }
// archivedRow is the columns of an archive row these tests read. // removeArchiveFiles simulates an operator moving the archive
type archivedRow struct { // away by deleting the SQLite file and its sidecar files.
EventID string func removeArchiveFiles(t *testing.T, path string) {
WebhookID string t.Helper()
Method string
RawQuery string for _, suffix := range []string{
Body string "", "-wal", "-shm", "-journal",
} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("removing %s%s: %v", path, suffix, err)
}
}
} }
// TestDeliverDatabase_ArchivesEvent verifies that delivering to // TestDeliverDatabase_ArchivesEvent verifies that delivering to
@@ -222,7 +81,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t) tgt := env.seedDatabaseTarget(t, "")
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) event := seedEvent(t, webhookDB, `{"archived":true}`)
@@ -248,9 +107,9 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
rdb := openArchiveDBForRead(t, archivePath) rdb := openArchiveDBForRead(t, archivePath)
var rows []archivedRow var rows []delivery.ExportArchivedEvent
require.NoError(t, rdb.Table("archived_events").Find(&rows).Error) require.NoError(t, rdb.Find(&rows).Error)
require.Len(t, rows, 1) require.Len(t, rows, 1)
assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.ID, rows[0].EventID)
assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.WebhookID, rows[0].WebhookID)
@@ -259,10 +118,266 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
assert.JSONEq(t, `{"archived":true}`, rows[0].Body) assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
} }
func TestArchiveWriter_WritesRow(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
row := delivery.ExportArchivedEvent{
EventID: "ev-1",
WebhookID: "wh-1",
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X":"Y"}`,
Body: `{"hello":"world"}`,
ContentType: "application/json",
}
require.NoError(t, w.Write(row, 0))
assert.FileExists(t, path)
var got []delivery.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "ev-1", got[0].EventID)
assert.Equal(t, "wh-1", got[0].WebhookID)
assert.Equal(t, "ep-1", got[0].EntrypointID)
assert.Equal(t, row.Method, got[0].Method)
assert.Equal(t, row.ContentType, got[0].ContentType)
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
assert.False(t, got[0].ArchivedAt.IsZero())
var archivedAt string
require.NoError(t, w.DB().Raw(
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
).Row().Scan(&archivedAt))
assertStoredTime(t, archivedAt)
}
func TestArchiveWriter_RecreatesAfterRemoval(
t *testing.T,
) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0,
))
assert.FileExists(t, path)
// The operator moves the archive away while the handle is
// still open.
removeArchiveFiles(t, path)
require.NoFileExists(t, path)
// The next write recreates the file with a fresh schema and
// only the new row.
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "b"}, 0,
))
assert.FileExists(t, path)
var got []delivery.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "b", got[0].EventID)
}
// TestStatArchive proves StatArchive finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
const debounce = 2 * time.Second
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), debounce,
)
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0,
))
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "b"}, 0,
))
// Two writes inside the debounce window trigger only the
// initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce)
require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "c"}, 0,
))
// A write after the window elapses closes and reopens once.
assert.Equal(t, 2, w.Reopens())
}
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
old := delivery.ExportArchivedEvent{
EventID: "old",
ArchivedAt: time.Now().Add(-2 * time.Hour),
}
fresh := delivery.ExportArchivedEvent{
EventID: "fresh",
ArchivedAt: time.Now(),
}
require.NoError(t, w.DB().Create(&old).Error)
require.NoError(t, w.DB().Create(&fresh).Error)
// Reopening with a one-hour expiry prunes the old row.
require.NoError(t, w.Reopen(time.Hour))
var got []delivery.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "fresh", got[0].EventID)
}
func TestParseArchiveExpiry(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{"empty config", "", 0, false},
{"explicit never", `{"expiry":"never"}`, 0, false},
{"empty expiry", `{"expiry":""}`, 0, false},
{"duration", `{"expiry":"1h"}`, time.Hour, false},
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
{"zero duration", `{"expiry":"0s"}`, 0, true},
{"negative duration", `{"expiry":"-5h"}`, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := delivery.ExportParseArchiveExpiry(tc.in)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
// seedDatabaseTargetDelivery seeds a pending delivery of an event
// to a database target and returns the in-memory delivery the
// target handler is invoked with.
func seedDatabaseTargetDelivery(
t *testing.T,
webhookDB *gorm.DB,
event database.Event,
tgt *database.Target,
) *database.Delivery {
t.Helper()
dlv := seedDelivery(
t, webhookDB, event.ID, tgt.ID,
database.DeliveryStatusPending,
)
d := &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: tgt.Name,
Type: database.TargetTypeDatabase,
Config: tgt.Config,
},
}
d.ID = dlv.ID
return d
}
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that // TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
// an archive error (here: an event with no webhook id to record) // an archive error (here: an unparseable expiry in the target
// fails the delivery loudly: the attempt is recorded as failed // config) fails the delivery loudly: the attempt is recorded as
// with the error and the delivery is marked failed, not // failed with the error and the delivery is marked failed, not
// delivered. // delivered.
func TestDeliverDatabase_ArchiveFailureFailsDelivery( func TestDeliverDatabase_ArchiveFailureFailsDelivery(
t *testing.T, t *testing.T,
@@ -270,12 +385,11 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t) tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":false}`) event := seedEvent(t, webhookDB, `{"archived":false}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d.Event.WebhookID = ""
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
@@ -300,96 +414,357 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
"the attempt must be recorded as failed", "the attempt must be recorded as failed",
) )
assert.Contains(t, assert.Contains(t,
results[0].Error, "without a webhook id", results[0].Error, "nonsense",
"the archive error must be recorded on the attempt", "the archive error must be recorded on the attempt",
) )
assert.NoFileExists(t, assert.NoFileExists(t,
env.archivePath(tgt), env.archivePath(tgt),
"no archive file should exist for a failed write", "no archive file should exist for a failed config",
) )
} }
// seedArchivedRow creates the archive file at path holding one row, for func TestValidateArchiveExpiry(t *testing.T) {
// eventID, archived at archivedAt.
func seedArchivedRow(
t *testing.T, path, eventID string, archivedAt time.Time,
) {
t.Helper()
sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
)
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
require.NoError(t, gdb.Exec(
"INSERT INTO archived_events (event_id, archived_at) VALUES (?, ?)",
eventID, archivedAt,
).Error)
}
// TestDeliverDatabase_TaskArchiveSettings proves a task's ArchiveExpiry
// and ArchiveRotation reach the archive through the engine. The
// target's row has neither, so only the task can bring them: with daily
// rotation the event goes to the file for its day, and opening that
// file with a one-hour expiry prunes the row it already held, archived
// two hours ago.
func TestDeliverDatabase_TaskArchiveSettings(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) valid := []string{"", "never", "1h", "720h", "30m"}
for _, in := range valid {
webhook := database.Webhook{UserID: uuid.NewString(), Name: "settings"} require.NoError(t,
webhook.ID = s.WebhookID delivery.ValidateArchiveExpiry(in),
require.NoError(t, s.MainDB.Create(&webhook).Error) "expiry %q should be accepted", in,
)
target := database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
Active: true,
MaxAttempts: 1,
} }
require.NoError(t, s.MainDB.Create(&target).Error)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"archived":true}`) invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
d := iSeedDelivery( for _, in := range invalid {
t, s.WebhookDB, event.ID, target.ID, require.Error(t,
database.DeliveryStatusPending, delivery.ValidateArchiveExpiry(in),
"expiry %q should be rejected", in,
)
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
delivery.ArchiveFileName(tc.webhook, tc.target, id),
)
})
}
}
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
) )
assert.Equal(
task := iTask( t, []string{event.ID},
d, event, s.WebhookID, target.ID, target.Name, "", 1, 1, archivedEventIDs(t, env.archivePath(first)),
&event.Body,
) )
task.TargetType = database.TargetTypeDatabase assert.Equal(
task.ArchiveExpiry = time.Hour t, []string{event.ID},
task.ArchiveRotation = "daily" archivedEventIDs(t, env.archivePath(second)),
daily := target
daily.ArchiveRotation = task.ArchiveRotation
dailyFile := archive.PathAt(s.DBMgr, &webhook, &daily, event.CreatedAt)
seedArchivedRow(t, dailyFile, "expired", time.Now().Add(-2*time.Hour))
s.Engine.ExportProcessNewTask(context.TODO(), &task)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered)
var rows []archivedRow
require.NoError(t, openArchiveDBForRead(t, dailyFile).
Table("archived_events").Find(&rows).Error)
require.Len(t, rows, 1)
assert.Equal(t, event.ID, rows[0].EventID,
"the event goes to its day's file and the expired row is pruned",
) )
} }
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
require.FileExists(t, oldPath)
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
))
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, entry := range entries {
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
"%s is not under the old name", entry.Name())
}
assert.Equal(t, oldPath, w.Path())
}
+9 -7
View File
@@ -17,7 +17,7 @@ import (
const MaxTargetTimeoutSeconds = 300 const MaxTargetTimeoutSeconds = 300
// Errors returned when a target's header or timeout form input // Errors returned when a target's header or timeout form input
// cannot be turned into the target's setting. // cannot be turned into a configuration.
// //
// None of these ever quotes a header VALUE. A target header value // None of these ever quotes a header VALUE. A target header value
// is routinely an authorization token, and these messages are shown // is routinely an authorization token, and these messages are shown
@@ -48,8 +48,8 @@ var (
) )
// isReservedTargetHeader reports whether name (canonicalised) is a // isReservedTargetHeader reports whether name (canonicalised) is a
// header a target may not set, because the delivery path or // header a target configuration may not set, because the delivery
// net/http itself writes it regardless. // path or net/http itself writes it regardless.
// //
// These are rejected rather than accepted-and-ignored. Storing a // These are rejected rather than accepted-and-ignored. Storing a
// header that provably never reaches the wire tells the operator // header that provably never reaches the wire tells the operator
@@ -77,11 +77,13 @@ func isReservedTargetHeader(name string) bool {
// ParseTargetHeaders turns the target form's headers field — one // ParseTargetHeaders turns the target form's headers field — one
// "Name: value" pair per line, blank lines ignored — into the map // "Name: value" pair per line, blank lines ignored — into the map
// stored in database.Target.Headers. Names are canonicalised, so a // stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
// name repeated in a different case is still a duplicate rather than // name repeated in a different case is still a duplicate rather than
// one pair silently overwriting the other. // one pair silently overwriting the other.
// //
// An input with no pairs yields an empty map. // An input with no pairs yields an empty map, which omitempty drops
// from the stored config, so a target configured with no headers
// stores no headers key at all.
func ParseTargetHeaders(raw string) (map[string]string, error) { func ParseTargetHeaders(raw string) (map[string]string, error) {
headers := make(map[string]string) headers := make(map[string]string)
@@ -221,8 +223,8 @@ func FormatTargetHeaders(headers map[string]string) string {
// ParseTargetTimeout interprets the target form's timeout field as // ParseTargetTimeout interprets the target form's timeout field as
// a whole number of seconds. An empty field means "unset" and yields // a whole number of seconds. An empty field means "unset" and yields
// 0, which the delivery path reads as "use the shared client's // 0, which omitempty drops from the stored config and which the
// timeout". // delivery path reads as "use the shared client's timeout".
// //
// Anything else that is not a whole number in range is an error, not // Anything else that is not a whole number in range is an error, not
// a silently substituted default: a target whose timeout was typed // a silently substituted default: a target whose timeout was typed
+99 -4
View File
@@ -1,21 +1,23 @@
package delivery_test package delivery_test
import ( import (
"encoding/json"
"strconv" "strconv"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// Literals these tests repeat, named so that each header name has one // Literals these tests repeat, named so that the header names and the
// definition. // keep-forever archive config each have one definition.
const ( const (
headerAuthorization = "Authorization" headerAuthorization = "Authorization"
headerContentType = "Content-Type" headerContentType = "Content-Type"
headerUserAgent = "User-Agent"
bearerValue = "Bearer abc" bearerValue = "Bearer abc"
archiveConfigNever = "{\"expiry\":\"never\"}"
) )
func TestParseTargetHeaders_AcceptsPairs(t *testing.T) { func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
@@ -39,13 +41,23 @@ func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
) )
} }
// An input of blank lines is no headers, not an error. // A configuration with no headers must stay indistinguishable from
// one written before the field existed, so omitempty drops the key.
func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) { func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) {
t.Parallel() t.Parallel()
got, err := delivery.ParseTargetHeaders("\n \n") got, err := delivery.ParseTargetHeaders("\n \n")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, got) assert.Empty(t, got)
encoded, err := json.Marshal(delivery.HTTPTargetConfig{
URL: "https://example.com/h",
Headers: got,
})
require.NoError(t, err)
assert.JSONEq(
t, `{"url":"https://example.com/h"}`, string(encoded),
)
} }
func TestParseTargetHeaders_Rejects(t *testing.T) { func TestParseTargetHeaders_Rejects(t *testing.T) {
@@ -169,6 +181,89 @@ func TestFormatTargetTimeout(t *testing.T) {
assert.Empty(t, delivery.FormatTargetTimeout(-1)) assert.Empty(t, delivery.FormatTargetTimeout(-1))
} }
func TestNewTargetConfigForm(t *testing.T) {
t.Parallel()
form, err := delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"https://example.com/h",` +
`"headers":{"Authorization":"Bearer abc"},` +
`"timeout":9}`,
MaxAttempts: 1,
})
require.NoError(t, err)
assert.Equal(t, "https://example.com/h", form.URL)
assert.Equal(t, "Authorization: Bearer abc\n", form.Headers)
assert.Equal(t, "9", form.Timeout)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://hooks.example/s"}`,
MaxAttempts: 1,
})
require.NoError(t, err)
assert.Equal(t, "https://hooks.example/s", form.URL)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"720h","rotation":"daily"}`,
})
require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry)
assert.Equal(t, rotationDaily, form.Rotation)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeLog,
})
require.NoError(t, err)
assert.Empty(t, form.URL)
}
// A keep-forever archive target yields an empty expiry, so the edit
// form starts on never; saving it unchanged stores never, which means
// the same as an empty expiry.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel()
for _, cfg := range []string{"", `{}`, archiveConfigNever} {
form, err := delivery.NewTargetConfigForm(
&database.Target{
Type: database.TargetTypeDatabase,
Config: cfg,
},
)
require.NoError(t, err, cfg)
assert.Empty(t, form.Expiry, cfg)
}
}
// An unreadable stored config is an error rather than a blank form
// that looks like a target with no settings, so the caller can tell
// the operator that saving replaces the stored value.
func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
t.Parallel()
cases := []*database.Target{
{Type: database.TargetTypeHTTP, Config: "not json", MaxAttempts: 1},
{Type: database.TargetTypeHTTP, Config: `{}`, MaxAttempts: 1},
{Type: database.TargetTypeSlack, Config: "", MaxAttempts: 1},
{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"soon"}`,
},
{
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
{Type: database.TargetType("nope")},
}
for _, target := range cases {
_, err := delivery.NewTargetConfigForm(target)
require.Error(t, err, target.Type)
}
}
// The ceiling exists so one misconfigured target cannot hold a // The ceiling exists so one misconfigured target cannot hold a
// delivery worker indefinitely, and it is inclusive. // delivery worker indefinitely, and it is inclusive.
func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) { func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) {
+99 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -16,6 +17,29 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// Sentinel errors returned by the config parsers.
var (
errEmptyTargetConfig = errors.New(
"empty target config",
)
errMissingTargetURL = errors.New(
"target URL is required",
)
)
// HTTPTargetConfig holds configuration for http target
// types.
type HTTPTargetConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"`
// ForwardQuery passes each event's query string on to the target,
// appended to URL. Off, the target URL is sent exactly as
// configured.
ForwardQuery bool `json:"forwardQuery,omitempty"`
}
// httpCore holds the retry, backoff, and circuit-breaker // httpCore holds the retry, backoff, and circuit-breaker
// machinery shared by the HTTP and Slack targets. Each of // machinery shared by the HTTP and Slack targets. Each of
// those targets owns its own httpCore instance (and thus its // those targets owns its own httpCore instance (and thus its
@@ -60,8 +84,8 @@ func (c *httpCore) deliver(
// engine is stopping, which cancels the context its request is made // engine is stopping, which cancels the context its request is made
// with. Such an attempt is not recorded: no DeliveryResult, no circuit // with. Such an attempt is not recorded: no DeliveryResult, no circuit
// breaker outcome, no status change, no count in the attempt metrics. // breaker outcome, no status change, no count in the attempt metrics.
// The delivery stays pending or retrying, and the recovery routine // The delivery stays pending or retrying, and startup recovery sends
// sends it again on the next start. // it again on the next start.
// //
// A target's own timeout leaves that context alone, so its attempt is // A target's own timeout leaves that context alone, so its attempt is
// still recorded as the failure it is. // still recorded as the failure it is.
@@ -361,8 +385,34 @@ func (t *httpTarget) Deliver(
task *Task, task *Task,
sched Scheduler, sched Scheduler,
) { ) {
cfg, err := parseHTTPConfig(d.Target.Config)
if err != nil {
t.eng.log.Error(
"invalid HTTP target config",
"target_id", d.TargetID,
"error", err,
)
recErr := t.eng.recordResult(
webhookDB, d, task.AttemptNum,
false, 0, "", err.Error(), 0,
)
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return
}
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed,
)
return
}
attempt := func() attemptResult { attempt := func() attemptResult {
return t.attempt(ctx, &d.Target, &d.Event) return t.attempt(ctx, cfg, &d.Event)
} }
t.deliver( t.deliver(
@@ -377,11 +427,11 @@ func (t *httpTarget) Deliver(
// string; only a transport-level error does. // string; only a transport-level error does.
func (t *httpTarget) attempt( func (t *httpTarget) attempt(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) attemptResult { ) attemptResult {
statusCode, respBody, duration, reqErr := statusCode, respBody, duration, reqErr :=
t.doHTTPRequest(ctx, target, event) t.doHTTPRequest(ctx, cfg, event)
success := reqErr == nil && success := reqErr == nil &&
statusCode >= httpSuccessMin && statusCode >= httpSuccessMin &&
@@ -403,7 +453,7 @@ func (t *httpTarget) attempt(
func (t *httpTarget) doHTTPRequest( func (t *httpTarget) doHTTPRequest(
ctx context.Context, ctx context.Context,
target *database.Target, cfg *HTTPTargetConfig,
event *database.Event, event *database.Event,
) (int, string, int64, error) { ) (int, string, int64, error) {
start := time.Now() start := time.Now()
@@ -411,7 +461,7 @@ func (t *httpTarget) doHTTPRequest(
req, reqErr := http.NewRequestWithContext( req, reqErr := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
target.URL, cfg.URL,
bytes.NewReader([]byte(event.Body)), bytes.NewReader([]byte(event.Body)),
) )
if reqErr != nil { if reqErr != nil {
@@ -421,15 +471,15 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
if target.ForwardQuery { if cfg.ForwardQuery {
appendQuery(req.URL, event.RawQuery) appendQuery(req.URL, event.RawQuery)
} }
originScoped := applyRequestHeaders( originScoped := applyRequestHeaders(
req, event, target.Headers, t.eng.userAgent(), req, event, cfg, t.eng.userAgent(),
) )
client := t.clientForRequest(target.TimeoutSeconds, originScoped) client := t.clientForRequest(cfg, originScoped)
resp, doErr := executeHTTPRequest(client, req) resp, doErr := executeHTTPRequest(client, req)
@@ -469,17 +519,17 @@ func appendQuery(u *url.URL, rawQuery string) {
} }
// clientForRequest returns the client for one delivery attempt. // clientForRequest returns the client for one delivery attempt.
// timeoutSeconds is the target's TimeoutSeconds, and originScoped is // originScoped is the header set applyRequestHeaders built for that
// the header set applyRequestHeaders built for that attempt; a // attempt; a request with neither a per-target timeout nor an
// request with neither a per-target timeout nor an origin-scoped // origin-scoped header gets the shared client, because there is
// header gets the shared client, because there is then nothing for // then nothing for the redirect policy to strip and net/http's
// the redirect policy to strip and net/http's default policy already // default policy already withholds Authorization and Cookie across
// withholds Authorization and Cookie across hosts. // hosts.
func (t *httpTarget) clientForRequest( func (t *httpTarget) clientForRequest(
timeoutSeconds int, cfg *HTTPTargetConfig,
originScoped []string, originScoped []string,
) *http.Client { ) *http.Client {
if timeoutSeconds <= 0 && len(originScoped) == 0 { if cfg.Timeout <= 0 && len(originScoped) == 0 {
return t.client return t.client
} }
@@ -492,9 +542,9 @@ func (t *httpTarget) clientForRequest(
Transport: t.client.Transport, Transport: t.client.Transport,
} }
if timeoutSeconds > 0 { if cfg.Timeout > 0 {
client.Timeout = time.Duration( client.Timeout = time.Duration(
timeoutSeconds, cfg.Timeout,
) * time.Second ) * time.Second
} }
@@ -505,6 +555,31 @@ func (t *httpTarget) clientForRequest(
return client return client
} }
func parseHTTPConfig(
configJSON string,
) (*HTTPTargetConfig, error) {
if configJSON == "" {
return nil, errEmptyTargetConfig
}
var cfg HTTPTargetConfig
err := json.Unmarshal(
[]byte(configJSON), &cfg,
)
if err != nil {
return nil, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
if cfg.URL == "" {
return nil, errMissingTargetURL
}
return &cfg, nil
}
// isForwardableHeader returns true if the header should // isForwardableHeader returns true if the header should
// be forwarded to targets. // be forwarded to targets.
func isForwardableHeader(name string) bool { func isForwardableHeader(name string) bool {
@@ -536,12 +611,11 @@ func isForwardableHeader(name string) bool {
// wins, otherwise the event's ContentType, otherwise none. The inbound // wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded. // Content-Type in the event's headers is never forwarded.
// //
// configured is the target's Headers. userAgent is set last, over // userAgent is set last, over any configured or inbound User-Agent.
// any configured or inbound User-Agent.
func applyRequestHeaders( func applyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
configured map[string]string, cfg *HTTPTargetConfig,
userAgent string, userAgent string,
) []string { ) []string {
if event.ContentType != "" { if event.ContentType != "" {
@@ -552,7 +626,7 @@ func applyRequestHeaders(
originScoped := forwardEventHeaders(req, event) originScoped := forwardEventHeaders(req, event)
for k, v := range configured { for k, v := range cfg.Headers {
req.Header.Set(k, v) req.Header.Set(k, v)
originScoped[http.CanonicalHeaderKey(k)] = struct{}{} originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
} }
@@ -617,7 +691,7 @@ func forwardEventHeaders(
} }
// executeHTTPRequest sends an HTTP request using the provided // executeHTTPRequest sends an HTTP request using the provided
// client. URLs are validated by the target forms and the // client. URLs are validated by the config parsers and the
// SSRF-safe transport before reaching here. // SSRF-safe transport before reaching here.
// //
// Transport failures are masked here, at the single point // Transport failures are masked here, at the single point
+28 -18
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -17,13 +18,25 @@ import (
// with. // with.
const eventQuery = "a=1&b=2" const eventQuery = "a=1&b=2"
// deliverWithQuery sends one event that arrived with eventQuery to an // httpTargetConfig is the stored configuration of an HTTP target at
// HTTP target at targetURL, with its ForwardQuery setting as given, // targetURL.
// through the path a received event's delivery takes, and returns the func httpTargetConfig(
// attempt it recorded.
func deliverWithQuery(
t *testing.T, targetURL string, forwardQuery bool, t *testing.T, targetURL string, forwardQuery bool,
) database.DeliveryResult { ) string {
t.Helper()
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL, ForwardQuery: forwardQuery,
})
require.NoError(t, err)
return string(cfg)
}
// deliverWithQuery sends one event that arrived with eventQuery to an
// HTTP target configured with cfg, through the path a received event's
// delivery takes, and returns the attempt it recorded.
func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
t.Helper() t.Helper()
s := newISetup(t) s := newISetup(t)
@@ -34,10 +47,8 @@ func deliverWithQuery(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
task := iTask( task := iTask(
d, event, s.WebhookID, d.TargetID, "query", targetURL, 1, 1, d, event, s.WebhookID, d.TargetID, "query", cfg, 1, 1, &event.Body,
&event.Body,
) )
task.ForwardQuery = forwardQuery
task.RawQuery = eventQuery task.RawQuery = eventQuery
s.Engine.ExportProcessNewTask(context.TODO(), &task) s.Engine.ExportProcessNewTask(context.TODO(), &task)
@@ -100,9 +111,9 @@ func TestDeliverHTTP_ForwardQuery(t *testing.T) {
)) ))
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
result := deliverWithQuery( result := deliverWithQuery(t, httpTargetConfig(
t, ts.URL+tc.path, tc.forwardQuery, t, ts.URL+tc.path, tc.forwardQuery,
) ))
assert.True(t, result.Success) assert.True(t, result.Success)
require.Len(t, received, 1) require.Len(t, received, 1)
@@ -133,13 +144,12 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
target := &database.Target{ target := &database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: ts.URL + "/in?token=" + secret, Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
ForwardQuery: true, MaxAttempts: 1,
MaxAttempts: 1,
} }
deliverWithQuery(t, target.URL, target.ForwardQuery) deliverWithQuery(t, target.Config)
require.Len(t, received, 1) require.Len(t, received, 1)
sent := <-received sent := <-received
@@ -154,9 +164,9 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
} }
// Nothing listens on port 1. // Nothing listens on port 1.
failed := deliverWithQuery( failed := deliverWithQuery(t, httpTargetConfig(
t, "http://127.0.0.1:1/in?token="+secret, true, t, "http://127.0.0.1:1/in?token="+secret, true,
) ))
require.NotEmpty(t, failed.Error) require.NotEmpty(t, failed.Error)
assert.NotContains(t, failed.Error, secret) assert.NotContains(t, failed.Error, secret)
assert.NotContains(t, failed.Error, eventQuery) assert.NotContains(t, failed.Error, eventQuery)
+27 -6
View File
@@ -18,7 +18,7 @@ const RedactionMarker = "(redacted)"
// line behind the delivery path's own URL masking. // line behind the delivery path's own URL masking.
// //
// It removes byte-identical echoes of strings taken from the // It removes byte-identical echoes of strings taken from the
// target's URL and headers, and nothing else. Anything // target's stored configuration, and nothing else. Anything
// the remote re-encodes survives: JSON "\/" escaping (what // the remote re-encodes survives: JSON "\/" escaping (what
// PHP's json_encode emits by default), percent-encoding, HTML // PHP's json_encode emits by default), percent-encoding, HTML
// entities, and an echo of only part of a path. It cannot // entities, and an echo of only part of a path. It cannot
@@ -115,7 +115,7 @@ func (r Redactor) secretPrefixSuffix(s string) int {
} }
// targetSecrets returns the credential-bearing strings a // targetSecrets returns the credential-bearing strings a
// target's settings carry. // target's configuration carries.
// //
// The destination URL contributes. Its path, query and // The destination URL contributes. Its path, query and
// userinfo are the credential for both target types that have // userinfo are the credential for both target types that have
@@ -129,15 +129,36 @@ func (r Redactor) secretPrefixSuffix(s string) int {
// picks out. That is the same class-based rule applied to // picks out. That is the same class-based rule applied to
// URLs: an echoed Accept or User-Agent still renders, an // URLs: an echoed Accept or User-Agent still renders, an
// echoed Authorization does not. // echoed Authorization does not.
//
// A setting a type does not use is empty and contributes
// nothing, so a database or log target has nothing to redact.
func targetSecrets(t *database.Target) []string { func targetSecrets(t *database.Target) []string {
if t == nil { if t == nil {
return nil return nil
} }
return append(urlSecrets(t.URL), headerSecrets(t.Headers)...) switch t.Type {
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return nil
}
return urlSecrets(cfg.WebhookURL)
case database.TargetTypeHTTP:
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return nil
}
return append(
urlSecrets(cfg.URL),
headerSecrets(cfg.Headers)...,
)
case database.TargetTypeDatabase, database.TargetTypeLog:
// Neither has a destination URL, so neither has
// anything to redact.
return nil
default:
return nil
}
} }
// urlSecrets returns the substrings of a destination URL that // urlSecrets returns the substrings of a destination URL that
+29 -29
View File
@@ -23,7 +23,7 @@ func TestRedactor_RemovesSlackWebhookURL(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -54,7 +54,7 @@ func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -86,19 +86,18 @@ func TestRedactor_RedactsCredentialShapedHeaderValues(
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: "https://example.com/in", Config: `{"url":"https://example.com/in",` +
Headers: map[string]string{ `"headers":{` +
headerAuthorization: "Bearer AAAAAAAAAAAA", `"Authorization":"Bearer AAAAAAAAAAAA",` +
"Cookie": "session=BBBBBBBBBBBB", `"Cookie":"session=BBBBBBBBBBBB",` +
probeHeaderName: "CCCCCCCCCCCC", `"X-Api-Key":"CCCCCCCCCCCC",` +
"X-Hub-Signature": "sha256=DDDDDDDDDDDD", `"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` +
"X-Sig": "EEEEEEEEEEEE", `"X-Sig":"EEEEEEEEEEEE",` +
"X-Pass": "FFFFFFFFFFFF", `"X-Pass":"FFFFFFFFFFFF",` +
"X-HMAC": "GGGGGGGGGGGG", `"X-HMAC":"GGGGGGGGGGGG",` +
"X-Credential": "HHHHHHHHHHHH", `"X-Credential":"HHHHHHHHHHHH",` +
"Accept": "application/json", `"Accept":"application/json",` +
headerUserAgent: "webhooker/1.0", `"User-Agent":"webhooker/1.0"}}`,
},
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -137,9 +136,9 @@ func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) {
t.Parallel() t.Parallel()
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: "https://example.com/in", Config: `{"url":"https://example.com/in",` +
Headers: map[string]string{probeHeaderName: "ab"}, `"headers":{"X-Api-Key":"ab"}}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -156,7 +155,7 @@ func TestRedactor_RemovesBarePath(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -190,7 +189,7 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: raw, Config: `{"url":"` + raw + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -240,7 +239,7 @@ func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
} { } {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: dest, Config: `{"url":"` + dest + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -262,7 +261,7 @@ func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
URL: redactWebhookURL, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -289,7 +288,7 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
r := delivery.NewRedactor(&database.Target{ r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
URL: dest, Config: `{"url":"` + dest + `"}`,
MaxAttempts: 1, MaxAttempts: 1,
}) })
@@ -307,10 +306,11 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
) )
} }
// TestRedactor_ZeroValueAndURLlessTargets pins that a caller // TestRedactor_ZeroValueAndConfiglessTargets pins that a
// with no target, or a target with no destination URL, gets a // caller with no target, an unparseable config, or a target
// redactor that changes nothing rather than one that panics. // type with no destination URL gets a redactor that changes
func TestRedactor_ZeroValueAndURLlessTargets(t *testing.T) { // nothing rather than one that panics.
func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) {
t.Parallel() t.Parallel()
const text = "some response body" const text = "some response body"
@@ -323,8 +323,8 @@ func TestRedactor_ZeroValueAndURLlessTargets(t *testing.T) {
for _, tgt := range []database.Target{ for _, tgt := range []database.Target{
{Type: database.TargetTypeLog}, {Type: database.TargetTypeLog},
{Type: database.TargetTypeDatabase}, {Type: database.TargetTypeDatabase},
{Type: database.TargetTypeSlack, MaxAttempts: 1}, {Type: database.TargetTypeSlack, Config: "not json", MaxAttempts: 1},
{Type: database.TargetTypeHTTP, MaxAttempts: 1}, {Type: database.TargetTypeHTTP, Config: "", MaxAttempts: 1},
} { } {
assert.Equal( assert.Equal(
t, text, t, text,
+81 -19
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -14,6 +15,18 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// errMissingWebhookURL is returned when a Slack target config
// omits its webhook URL.
var errMissingWebhookURL = errors.New(
"webhook_url is required",
)
// SlackTargetConfig holds configuration for slack target
// types.
type SlackTargetConfig struct {
WebhookURL string `json:"webhookUrl"`
}
// slackTarget delivers events to Slack incoming webhooks. It // slackTarget delivers events to Slack incoming webhooks. It
// formats the event into a Slack message and posts it as // formats the event into a Slack message and posts it as
// JSON. It shares the retry core with the HTTP target: a // JSON. It shares the retry core with the HTTP target: a
@@ -33,6 +46,19 @@ func (t *slackTarget) Deliver(
task *Task, task *Task,
sched Scheduler, sched Scheduler,
) { ) {
cfg, err := parseSlackConfig(d.Target.Config)
if err != nil {
t.eng.log.Error(
"invalid Slack target config",
"target_id", d.TargetID,
"error", err,
)
t.failConfig(webhookDB, d, err)
return
}
msg := FormatSlackMessage(&d.Event) msg := FormatSlackMessage(&d.Event)
payload, err := json.Marshal( payload, err := json.Marshal(
@@ -45,26 +71,13 @@ func (t *slackTarget) Deliver(
"error", err, "error", err,
) )
recErr := t.eng.recordResult( t.failConfig(webhookDB, d, err)
webhookDB, d, 1,
false, 0, "", err.Error(), 0,
)
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return
}
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed,
)
return return
} }
attempt := func() attemptResult { attempt := func() attemptResult {
return t.attempt(ctx, d.Target.URL, payload) return t.attempt(ctx, cfg, payload)
} }
t.deliver( t.deliver(
@@ -73,13 +86,37 @@ func (t *slackTarget) Deliver(
) )
} }
// attempt performs a single Slack POST to targetURL and derives // failConfig records a first-attempt failure for a delivery
// its outcome, preserving the engine's original semantics: a // that could not be prepared (bad config or unmarshalable
// payload) and marks it failed.
func (t *slackTarget) failConfig(
webhookDB *gorm.DB,
d *database.Delivery,
err error,
) {
recErr := t.eng.recordResult(
webhookDB, d, 1,
false, 0, "", err.Error(), 0,
)
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return
}
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed,
)
}
// attempt performs a single Slack POST and derives its
// outcome, preserving the engine's original semantics: a
// non-2xx response records an "HTTP <code>" error string and // non-2xx response records an "HTTP <code>" error string and
// a transport error records a "sending request" error. // a transport error records a "sending request" error.
func (t *slackTarget) attempt( func (t *slackTarget) attempt(
ctx context.Context, ctx context.Context,
targetURL string, cfg *SlackTargetConfig,
payload []byte, payload []byte,
) attemptResult { ) attemptResult {
start := time.Now() start := time.Now()
@@ -87,7 +124,7 @@ func (t *slackTarget) attempt(
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
targetURL, cfg.WebhookURL,
bytes.NewReader(payload), bytes.NewReader(payload),
) )
if err != nil { if err != nil {
@@ -149,6 +186,31 @@ func (t *slackTarget) readSlackResponse(
} }
} }
func parseSlackConfig(
configJSON string,
) (*SlackTargetConfig, error) {
if configJSON == "" {
return nil, errEmptyTargetConfig
}
var cfg SlackTargetConfig
err := json.Unmarshal(
[]byte(configJSON), &cfg,
)
if err != nil {
return nil, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
if cfg.WebhookURL == "" {
return nil, errMissingWebhookURL
}
return &cfg, nil
}
// FormatSlackMessage builds a Slack-compatible message // FormatSlackMessage builds a Slack-compatible message
// string from a webhook event. // string from a webhook event.
func FormatSlackMessage( func FormatSlackMessage(
+31 -31
View File
@@ -45,7 +45,7 @@ func tSeedDeletedTarget(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, name, t, s.MainDB, targetID, s.WebhookID, name,
database.TargetTypeHTTP, url, 5, database.TargetTypeHTTP, iHTTPConfig(url), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -122,9 +122,9 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy(
) )
target := database.Target{ target := database.Target{
Name: "mystery", Name: "mystery",
Type: tUnknownType, Type: tUnknownType,
URL: "http://example.com/hook", Config: iHTTPConfig("http://example.com/hook"),
} }
target.ID = targetID target.ID = targetID
@@ -140,7 +140,7 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy(
body := event.Body body := event.Body
task := iTask( task := iTask(
seeded, event, s.WebhookID, targetID, "mystery", seeded, event, s.WebhookID, targetID, "mystery",
target.URL, 0, 1, &body, target.Config, 0, 1, &body,
) )
task.TargetType = tUnknownType task.TargetType = tUnknownType
@@ -201,7 +201,7 @@ func TestRecoverSingleRetry_TargetDeleted(t *testing.T) {
) )
} }
func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) { func TestSweepSingleRetry_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -239,11 +239,10 @@ func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) {
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestRecoverSingleRetry_OnTick_TargetNeverExisted covers the other // TestSweepSingleRetry_TargetNeverExisted covers the other half of the
// half of the soft-delete distinction: an id with no row at all, // soft-delete distinction: an id with no row at all, deleted or
// deleted or otherwise, must not be reported as something the operator // otherwise, must not be reported as something the operator deleted.
// deleted. func TestSweepSingleRetry_TargetNeverExisted(t *testing.T) {
func TestRecoverSingleRetry_OnTick_TargetNeverExisted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -346,10 +345,11 @@ func tRetryChainSetup(
iCreateWebhook(t, s.MainDB, s.WebhookID, name) iCreateWebhook(t, s.MainDB, s.WebhookID, name)
targetID := uuid.New().String() targetID := uuid.New().String()
cfg := iHTTPConfig(ts.URL)
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, name, t, s.MainDB, targetID, s.WebhookID, name,
database.TargetTypeHTTP, ts.URL, 5, database.TargetTypeHTTP, cfg, 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -366,7 +366,7 @@ func tRetryChainSetup(
body := event.Body body := event.Body
return iTask( return iTask(
d, event, s.WebhookID, targetID, name, ts.URL, 5, 2, &body, d, event, s.WebhookID, targetID, name, cfg, 5, 2, &body,
), targetID ), targetID
} }
@@ -527,7 +527,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
iCreateTarget( iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "healthy", t, s.MainDB, targetID, s.WebhookID, "healthy",
database.TargetTypeHTTP, database.TargetTypeHTTP,
"http://example.com/hook", 5, iHTTPConfig("http://example.com/hook"), 5,
) )
event := iSeedEvent( event := iSeedEvent(
@@ -564,7 +564,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone(
// --- 4. A pending delivery whose target is gone --- // --- 4. A pending delivery whose target is gone ---
func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) { func TestRecoverPending_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
s := newISetup(t) s := newISetup(t)
@@ -600,10 +600,10 @@ func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) {
) )
} }
// TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone: // TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone: the
// the terminal write takes ownership like every other recovery write, so // terminal write takes ownership like every other recovery write, so a
// a delivery the engine still holds is not failed underneath its worker. // delivery the engine still holds is not failed underneath its worker.
func TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone( func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -675,18 +675,18 @@ func TestFailMissingTarget_LeavesASettledDeliveryAlone(
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestRecoverPendingDeliveries_OnTick_TargetDeleted sweeps twice over a // TestSweepPending_TargetDeleted sweeps twice over a batch that also
// batch that also holds a healthy stranded delivery. The one whose // holds a healthy stranded delivery. The one whose target is gone is
// target is gone is failed once and then left alone; the healthy one is // failed once and then left alone; the healthy one is queued by the
// queued by the first sweep and not again by the second. // first sweep and not again by the second.
func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) { func TestSweepPending_TargetDeleted(t *testing.T) {
t.Parallel() t.Parallel()
liveTargetID := uuid.New().String() liveTargetID := uuid.New().String()
s := fSweepSetup(t, liveTargetID, "still-there") s := fSweepSetup(t, liveTargetID, "still-there")
deliveryID := tSeedDeletedTarget( deliveryID := tSeedDeletedTarget(
t, s, "gone-while-pending-on-tick", "http://example.com/hook", t, s, "gone-on-pending-sweep", "http://example.com/hook",
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
rAgePending(t, s.WebhookDB, deliveryID) rAgePending(t, s.WebhookDB, deliveryID)
@@ -724,7 +724,7 @@ func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) {
last := tLastResult(t, s, deliveryID, 2) last := tLastResult(t, s, deliveryID, 2)
assert.Contains(t, last.Error, "gone-while-pending-on-tick") assert.Contains(t, last.Error, "gone-on-pending-sweep")
assert.Contains(t, last.Error, "was deleted") assert.Contains(t, last.Error, "was deleted")
} }
@@ -775,11 +775,11 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap(
) )
} }
// TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone: a // TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone: a failed
// failed read of the main database is not a deleted target. At start the // read of the main database is not a deleted target. Restart recovery
// recovery routine holds every pending delivery of the webhook in one // holds every pending delivery of the webhook in one batch, so failing
// batch, so failing on this would fail all of them. // on this would fail all of them.
func TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone( func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
+16 -4
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -70,6 +71,13 @@ func deliverSlackTo(
e := testEngine(t, 1) e := testEngine(t, 1)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{
WebhookURL: webhookURL,
},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"test":true}`) event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery( dlv := seedDelivery(
@@ -79,7 +87,7 @@ func deliverSlackTo(
d := buildSlackDelivery( d := buildSlackDelivery(
dlv, event, targetID, dlv, event, targetID,
"test-slack-mask", webhookURL, "test-slack-mask", string(slackCfg),
) )
e.ExportDeliverSlack(context.TODO(), db, d) e.ExportDeliverSlack(context.TODO(), db, d)
@@ -152,9 +160,13 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
e := testEngine(t, 1) e := testEngine(t, 1)
cfg, err := e.ExportParseHTTPConfig(
newHTTPTargetConfig(host + maskSecretPath),
)
require.NoError(t, err)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest( statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(), cfg,
&database.Target{URL: host + maskSecretPath},
&database.Event{Body: `{"test":true}`}, &database.Event{Body: `{"test":true}`},
) )
require.Error(t, reqErr) require.Error(t, reqErr)
@@ -176,7 +188,7 @@ func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
statusCode, _, _, reqErr := e.ExportDoHTTPRequest( statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(), context.TODO(),
&database.Target{ &delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n", URL: "https://hooks.example.com" + maskSecretPath + "\n",
}, },
&database.Event{}, &database.Event{},
+9 -3
View File
@@ -2,6 +2,7 @@ package delivery_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -58,9 +59,9 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) {
statusCode, _, _, err := e.ExportDoHTTPRequest( statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(), context.Background(),
&database.Target{ &delivery.HTTPTargetConfig{
URL: ts.URL, URL: ts.URL,
Headers: map[string]string{headerUserAgent: "configured/1"}, Headers: map[string]string{"User-Agent": "configured/1"},
}, },
&database.Event{Headers: `{"User-Agent":["curl/8"]}`}, &database.Event{Headers: `{"User-Agent":["curl/8"]}`},
) )
@@ -72,13 +73,18 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) {
db := testWebhookDB(t) db := testWebhookDB(t)
targetID := uuid.New().String() targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`) event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending, t, db, event.ID, targetID, database.DeliveryStatusPending,
) )
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery( e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", ts.URL, dlv, event, targetID, "test-slack", string(slackCfg),
)) ))
require.Len(t, userAgents, 1, "the slack target sent no request") require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target") assert.Equal(t, want, <-userAgents, "slack target")
+1 -23
View File
@@ -1,14 +1,8 @@
package handlers package handlers
import (
"strconv"
"time"
)
const ( const (
// archiveExpiryNever is the archive expiry that keeps archived // archiveExpiryNever is the archive expiry that keeps archived
// events forever. It is stored as an ArchiveExpiry of 0, and an // events forever. A stored empty expiry means the same.
// empty expiry on a form means the same.
archiveExpiryNever = "never" archiveExpiryNever = "never"
// tmplKeyArchiveExpiryChoices is the template data key for the // tmplKeyArchiveExpiryChoices is the template data key for the
@@ -62,19 +56,3 @@ func archiveExpiryOptions(expiry string) []archiveChoice {
return append([]archiveChoice{own}, options...) return append([]archiveChoice{own}, options...)
} }
// formatArchiveExpiry writes a stored ArchiveExpiry the way the
// expiry select spells its values, so the target edit form starts on
// the stored choice: never for 0, a whole number of hours as "720h",
// and anything else as Go writes a duration, which the form accepts
// back unchanged.
func formatArchiveExpiry(expiry time.Duration) string {
switch {
case expiry == 0:
return archiveExpiryNever
case expiry%time.Hour == 0:
return strconv.FormatInt(int64(expiry/time.Hour), 10) + "h"
default:
return expiry.String()
}
}
+16 -20
View File
@@ -7,7 +7,6 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -95,18 +94,14 @@ func TestArchiveExpiryChoices(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
choices := []struct { choices := []struct{ value, shown string }{
value string {expiryNever, expiryNever},
stored time.Duration {"1h", "1 hour"},
shown string {"12h", "12 hours"},
}{ {"24h", "1 day"},
{expiryNever, 0, expiryNever}, {"720h", "30 days"},
{"1h", time.Hour, "1 hour"}, {"2160h", "90 days"},
{"12h", 12 * time.Hour, "12 hours"}, {"8760h", "365 days"},
{"24h", 24 * time.Hour, "1 day"},
{"720h", 720 * time.Hour, "30 days"},
{"2160h", 2160 * time.Hour, "90 days"},
{"8760h", 8760 * time.Hour, "365 days"},
} }
for _, choice := range choices { for _, choice := range choices {
@@ -127,7 +122,9 @@ func TestArchiveExpiryChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, choice.stored, targets[0].ArchiveExpiry) assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal( assert.Equal(
t, []string{choice.shown}, t, []string{choice.shown},
@@ -141,7 +138,7 @@ func TestArchiveExpiryChoices(t *testing.T) {
} }
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a // TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is 0, which selects never, // database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed // and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged. // first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
@@ -151,8 +148,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget( empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase, "",
database.Target{},
) )
_, selected := expirySelected(t, env, webhook.ID, empty.ID) _, selected := expirySelected(t, env, webhook.ID, empty.ID)
@@ -161,7 +157,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
webhook = seedWebhookWithRetention(t, env.db, 30) webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget( unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveExpiry: 36 * time.Hour}, `{"expiry":"36h"}`,
) )
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID)) assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
@@ -183,7 +179,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form) w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal( assert.JSONEq(
t, 36*time.Hour, storedTarget(t, env, unlisted.ID).ArchiveExpiry, t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
) )
} }
+25 -39
View File
@@ -12,16 +12,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The archive rotations these tests store: none keeps one file. // rotationNone is the archive rotation that keeps one file.
const ( const rotationNone = "none"
rotationNone = "none"
rotationDaily = "daily"
rotationHourly = "hourly"
)
// rotationShown returns the archive rotations the webhook page's // rotationShown returns the archive rotations the webhook page's
// target list shows. // target list shows.
@@ -45,9 +41,7 @@ func TestArchiveRotationChoices(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
for _, rotation := range []string{ for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
rotationNone, "monthly", rotationDaily, rotationHourly,
} {
t.Run(rotation, func(t *testing.T) { t.Run(rotation, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -66,8 +60,10 @@ func TestArchiveRotationChoices(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) assert.JSONEq(t,
assert.Equal(t, rotation, targets[0].ArchiveRotation) `{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t, assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID)) []string{rotation}, rotationShown(t, env, webhook.ID))
@@ -82,37 +78,26 @@ func TestArchiveRotationChoices(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form) w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
stored := storedTarget(t, env, targets[0].ID) `{"expiry":"720h","rotation":"hourly"}`,
assert.Equal(t, 720*time.Hour, stored.ArchiveExpiry) storedTarget(t, env, targets[0].ID).Config,
assert.Equal(t, rotationHourly, stored.ArchiveRotation) )
}) })
} }
} }
// TestArchiveRotationEditStartsOnNone checks that a database target // TestArchiveRotationEditStartsOnNone checks the edit form of a
// added with no rotation is stored with none and shown with none, and // database target with no rotation stored starts on none.
// that its edit form starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) { func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
form := url.Values{} t, env.db, webhook.ID, database.TargetTypeDatabase, "",
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
) )
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID) page := targetEditPage(t, env, webhook.ID, target.ID)
require.Len(t, targets, 1)
assert.Equal(t, rotationNone, targets[0].ArchiveRotation)
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation")) assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID)) assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
} }
@@ -140,7 +125,7 @@ func TestArchiveRotationRefused(t *testing.T) {
target := seedConfiguredTarget( target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveRotation: rotationDaily}, `{"rotation":"daily"}`,
) )
form.Del("type") form.Del("type")
@@ -148,8 +133,8 @@ func TestArchiveRotationRefused(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, target.ID, form) w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation") assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Equal( assert.JSONEq(t,
t, rotationDaily, storedTarget(t, env, target.ID).ArchiveRotation, `{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
) )
} }
@@ -176,8 +161,9 @@ func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) assert.JSONEq(t,
assert.Equal(t, rotationDaily, targets[0].ArchiveRotation) `{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
} }
// TestArchiveFileView_Rotated describes a daily target's archive files // TestArchiveFileView_Rotated describes a daily target's archive files
@@ -192,10 +178,10 @@ func TestArchiveFileView_Rotated(t *testing.T) {
webhook := seedWebhookWithRetention(t, env.db, 30) webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget( target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, t, env.db, webhook.ID, database.TargetTypeDatabase,
database.Target{ArchiveRotation: rotationDaily}, `{"rotation":"daily"}`,
) )
path := archive.Path(env.dbMgr, &webhook, target) path := delivery.ArchivePath(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db") stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
+90 -12
View File
@@ -5,7 +5,9 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The outcomes of a replay POST, as the notice codes its redirect // The outcomes of a replay POST, as the notice codes its redirect
@@ -44,12 +46,12 @@ const (
// HandleDeliveryReplay re-sends a finished delivery's event to its // HandleDeliveryReplay re-sends a finished delivery's event to its
// target. // target.
// //
// A replay never touches the delivery it repeats. The delivery engine // A replay never touches the delivery it repeats. It creates a NEW
// creates a NEW pending delivery row for the same event and target and // pending delivery row for the same event and target and hands it to
// queues it as it queues the receiver's, so the original's status, // the delivery engine through the same Notifier the receiver uses, so
// attempts and timestamps stand as the record of what actually // the original's status, attempts and timestamps stand as the record
// happened, and the replay is retried, SSRF-guarded and circuit-broken // of what actually happened, and the replay is retried, SSRF-guarded
// exactly as a first attempt is. // and circuit-broken exactly as a first attempt is.
// //
// What is re-sent is the stored EVENT body, never the response the // What is re-sent is the stored EVENT body, never the response the
// original delivery received. // original delivery received.
@@ -143,8 +145,7 @@ func (h *Handlers) loadReplaySource(
return &original, true return &original, true
} }
// queueReplay has the delivery engine create and queue the new // queueReplay writes the new delivery and hands it to the engine.
// delivery, unless an earlier replay is still in flight.
func (h *Handlers) queueReplay( func (h *Handlers) queueReplay(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -170,8 +171,18 @@ func (h *Handlers) queueReplay(
return return
} }
deliveryID, err := h.engine.CreateReplay( var event database.Event
webhook.ID, original.EventID, target,
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, r, "failed to load event for replay", err)
return
}
task, err := createReplayDelivery(
webhookDB, webhook.ID, &event, target,
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
@@ -182,14 +193,15 @@ func (h *Handlers) queueReplay(
} }
h.mtr.DeliveryReplayed(target.Type) h.mtr.DeliveryReplayed(target.Type)
h.notifier.Notify([]delivery.Task{task})
h.log.Info( h.log.Info(
"delivery replay queued", "delivery replay queued",
"webhook_id", webhook.ID, "webhook_id", webhook.ID,
"event_id", original.EventID, "event_id", event.ID,
"target_id", target.ID, "target_id", target.ID,
"replayed_delivery_id", original.ID, "replayed_delivery_id", original.ID,
"delivery_id", deliveryID, "delivery_id", task.DeliveryID,
) )
redirectToEventLog(w, r, webhook, replayQueued) redirectToEventLog(w, r, webhook, replayQueued)
@@ -251,6 +263,72 @@ func countInFlightDeliveries(
return count, err return count, err
} }
// 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
// otherwise upsert the whole target row — plaintext config, which for a
// Slack target is the credential — into the per-webhook event database.
// See https://git.eeqj.de/sneak/webhooker/issues/206.
func createReplayDelivery(
webhookDB *gorm.DB,
webhookID string,
event *database.Event,
target *database.Target,
) (delivery.Task, error) {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: target.ID,
Status: database.DeliveryStatusPending,
Replay: true,
}
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
}
return delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: target.Type,
TargetConfig: target.Config,
MaxAttempts: target.MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
AttemptNum: 1,
}, nil
}
// replayBody returns the stored event body for a replay task to carry
// inline, or nil when it is large enough that the engine should fetch
// it from the per-webhook database instead.
func replayBody(body string) *string {
if len(body) >= delivery.MaxInlineBodySize {
return nil
}
return &body
}
// redirectToEventLog redirects a replay or resubmit back to the event // redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice. A // log it was triggered from, carrying the outcome as its notice. A
// Replay form carries the list it was pressed in as show, so a replay // Replay form carries the list it was pressed in as show, so a replay
+85 -54
View File
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -22,7 +23,7 @@ const paramDeliveryID = "deliveryID"
// replayTargetURL is a public destination, so a target configured with // replayTargetURL is a public destination, so a target configured with
// it is one the SSRF guard would accept. Nothing in these tests // it is one the SSRF guard would accept. Nothing in these tests
// dispatches to it: the delivery engine is never started. // dispatches to it: the notifier is recorded, not run.
const replayTargetURL = "http://93.184.216.34/hook" const replayTargetURL = "http://93.184.216.34/hook"
// replayEventQuery is the query string a seeded event arrived with. // replayEventQuery is the query string a seeded event arrived with.
@@ -162,8 +163,14 @@ func postReplay(
// TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal is the // TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal is the
// core requirement: replaying a failed delivery succeeds, appends a // core requirement: replaying a failed delivery succeeds, appends a
// new delivery marked as a replay, and leaves the original row and its // new delivery, and leaves the original row and its recorded attempt
// recorded attempt exactly as they were. // exactly as they were.
//
// It also pins the two things a replay would be wrong to get from the
// original: the task carries the target's CURRENT configuration, which
// this test changes between the failure and the replay, and it carries
// the stored EVENT body rather than anything the failed attempt
// received back.
func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
t *testing.T, t *testing.T,
) { ) {
@@ -174,9 +181,10 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -184,7 +192,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -196,6 +204,15 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
before := loadDelivery(t, webhookDB, original.ID) before := loadDelivery(t, webhookDB, original.ID)
// The operator fixes the destination, which is the whole reason
// to replay. The replay must use this, not the config the
// original delivery ran against.
const fixedConfig = `{"url":"http://93.184.216.34/fixed"}`
require.NoError(t, db.DB().Model(&database.Target{}).
Where("id = ?", tgt.ID).
Update("config", fixedConfig).Error)
w := postReplay(t, h, sess, wh.ID, original.ID) w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
@@ -217,9 +234,14 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
assert.Equal( assert.Equal(
t, database.DeliveryStatusPending, replayed.Status, t, database.DeliveryStatusPending, replayed.Status,
) )
assert.True(t, replayed.Replay)
assertDeliveryUntouched(t, webhookDB, before) assertDeliveryUntouched(t, webhookDB, before)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assertReplayTask(
t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedConfig,
)
assertNoLeakedTarget(t, webhookDB) assertNoLeakedTarget(t, webhookDB)
} }
@@ -253,6 +275,45 @@ func assertDeliveryUntouched(
) )
} }
// assertReplayTask proves the task handed to the delivery engine is
// the one the receiver would build for this event and this target, and
// that it carries wantConfig — the target's configuration as it stands
// now rather than as the original delivery ran against it.
func assertReplayTask(
t *testing.T,
task delivery.Task,
webhookID string,
event *database.Event,
target *database.Target,
wantDeliveryID, wantConfig string,
) {
t.Helper()
assert.Equal(t, wantDeliveryID, task.DeliveryID)
assert.Equal(t, event.ID, task.EventID)
assert.Equal(t, webhookID, task.WebhookID)
assert.Equal(t, event.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.JSONEq(
t, wantConfig, task.TargetConfig,
"replay must use the target's current configuration",
)
assert.Equal(t, event.Method, task.Method)
assert.Equal(
t, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
require.NotNil(t, task.Body)
assert.Equal(
t, event.Body, *task.Body,
"replay re-sends the stored event body",
)
}
// assertNoLeakedTarget proves the per-webhook database holds no // assertNoLeakedTarget proves the per-webhook database holds no
// target: an association write would have upserted the whole target, // target: an association write would have upserted the whole target,
// plaintext config and all, into the event database. See // plaintext config and all, into the event database. See
@@ -266,49 +327,9 @@ func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
) )
} }
// TestHandleDeliveryReplay_FailedStoreIsAnError proves a replay the
// engine cannot store answers with the 500 error page and adds no
// delivery.
func TestHandleDeliveryReplay_FailedStoreIsAnError(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)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL},
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
// Without its target totals table the webhook's database refuses
// the totals write, which comes after the delivery row.
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.TargetTotals{}))
w := postReplay(t, h, sess, wh.ID, original.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
assert.Len(t, listDeliveries(t, webhookDB, event.ID), 1)
}
// TestHandleDeliveryReplay_RefusesDeletedTarget proves the required // TestHandleDeliveryReplay_RefusesDeletedTarget proves the required
// refusal: a target deleted since the delivery ran is reported as // refusal: a target deleted since the delivery ran is reported as
// deleted rather than erroring, and nothing is created. // deleted rather than erroring, and nothing is created or queued.
func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -317,9 +338,10 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -327,7 +349,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -355,6 +377,10 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
t, listDeliveries(t, webhookDB, event.ID), 1, t, listDeliveries(t, webhookDB, event.ID), 1,
"a refused replay must create no delivery", "a refused replay must create no delivery",
) )
assert.Empty(
t, notif.Tasks(),
"a refused replay must queue nothing",
)
// The refusal is specific, which is why the target is looked up // The refusal is specific, which is why the target is looked up
// including soft-deleted rows: an id that never named a target // including soft-deleted rows: an id that never named a target
@@ -387,9 +413,10 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -397,7 +424,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery( event, original := seedFailedDelivery(
@@ -427,6 +454,10 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
t, listDeliveries(t, webhookDB, event.ID), 2, t, listDeliveries(t, webhookDB, event.ID), 2,
"the refused second replay must add nothing", "the refused second replay must add nothing",
) )
assert.Len(
t, notif.Tasks(), 1,
"only the first replay reaches the delivery engine",
)
// A delivery the engine has not finished is not replayable // A delivery the engine has not finished is not replayable
// either, which is the same rule seen from the other side. // either, which is the same rule seen from the other side.
@@ -467,7 +498,7 @@ func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -525,7 +556,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
@@ -584,7 +615,7 @@ func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
+12 -12
View File
@@ -86,12 +86,12 @@ func seedFailedDeliveryWithResponse(
} }
// seedFailureAndRender seeds a failed delivery against a // seedFailureAndRender seeds a failed delivery against a
// target of the given type and URL, and returns the // target of the given type and config, and returns the
// rendered event log page. // rendered event log page.
func seedFailureAndRender( func seedFailureAndRender(
t *testing.T, t *testing.T,
targetType database.TargetType, targetType database.TargetType,
targetURL, responseBody string, config, responseBody string,
) string { ) string {
t.Helper() t.Helper()
@@ -109,7 +109,7 @@ func seedFailureAndRender(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, targetType, database.Target{URL: targetURL}, t, db, wh.ID, targetType, config,
) )
seedFailedDeliveryWithResponse( seedFailedDeliveryWithResponse(
@@ -129,7 +129,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeHTTP, database.TargetTypeHTTP,
"https://example.com/hook/abc", `{"url":"https://example.com/hook/abc"}`,
"upstream exploded", "upstream exploded",
) )
@@ -167,7 +167,7 @@ func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeHTTP, database.TargetTypeHTTP,
"https://example.com/hook/abc", `{"url":"https://example.com/hook/abc"}`,
payload, payload,
) )
@@ -189,7 +189,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInResponse(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
"no_service: "+slackWebhookURL, "no_service: "+slackWebhookURL,
) )
@@ -228,7 +228,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInError(
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -300,7 +300,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredByTheEngineCut(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
stored, stored,
) )
@@ -335,7 +335,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredBySQLCut(
body := seedFailureAndRender( body := seedFailureAndRender(
t, t,
database.TargetTypeSlack, database.TargetTypeSlack,
slackWebhookURL, `{"webhookUrl":"`+slackWebhookURL+`"}`,
stored, stored,
) )
@@ -373,7 +373,7 @@ func TestHandleSourceLogs_RedactsForSoftDeletedTarget(
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
seedFailedDeliveryWithResponse( seedFailedDeliveryWithResponse(
@@ -413,7 +413,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, database.Target{}, t, db, wh.ID, database.TargetTypeLog, "",
) )
dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "")
@@ -480,7 +480,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeLog, database.Target{}, t, db, wh.ID, database.TargetTypeLog, "",
) )
stored := strings.Repeat("A", responseCap*4) + tail stored := strings.Repeat("A", responseCap*4) + tail
+3 -3
View File
@@ -13,8 +13,8 @@ import (
// DeliveryView is the display-safe projection of a delivery // DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the // for the event log page. Its target is a TargetView, so the
// target's URL and headers — which hold the target's // stored configuration blob — which holds the target's
// credential — have no path to the template. // credential — has no path to the template.
type DeliveryView struct { type DeliveryView struct {
ID string ID string
Status database.DeliveryStatus Status database.DeliveryStatus
@@ -126,7 +126,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// loadTargetMap loads targets into a map of display-safe // loadTargetMap loads targets into a map of display-safe
// views keyed by target ID, each paired with its redactor. // views keyed by target ID, each paired with its redactor.
// The projection happens here so that no caller can hand a // The projection happens here so that no caller can hand a
// raw target, URL and headers and all, to a template: the // raw target, configuration blob and all, to a template: the
// raw rows do not leave this function. // raw rows do not leave this function.
// //
// The load is Unscoped because deleting a target only soft // The load is Unscoped because deleting a target only soft
+25 -17
View File
@@ -171,29 +171,37 @@ func loadResubmitSource(
return src, true, nil return src, true, nil
} }
// queueResubmit has the delivery engine store the copy, with a delivery // queueResubmit stores the copy and fans it out to the webhook's
// for each of the webhook's active targets as they stand now, through // active targets.
// the same StoreEvent the receiver uses. The one difference is that the
// copy leaves its entrypoint's last event as it is, since it did not
// arrive on that URL.
func (h *Handlers) queueResubmit( func (h *Handlers) queueResubmit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
src resubmitSource, src resubmitSource,
) { ) {
event := &database.Event{ // The receiver's own query, run now: an active target created
WebhookID: webhook.ID, // after the original event arrived is included, and an
EntrypointID: src.EntrypointID, // inactive one is skipped rather than refused.
Method: src.Method, targets, err := h.loadActiveTargets(webhook.ID)
RawQuery: src.RawQuery, if err != nil {
Headers: src.Headers, h.serverError(w, r, "failed to query targets", err)
ContentType: src.ContentType,
Body: string(src.Body), return
ResubmittedFromID: &src.ID,
} }
deliveries, err := h.engine.StoreEvent(event) event, tasks, err := h.createAndFanOut(
eventSource{
WebhookID: webhook.ID,
EntrypointID: src.EntrypointID,
Method: src.Method,
RawQuery: src.RawQuery,
HeadersJSON: src.Headers,
ContentType: src.ContentType,
Body: src.Body,
ResubmittedFromID: &src.ID,
},
targets,
)
if err != nil { if err != nil {
h.serverError(w, r, "failed to store resubmitted event", err) h.serverError(w, r, "failed to store resubmitted event", err)
@@ -207,11 +215,11 @@ func (h *Handlers) queueResubmit(
"webhook_id", webhook.ID, "webhook_id", webhook.ID,
"event_id", event.ID, "event_id", event.ID,
"resubmitted_from_id", src.ID, "resubmitted_from_id", src.ID,
"target_count", deliveries, "target_count", len(tasks),
) )
code := resubmitQueued code := resubmitQueued
if deliveries == 0 { if len(tasks) == 0 {
code = resubmitNoTargets code = resubmitNoTargets
} }
+102 -81
View File
@@ -19,7 +19,7 @@ import (
// resubmitTargetURL is a public destination, so a target configured // resubmitTargetURL is a public destination, so a target configured
// with it is one the SSRF guard would accept. Nothing in these tests // with it is one the SSRF guard would accept. Nothing in these tests
// dispatches to it: the delivery engine is never started. // dispatches to it: the notifier is recorded, not run.
const resubmitTargetURL = "http://93.184.216.34/hook" const resubmitTargetURL = "http://93.184.216.34/hook"
// resubmitEventHeaders and resubmitEventQuery are the stored header // resubmitEventHeaders and resubmitEventQuery are the stored header
@@ -134,9 +134,10 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -150,7 +151,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
// captured. It has no prior delivery of anything. // captured. It has no prior delivery of anything.
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
w := postResubmit(t, h, sess, wh.ID, original.ID) w := postResubmit(t, h, sess, wh.ID, original.ID)
@@ -188,6 +189,11 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
t, database.DeliveryStatusPending, deliveries[0].Status, t, database.DeliveryStatusPending, deliveries[0].Status,
) )
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, deliveries[0].ID, tasks[0].DeliveryID)
assertResubmitTask(t, tasks[0], wh.ID, &fresh, tgt)
assertNoLeakedTarget(t, webhookDB) assertNoLeakedTarget(t, webhookDB)
} }
@@ -219,6 +225,32 @@ func assertEventCopy(
) )
} }
// assertResubmitTask proves the task handed to the delivery engine is
// the one the receiver would build for the NEW event and this target.
func assertResubmitTask(
t *testing.T,
task delivery.Task,
webhookID string,
fresh *database.Event,
target *database.Target,
) {
t.Helper()
assert.Equal(t, fresh.ID, task.EventID)
assert.Equal(t, webhookID, task.WebhookID)
assert.Equal(t, fresh.EntrypointID, task.EntrypointID)
assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, fresh.Method, task.Method)
assert.Equal(t, fresh.RawQuery, task.RawQuery)
assert.Equal(t, fresh.Headers, task.Headers)
assert.Equal(t, fresh.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum)
require.NotNil(t, task.Body)
assert.Equal(t, fresh.Body, *task.Body)
}
// TestHandleEventResubmit_IsRepeatable proves the requirement replay // TestHandleEventResubmit_IsRepeatable proves the requirement replay
// deliberately does not meet: firing the same captured event at a // deliberately does not meet: firing the same captured event at a
// backend over and over must work, with no in-flight refusal, and each // backend over and over must work, with no in-flight refusal, and each
@@ -231,9 +263,10 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -241,7 +274,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`) original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
@@ -267,18 +300,30 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
events := listEvents(t, webhookDB) events := listEvents(t, webhookDB)
require.Len( require.Len(t, events, presses+1)
t, events, presses+1, "each resubmit creates its own event",
)
assert.Empty( tasks := notif.Tasks()
t, listDeliveries(t, webhookDB, original.ID), require.Len(t, tasks, presses)
"each resubmit delivers its own new event",
)
// Every copy has its own delivery, and names the same source seen := make(map[string]struct{}, presses)
// event, so twenty presses stay traceable to the one captured
// request. for _, task := range tasks {
assert.Equal(t, tgt.ID, task.TargetID)
assert.NotEqual(
t, original.ID, task.EventID,
"each resubmit delivers its own new event",
)
_, dup := seen[task.EventID]
assert.False(t, dup, "each resubmit creates its own event")
seen[task.EventID] = struct{}{}
require.Len(t, listDeliveries(t, webhookDB, task.EventID), 1)
}
// Every copy names the same source event, so twenty presses stay
// traceable to the one captured request.
for _, e := range events { for _, e := range events {
if e.ID == original.ID { if e.ID == original.ID {
continue continue
@@ -286,10 +331,6 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.NotNil(t, e.ResubmittedFromID) require.NotNil(t, e.ResubmittedFromID)
assert.Equal(t, original.ID, *e.ResubmittedFromID) assert.Equal(t, original.ID, *e.ResubmittedFromID)
deliveries := listDeliveries(t, webhookDB, e.ID)
require.Len(t, deliveries, 1)
assert.Equal(t, tgt.ID, deliveries[0].TargetID)
} }
} }
@@ -305,9 +346,10 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -326,7 +368,7 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, stored) original := seedStoredEvent(t, dbMgr, wh.ID, stored)
@@ -343,7 +385,22 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
t, stored, fresh.Body, t, stored, fresh.Body,
"the stored body must be copied byte for byte", "the stored body must be copied byte for byte",
) )
assert.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Nil(
t, tasks[0].Body,
"a body over the inline limit is fetched from the new "+
"event row rather than carried on the task",
)
// The engine's own read of the body, against the new event id:
// what it would send is what was stored.
var loaded database.Event
require.NoError(t, webhookDB.Select("body").
First(&loaded, "id = ?", tasks[0].EventID).Error)
assert.Equal(t, stored, loaded.Body)
} }
// TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated // TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated
@@ -357,9 +414,10 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -367,11 +425,11 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
active := seedConfiguredTarget( active := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
off := seedConfiguredTarget( off := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL + "/off"}, `{"url":"`+resubmitTargetURL+`/off"}`,
) )
require.NoError(t, db.DB().Model(&database.Target{}). require.NoError(t, db.DB().Model(&database.Target{}).
@@ -390,19 +448,21 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
"an inactive target is skipped, not an error", "an inactive target is skipped, not an error",
) )
tasks := notif.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, active.ID, tasks[0].TargetID)
webhookDB, err := dbMgr.GetDB(wh.ID) webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err) require.NoError(t, err)
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID) fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
deliveries := listDeliveries(t, webhookDB, fresh.ID) require.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
require.Len(t, deliveries, 1)
assert.Equal(t, active.ID, deliveries[0].TargetID)
} }
// TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a // TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a
// source with nothing to deliver to behaves as the receiver does: the // source with nothing to deliver to behaves as the receiver does: the
// event is stored with no delivery, and the operator is told so rather // event is stored, nothing is queued, and the operator is told so
// than being shown an error. // rather than being shown an error.
func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent( func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
t *testing.T, t *testing.T,
) { ) {
@@ -413,9 +473,10 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -432,14 +493,12 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
w.Header().Get("Location"), w.Header().Get("Location"),
) )
assert.Empty(t, notif.Tasks())
webhookDB, err := dbMgr.GetDB(wh.ID) webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err) require.NoError(t, err)
events := listEvents(t, webhookDB) assert.Len(t, listEvents(t, webhookDB), 2)
assert.Len(t, events, 2)
fresh := theOtherEvent(t, events, original.ID)
assert.Empty(t, listDeliveries(t, webhookDB, fresh.ID))
} }
// TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the // TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the
@@ -455,9 +514,10 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
sess *session.Session sess *session.Session
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
notif *recordingNotifier
) )
app := newTestApp(t, &h, &sess, &db, &dbMgr) app := newTestApp(t, &h, &sess, &db, &dbMgr, &notif)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -468,7 +528,7 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
mine := seedWebhook(t, db) mine := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, mine.ID, database.TargetTypeHTTP, t, db, mine.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`) seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
@@ -488,49 +548,10 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
w = postResubmit(t, h, sess, mine.ID, "not-a-uuid") w = postResubmit(t, h, sess, mine.ID, "not-a-uuid")
assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, http.StatusNotFound, w.Code)
for _, webhookID := range []string{theirs.ID, mine.ID} { assert.Empty(
webhookDB, err := dbMgr.GetDB(webhookID) t, notif.Tasks(),
require.NoError(t, err) "a refused resubmit must queue nothing",
assert.Len(
t, listEvents(t, webhookDB), 1,
"a refused resubmit must store nothing",
)
}
}
// TestHandleEventResubmit_FailedStoreIsAnError proves a copy the engine
// cannot store answers with the 500 error page and leaves no new event.
func TestHandleEventResubmit_FailedStoreIsAnError(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)
original := seedStoredEvent(t, dbMgr, wh.ID, `{"not":"stored"}`)
// Without its event totals table the webhook's database refuses
// the totals write, which comes after the event row.
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.EventTotals{}))
w := postResubmit(t, h, sess, wh.ID, original.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
assert.Len(t, listEvents(t, webhookDB), 1)
} }
// TestHandleSourceLogs_ShowsResubmitProvenance proves the event log // TestHandleSourceLogs_ShowsResubmitProvenance proves the event log
@@ -554,7 +575,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: resubmitTargetURL}, `{"url":"`+resubmitTargetURL+`"}`,
) )
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`) original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
+25 -17
View File
@@ -133,28 +133,36 @@ func (s *Handlers) RenderTemplateForTest(
s.renderTemplate(w, r, pageTemplate, data) s.renderTemplate(w, r, pageTemplate, data)
} }
// SetSlackTargetSettingsForTest exposes setSlackTargetSettings for use // BuildSlackTargetConfigForTest exposes
// in the handlers_test package. // buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) SetSlackTargetSettingsForTest( func (s *Handlers) BuildSlackTargetConfigForTest(
ctx context.Context, ctx context.Context,
target *database.Target,
targetURL string, targetURL string,
) string { ) (string, string, error) {
return s.setSlackTargetSettings(ctx, target, targetURL) return s.buildSlackTargetConfig(ctx, targetURL)
} }
// SetDatabaseTargetSettingsForTest exposes setDatabaseTargetSettings // BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package. // for use in the handlers_test package, taking the form fields
func SetDatabaseTargetSettingsForTest( // an HTTP target's configuration is built from.
target *database.Target, func (s *Handlers) BuildHTTPTargetConfigForTest(
ctx context.Context,
targetURL, headers, timeout string,
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
})
}
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
expiry, rotation string, expiry, rotation string,
) string { ) (string, string, error) {
return setDatabaseTargetSettings(target, expiry, rotation) return buildDatabaseTargetConfig(expiry, rotation)
}
// FormatArchiveExpiryForTest exposes formatArchiveExpiry.
func FormatArchiveExpiryForTest(expiry time.Duration) string {
return formatArchiveExpiry(expiry)
} }
// ArchiveFileViewForTest exposes archiveFileView, which describes a // ArchiveFileViewForTest exposes archiveFileView, which describes a
+1 -1
View File
@@ -374,7 +374,7 @@ func assertFloodBounded(t *testing.T, label, out string) {
// this service's three gorm.Open sites: the main database behind the // this service's three gorm.Open sites: the main database behind the
// two unauthenticated lookups, and the per-webhook database the // two unauthenticated lookups, and the per-webhook database the
// WebhookDBManager opens. The third, the archive writer, is pinned in // WebhookDBManager opens. The third, the archive writer, is pinned in
// internal/archive, where its type lives. // internal/delivery, where its type lives.
// //
// What each assertion is worth, since two of the three would pass // What each assertion is worth, since two of the three would pass
// against a service that had never been fixed if the capture were set // against a service that had never been fixed if the capture were set
+17 -6
View File
@@ -10,11 +10,11 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
@@ -64,8 +64,8 @@ type HandlersParams struct {
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Session *session.Session Session *session.Session
Middleware *middleware.Middleware Middleware *middleware.Middleware
Engine *delivery.Engine Notifier delivery.Notifier
Archives archive.Archives Archives delivery.Archives
CircuitBreakers delivery.CircuitBreakers CircuitBreakers delivery.CircuitBreakers
SSRFGuard *delivery.Guard SSRFGuard *delivery.Guard
Metrics *metrics.Set Metrics *metrics.Set
@@ -82,8 +82,8 @@ type Handlers struct {
dbMgr *database.WebhookDBManager dbMgr *database.WebhookDBManager
session *session.Session session *session.Session
mw *middleware.Middleware mw *middleware.Middleware
engine *delivery.Engine notifier delivery.Notifier
archives archive.Archives archives delivery.Archives
breakers delivery.CircuitBreakers breakers delivery.CircuitBreakers
mtr *metrics.Set mtr *metrics.Set
templates map[string]*template.Template templates map[string]*template.Template
@@ -93,6 +93,17 @@ type Handlers struct {
// is one delivery will actually attempt. // is one delivery will actually attempt.
ssrf *delivery.Guard ssrf *delivery.Guard
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// it reads the stored names and lists the files they give, and
// again for each file while it finds the file under the names
// stored then and opens it.
renameMu sync.Mutex
// dummyVerifications counts the equivalent-cost verifications // dummyVerifications counts the equivalent-cost verifications
// charged for usernames that do not exist. It exists so a test // charged for usernames that do not exist. It exists so a test
// can prove that path runs without measuring wall-clock time. // can prove that path runs without measuring wall-clock time.
@@ -138,7 +149,7 @@ func New(
s.dbMgr = params.WebhookDBMgr s.dbMgr = params.WebhookDBMgr
s.session = params.Session s.session = params.Session
s.mw = params.Middleware s.mw = params.Middleware
s.engine = params.Engine s.notifier = params.Notifier
s.archives = params.Archives s.archives = params.Archives
s.breakers = params.CircuitBreakers s.breakers = params.CircuitBreakers
s.mtr = params.Metrics s.mtr = params.Metrics
+105 -117
View File
@@ -8,7 +8,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -17,7 +16,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
@@ -30,14 +28,39 @@ import (
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
// recordingArchives is an archive.Archives that records what it // recordingNotifier is a delivery.Notifier that records the tasks it
// was handed, so a test can prove a handler queued the delivery it
// claims to have queued — and, on the refusal paths, that it queued
// nothing.
type recordingNotifier struct {
mu sync.Mutex
tasks []delivery.Task
}
func (n *recordingNotifier) Notify(tasks []delivery.Task) {
n.mu.Lock()
defer n.mu.Unlock()
n.tasks = append(n.tasks, tasks...)
}
// Tasks returns a copy of the recorded tasks.
func (n *recordingNotifier) Tasks() []delivery.Task {
n.mu.Lock()
defer n.mu.Unlock()
out := make([]delivery.Task, len(n.tasks))
copy(out, n.tasks)
return out
}
// recordingArchives is a delivery.Archives that records what it
// was asked to do, so a test can prove that a deletion or rename // was asked to do, so a test can prove that a deletion or rename
// path reached the archive writers. After FailRenames, every // path reached the delivery engine. After FailRenames, every
// rename of that target fails with the given error. After // rename of that target fails with the given error. After
// BlockNextRename, the next rename is recorded and then waits. // BlockNextRename, the next rename is recorded and then waits.
// RenameLock returns a plain mutex, as in archive.Writers.
type recordingArchives struct { type recordingArchives struct {
renameMu sync.Mutex
mu sync.Mutex mu sync.Mutex
evicted []string evicted []string
evictedTargets []string evictedTargets []string
@@ -50,10 +73,10 @@ type recordingArchives struct {
// errInjectedRename is the failure a test hands FailRenames. // errInjectedRename is the failure a test hands FailRenames.
var errInjectedRename = errors.New("injected rename failure") var errInjectedRename = errors.New("injected rename failure")
// errNameTaken is what the archive writers return when a file // errNameTaken is what the delivery engine returns when a file
// already has an archive's new name, here archive-taken.db. // already has an archive's new name, here archive-taken.db.
var errNameTaken = fmt.Errorf( var errNameTaken = fmt.Errorf(
"%w: archive-taken.db", archive.ErrNameTaken, "%w: archive-taken.db", delivery.ErrArchiveNameTaken,
) )
// archiveRename is one recorded Rename call. // archiveRename is one recorded Rename call.
@@ -63,10 +86,6 @@ type archiveRename struct {
TargetName string TargetName string
} }
func (r *recordingArchives) RenameLock() *sync.Mutex {
return &r.renameMu
}
func (r *recordingArchives) EvictWebhook(webhookID string) { func (r *recordingArchives) EvictWebhook(webhookID string) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -237,17 +256,16 @@ func newTestAppWithConfig(
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
session.New, session.New,
archive.NewWriters, func() *recordingNotifier {
// The engine is built on a lifecycle that is never started, return &recordingNotifier{}
// so no worker runs: every delivery the engine creates for a },
// handler stays pending, where the test can read it. func(n *recordingNotifier) delivery.Notifier {
func(p delivery.EngineParams) *delivery.Engine { return n
return delivery.New(fxtest.NewLifecycle(t), p)
}, },
func() *recordingArchives { func() *recordingArchives {
return &recordingArchives{} return &recordingArchives{}
}, },
func(r *recordingArchives) archive.Archives { func(r *recordingArchives) delivery.Archives {
return r return r
}, },
func() *testCircuitBreakers { func() *testCircuitBreakers {
@@ -329,7 +347,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
) )
} }
func TestSetSlackTargetSettings_AcceptsPublicURL(t *testing.T) { func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Parallel() t.Parallel()
var h *handlers.Handlers var h *handlers.Handlers
@@ -339,34 +357,32 @@ func TestSetSlackTargetSettings_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
const hookURL = "http://93.184.216.34/services/T00/B00/xxx" cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
var target database.Target
errMsg := h.SetSlackTargetSettingsForTest(t.Context(), &target, hookURL)
assert.Empty(t, errMsg)
assert.Equal(t, hookURL, target.URL)
}
func TestSetSlackTargetSettings_RejectsReservedURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
var target database.Target
errMsg := h.SetSlackTargetSettingsForTest(
t.Context(), &target, "http://169.254.169.254/latest/meta-data/",
) )
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Contains(t, cfg, "webhookUrl")
}
func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
)
require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL") assert.Contains(t, errMsg, "Invalid target URL")
assert.Empty(t, target.URL) assert.Empty(t, cfg)
} }
func TestRenderTemplate(t *testing.T) { func TestRenderTemplate(t *testing.T) {
@@ -460,100 +476,72 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
) )
} }
func TestSetDatabaseTargetSettings_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { // Empty expiry and rotation: the keep-forever, one-file default,
expiry, rotation string // empty config.
wantExpiry time.Duration cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
wantRotation string require.NoError(t, err)
}{ assert.Empty(t, errMsg)
// Empty expiry and rotation: the keep-forever, one-file assert.Empty(t, cfg)
// default.
{"", "", 0, rotationNone},
{expiryNever, "", 0, rotationNone},
{" 36h ", "", 36 * time.Hour, rotationNone},
{"", rotationDaily, 0, rotationDaily},
{"90m", rotationHourly, 90 * time.Minute, rotationHourly},
}
for _, tc := range cases { // Explicit never is stored as config.
var target database.Target cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
errMsg := handlers.SetDatabaseTargetSettingsForTest( // A positive duration is stored as config.
&target, tc.expiry, tc.rotation, cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
) require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
assert.Empty(t, errMsg, tc) // A rotation is stored as config, with or without an expiry.
assert.Equal(t, tc.wantExpiry, target.ArchiveExpiry, tc) cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
assert.Equal(t, tc.wantRotation, target.ArchiveRotation, tc) require.NoError(t, err)
} assert.Empty(t, errMsg)
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
"720h", "hourly",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
} }
// TestSetDatabaseTargetSettings_RejectsBadExpiry pins each refusal's func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
// whole message, as the form shows it.
func TestSetDatabaseTargetSettings_RejectsBadExpiry(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
cases := map[string]string{ for _, bad := range []string{"nonsense", "7d", "-5h"} {
"nonsense": `Invalid archive expiry: expiry must be "never" or ` + cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
`a Go duration such as "720h": time: invalid duration ` +
`"nonsense"`,
"7d": `Invalid archive expiry: expiry must be "never" or a Go ` +
`duration such as "720h": time: unknown unit "d" in ` +
`duration "7d"`,
"-5h": `Invalid archive expiry: expiry must be a positive ` +
`duration or "never": "-5h"`,
}
for bad, want := range cases { require.NoError(t, err)
var target database.Target assert.Contains(
t, errMsg, "Invalid archive expiry",
errMsg := handlers.SetDatabaseTargetSettingsForTest( "expiry %q should be refused", bad,
&target, bad, "",
) )
assert.Empty(t, cfg)
assert.Equal(t, want, errMsg, "expiry %q", bad)
} }
} }
func TestSetDatabaseTargetSettings_RejectsBadRotation( func TestBuildDatabaseTargetConfig_RejectsBadRotation(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
for _, bad := range []string{"weekly", "Daily", " none"} { for _, bad := range []string{"weekly", "Daily", " none"} {
var target database.Target cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
errMsg := handlers.SetDatabaseTargetSettingsForTest( require.NoError(t, err)
&target, "", bad, assert.Contains(
) t, errMsg, "Invalid archive rotation",
"rotation %q should be refused", bad,
assert.Equal(
t,
"Invalid archive rotation: rotation must be none, "+
"monthly, daily or hourly: "+strconv.Quote(bad),
errMsg,
) )
} assert.Empty(t, cfg)
}
// TestFormatArchiveExpiry covers the expiries the expiry choices do
// not: TestArchiveExpiryChoices shows the edit form starting on each
// choice. An expiry in whole hours is written in hours, and any other
// as Go writes it, which the form accepts back unchanged.
func TestFormatArchiveExpiry(t *testing.T) {
t.Parallel()
cases := map[time.Duration]string{
0: expiryNever,
36 * time.Hour: "36h",
90 * time.Minute: "1h30m0s",
}
for expiry, want := range cases {
assert.Equal(t, want, handlers.FormatArchiveExpiryForTest(expiry))
} }
} }
+12 -23
View File
@@ -7,7 +7,6 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -55,26 +54,15 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
// Each value the archive pruning choice submits and the expiry it // Each value the archive pruning choice submits, after an empty
// stores, after an empty one that stands for the archive checkbox // one that stands for the archive checkbox left off.
// left off. expiries := []string{
expiries := []struct { "", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
value string
stored time.Duration
}{
{"", 0},
{expiryNever, 0},
{"1h", time.Hour},
{"12h", 12 * time.Hour},
{"24h", 24 * time.Hour},
{"720h", 720 * time.Hour},
{"2160h", 2160 * time.Hour},
{"8760h", 8760 * time.Hour},
} }
for _, httpURL := range []string{"", editOriginalURL} { for _, httpURL := range []string{"", editOriginalURL} {
for _, expiry := range expiries { for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry.value name := "url=" + httpURL + " archive=" + expiry
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -84,9 +72,9 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
form.Set("http_url", httpURL) form.Set("http_url", httpURL)
form.Set("archive_expiry", "720h") form.Set("archive_expiry", "720h")
if expiry.value != "" { if expiry != "" {
form.Set("archive", "on") form.Set("archive", "on")
form.Set("archive_expiry", expiry.value) form.Set("archive_expiry", expiry)
} }
w := submitCreateForm(env, form) w := submitCreateForm(env, form)
@@ -108,16 +96,17 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
wantCount++ wantCount++
assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name) assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name)
assert.Equal(t, httpURL, byType[database.TargetTypeHTTP].URL) assert.JSONEq(t, `{"url":"`+httpURL+`"}`,
byType[database.TargetTypeHTTP].Config)
} }
if expiry.value != "" { if expiry != "" {
wantCount++ wantCount++
assert.Equal(t, "Archive", assert.Equal(t, "Archive",
byType[database.TargetTypeDatabase].Name) byType[database.TargetTypeDatabase].Name)
assert.Equal(t, expiry.stored, assert.JSONEq(t, `{"expiry":"`+expiry+`"}`,
byType[database.TargetTypeDatabase].ArchiveExpiry) byType[database.TargetTypeDatabase].Config)
} }
assert.Len(t, byType, wantCount) assert.Len(t, byType, wantCount)
+7 -7
View File
@@ -16,8 +16,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -180,7 +180,7 @@ func failSaveOnTable(
} }
// archivePathFor returns the archive database path the // archivePathFor returns the archive database path the
// archive writers would use for a database target: beside the // delivery engine would use for a database target: beside the
// webhook's event database in the data directory. // webhook's event database in the data directory.
func archivePathFor( func archivePathFor(
t *testing.T, t *testing.T,
@@ -192,7 +192,7 @@ func archivePathFor(
return filepath.Join( return filepath.Join(
filepath.Dir(mgr.DBPath(wh.ID)), filepath.Dir(mgr.DBPath(wh.ID)),
archive.FileName(wh.Name, tgt.Name, tgt.ID), delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
) )
} }
@@ -228,9 +228,9 @@ func postRequest(
} }
// TestHandleSourceDelete_EvictsArchiveWriter proves that // TestHandleSourceDelete_EvictsArchiveWriter proves that
// deleting a webhook releases the webhook's archive writers, // deleting a webhook reaches the delivery engine and releases
// exercised through the real deletion handler rather than by // the webhook's archive writers, exercised through the real
// calling the archive writers directly. // deletion handler rather than by calling the engine directly.
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) { func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
t.Parallel() t.Parallel()
@@ -290,7 +290,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase) tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
// Place an archive file where the archive writers would. // Place an archive file where the delivery engine would.
archivePath := archivePathFor(t, mgr, wh, tgt) archivePath := archivePathFor(t, mgr, wh, tgt)
require.NoError( require.NoError(
t, t,
+86 -62
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -26,24 +25,25 @@ const (
slackSecretPath slackSecretPath
) )
// seedConfiguredTarget inserts an active target of targetType with // seedConfiguredTarget inserts a target with a stored config
// the settings (URL to ArchiveRotation) that settings carries, and // blob and returns it.
// returns it.
func seedConfiguredTarget( func seedConfiguredTarget(
t *testing.T, t *testing.T,
db *database.Database, db *database.Database,
webhookID string, webhookID string,
targetType database.TargetType, targetType database.TargetType,
settings database.Target, config string,
) *database.Target { ) *database.Target {
t.Helper() t.Helper()
tgt := &settings tgt := &database.Target{
tgt.WebhookID = webhookID WebhookID: webhookID,
tgt.Name = "t-" + string(targetType) Name: "t-" + string(targetType),
tgt.Type = targetType Type: targetType,
tgt.Active = true Active: true,
tgt.MaxAttempts = 1 Config: config,
MaxAttempts: 1,
}
require.NoError( require.NoError(
t, t,
@@ -108,59 +108,89 @@ func serveSourceDetailPage(
return w return w
} }
// TestHandleSourceDetail_MasksTargetURL is the load-bearing // TestHandleSourceDetail_MasksSlackWebhookURL is the
// regression test for the credential leak: the rendered page must // load-bearing regression test for the credential leak: the
// show a Slack target, and an http target, without any of the secret // rendered page must show the Slack target without any of the
// path segments of its URL. An http target's destination is routinely // secret path segments of its webhook URL.
// an incoming-webhook endpoint too, so the same Slack URL serves for func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) {
// both.
func TestHandleSourceDetail_MasksTargetURL(t *testing.T) {
t.Parallel() t.Parallel()
labels := map[database.TargetType]string{ var (
database.TargetTypeSlack: "Webhook URL", h *handlers.Handlers
database.TargetTypeHTTP: "Destination URL", sess *session.Session
} db *database.Database
)
for targetType, label := range labels { app := newTestApp(t, &h, &sess, &db)
t.Run(string(targetType), func(t *testing.T) { app.RequireStart()
t.Parallel()
var ( t.Cleanup(app.RequireStop)
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db) wh := seedWebhook(t, db)
app.RequireStart() seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeSlack,
`{"webhookUrl":"`+slackWebhookURL+`"}`,
)
t.Cleanup(app.RequireStop) body := renderSourceDetailPage(t, h, sess, wh.ID)
wh := seedWebhook(t, db) assert.NotContains(t, body, slackSecretPath)
seedConfiguredTarget( assert.NotContains(t, body, "T00000000")
t, db, wh.ID, targetType, assert.NotContains(t, body, "B00000000")
database.Target{URL: slackWebhookURL}, assert.NotContains(
) t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.NotContains(t, body, "webhookUrl")
body := renderSourceDetailPage(t, h, sess, wh.ID) assert.Contains(t, body, "Webhook URL")
assert.Contains(t, body, "https://hooks.slack.com/...")
}
assert.NotContains(t, body, slackSecretPath) // TestHandleSourceDetail_MasksHTTPDestinationURL is the
assert.NotContains(t, body, "T00000000") // regression test for the same leak reached through the http
assert.NotContains(t, body, "B00000000") // target: its destination is routinely an incoming-webhook
assert.NotContains( // endpoint whose path segments are the credential, so the
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", // rendered page must not contain them.
) func TestHandleSourceDetail_MasksHTTPDestinationURL(
t *testing.T,
) {
t.Parallel()
assert.Contains(t, body, label) var (
assert.Contains(t, body, "https://hooks.slack.com/...") h *handlers.Handlers
}) sess *session.Session
} db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
seedConfiguredTarget(
t, db, wh.ID,
database.TargetTypeHTTP,
`{"url":"`+slackWebhookURL+`"}`,
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.NotContains(t, body, slackSecretPath)
assert.NotContains(t, body, "T00000000")
assert.NotContains(t, body, "B00000000")
assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.Contains(t, body, "Destination URL")
assert.Contains(t, body, "https://hooks.slack.com/...")
} }
// TestHandleSourceDetail_RendersNamedTargetFields proves the // TestHandleSourceDetail_RendersNamedTargetFields proves the
// other target types render labelled fields rather than the // other target types render labelled fields rather than the
// stored values. // stored blob.
func TestHandleSourceDetail_RendersNamedTargetFields( func TestHandleSourceDetail_RendersNamedTargetFields(
t *testing.T, t *testing.T,
) { ) {
@@ -182,24 +212,18 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeHTTP, database.TargetTypeHTTP,
database.Target{ `{"url":"https://example.com/hook","timeout":30,`+
URL: "https://example.com/hook", `"headers":{"Authorization":"Bearer sekrit"}}`,
TimeoutSeconds: 30,
Headers: map[string]string{"X-Api-Key": "sekrit"},
},
) )
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeDatabase, database.TargetTypeDatabase,
database.Target{ `{"expiry":"720h"}`,
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: rotationNone,
},
) )
seedConfiguredTarget( seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetType("carrier-pigeon"), database.TargetType("carrier-pigeon"),
database.Target{URL: "https://pigeon.example/beak"}, `{"beak":"sharp"}`,
) )
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
@@ -218,8 +242,8 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, list, "Archive rotation: none") assert.Contains(t, list, "Archive rotation: none")
assert.Contains(t, body, `<option value="database">Archive</option>`) assert.Contains(t, body, `<option value="database">Archive</option>`)
// An unknown type gets the neutral placeholder, never its // An unknown type gets the neutral placeholder, never the
// stored URL. // stored blob.
assert.Contains(t, body, "(unavailable)") assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak") assert.NotContains(t, body, "beak")
} }
@@ -161,7 +161,7 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) {
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID) seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
@@ -175,6 +175,7 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) {
assert.NotContains( assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
) )
assert.NotContains(t, body, "webhookUrl")
// The name is there; only the credential is not. // The name is there; only the credential is not.
assert.Contains(t, body, tgt.Name+deletedMarker) assert.Contains(t, body, tgt.Name+deletedMarker)
+2 -1
View File
@@ -138,7 +138,7 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
tgt := seedConfiguredTarget( tgt := seedConfiguredTarget(
t, db, wh.ID, t, db, wh.ID,
database.TargetTypeSlack, database.TargetTypeSlack,
database.Target{URL: slackWebhookURL}, `{"webhookUrl":"`+slackWebhookURL+`"}`,
) )
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID) seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
@@ -151,6 +151,7 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
assert.NotContains( assert.NotContains(
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
) )
assert.NotContains(t, body, "webhookUrl")
// The page still identifies the delivery's target. // The page still identifies the delivery's target.
assert.Contains(t, body, tgt.Name) assert.Contains(t, body, tgt.Name)
+111 -77
View File
@@ -2,13 +2,13 @@ package handlers
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -27,8 +27,8 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
sourceID := chi.URLParam(r, "sourceID") sourceID := chi.URLParam(r, "sourceID")
h.archives.RenameLock().Lock() h.renameMu.Lock()
defer h.archives.RenameLock().Unlock() defer h.renameMu.Unlock()
var webhook database.Webhook var webhook database.Webhook
@@ -64,14 +64,20 @@ func (h *Handlers) processTargetCreate(
) { ) {
in := targetFormInputFrom(r) in := targetFormInputFrom(r)
target, errMsg := h.newTarget(r.Context(), webhook.ID, in) target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" { if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg) h.renderSourceDetail(w, r, webhook, in, errMsg)
return return
} }
err := h.db.DB().Create(target).Error err = h.db.DB().Create(target).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to create target", err) h.serverError(w, r, "failed to create target", err)
@@ -86,13 +92,15 @@ func (h *Handlers) processTargetCreate(
// newTarget validates a new target for a webhook and returns the row // newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form // to create, or, when it refuses the target, the message the form
// shows. Every form that creates a target goes through here, so they // shows. An error is the server's fault, not a refusal: the accepted
// all accept and refuse the same things. // configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
func (h *Handlers) newTarget( func (h *Handlers) newTarget(
ctx context.Context, ctx context.Context,
webhookID string, webhookID string,
in targetFormInput, in targetFormInput,
) (*database.Target, string) { ) (*database.Target, string, error) {
target := &database.Target{ target := &database.Target{
WebhookID: webhookID, WebhookID: webhookID,
Type: in.Type, Type: in.Type,
@@ -100,33 +108,34 @@ func (h *Handlers) newTarget(
MaxAttempts: 1, MaxAttempts: 1,
} }
errMsg := h.setTargetFromForm(ctx, target, in) errMsg, err := h.setTargetFromForm(ctx, target, in)
if errMsg != "" { if err != nil || errMsg != "" {
return nil, errMsg return nil, errMsg, err
} }
return target, "" return target, "", nil
} }
// setTargetFromForm validates a target form against the target's type // setTargetFromForm validates a target form against the target's type
// and sets the target's name, settings and number of delivery attempts // and, when it accepts it, sets the target's name, configuration and
// from it. It returns the message the form shows for anything it // number of delivery attempts from it. It returns the message the form
// refuses, an unknown type among them; the caller then discards the // shows for anything it refuses, an unknown type among them, and then
// target, which may be partly set. // leaves the target unchanged; an error is the server's fault, as for
// newTarget.
// The add target form and the target edit form both go through here, // The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be. // so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm( func (h *Handlers) setTargetFromForm(
ctx context.Context, ctx context.Context,
target *database.Target, target *database.Target,
in targetFormInput, in targetFormInput,
) string { ) (string, error) {
if in.Name == "" { if in.Name == "" {
return "Name is required" return "Name is required", nil
} }
errMsg := h.setTargetSettings(ctx, target, in) configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if errMsg != "" { if err != nil || errMsg != "" {
return errMsg return errMsg, err
} }
// An empty max_attempts keeps the target's count: the default of // An empty max_attempts keeps the target's count: the default of
@@ -137,13 +146,14 @@ func (h *Handlers) setTargetFromForm(
// target is delivering with. // target is delivering with.
maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts) maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts)
if err != nil { if err != nil {
return "Invalid delivery attempts: " + attemptsErrorMessage(err) return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil
} }
target.Name = in.Name target.Name = in.Name
target.Config = configJSON
target.MaxAttempts = maxAttempts target.MaxAttempts = maxAttempts
return "" return "", nil
} }
// targetFormInput carries the raw values of a target form. Both the // targetFormInput carries the raw values of a target form. Both the
@@ -202,81 +212,84 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
} }
} }
// setTargetSettings sets a target's settings from the submitted form // buildTargetConfig builds the JSON config string for a target from
// values, or returns the message the form shows for a value it // the submitted form values, or returns the message the form shows
// refuses. Which fields of in apply depends on the target type; a // for a value it refuses. An error is the server's fault, not a
// type without a URL ignores any URL submitted, and the settings a // refusal: the accepted configuration could not be encoded. Which
// type does not use are left empty. // fields of in apply depends on the target type; a type without a URL
func (h *Handlers) setTargetSettings( // ignores any URL submitted.
func (h *Handlers) buildTargetConfig(
ctx context.Context, ctx context.Context,
target *database.Target, targetType database.TargetType,
in targetFormInput, in targetFormInput,
) string { ) (string, string, error) {
switch target.Type { switch targetType {
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return h.setHTTPTargetSettings(ctx, target, in) return h.buildHTTPTargetConfig(ctx, in)
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.setSlackTargetSettings(ctx, target, in.URL) return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return setDatabaseTargetSettings(target, in.Expiry, in.Rotation) return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
case database.TargetTypeLog: case database.TargetTypeLog:
return "" return "", "", nil
default: default:
return "Invalid target type" return "", "Invalid target type", nil
} }
} }
// setHTTPTargetSettings sets an HTTP target's SSRF-validated // buildHTTPTargetConfig builds config JSON for an HTTP target: an
// destination plus the optional headers, timeout and query string // SSRF-validated destination plus the optional headers, timeout and
// setting the delivery path honours. // query string setting the delivery path honours.
func (h *Handlers) setHTTPTargetSettings( func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context, ctx context.Context,
target *database.Target,
in targetFormInput, in targetFormInput,
) string { ) (string, string, error) {
errMsg := h.validateTargetURL( errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets", ctx, in.URL, "URL is required for HTTP targets",
) )
if errMsg != "" { if errMsg != "" {
return errMsg return "", errMsg, nil
} }
headers, err := delivery.ParseTargetHeaders(in.Headers) headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil { if err != nil {
return fmt.Sprintf("Invalid headers: %v", err) return "", fmt.Sprintf("Invalid headers: %v", err), nil
} }
timeout, err := delivery.ParseTargetTimeout(in.Timeout) timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil { if err != nil {
return fmt.Sprintf("Invalid timeout: %v", err) return "", fmt.Sprintf("Invalid timeout: %v", err), nil
} }
target.URL = in.URL configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
target.Headers = headers URL: in.URL,
target.TimeoutSeconds = timeout Headers: headers,
target.ForwardQuery = in.ForwardQuery Timeout: timeout,
ForwardQuery: in.ForwardQuery,
})
return "" return configJSON, "", err
} }
// setSlackTargetSettings sets a Slack target's one setting, its // buildSlackTargetConfig builds config JSON for a Slack target,
// SSRF-validated webhook URL. // whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) setSlackTargetSettings( func (h *Handlers) buildSlackTargetConfig(
ctx context.Context, ctx context.Context,
target *database.Target,
targetURL string, targetURL string,
) string { ) (string, string, error) {
errMsg := h.validateTargetURL( errMsg := h.validateTargetURL(
ctx, targetURL, ctx, targetURL,
"Webhook URL is required for Slack targets", "Webhook URL is required for Slack targets",
) )
if errMsg != "" { if errMsg != "" {
return errMsg return "", errMsg, nil
} }
target.URL = targetURL configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
return "" return configJSON, "", err
} }
// validateTargetURL refuses an empty or SSRF-blocked destination, // validateTargetURL refuses an empty or SSRF-blocked destination,
@@ -327,31 +340,52 @@ func (h *Handlers) validateTargetURL(
return "" return ""
} }
// setDatabaseTargetSettings sets a database (archive) target's // marshalTargetConfig serialises a target configuration for storage.
// expiry and rotation. Both are optional: an empty or never expiry func marshalTargetConfig(cfg any) (string, error) {
// keeps the archive forever, and an empty rotation is stored as none, configBytes, err := json.Marshal(cfg)
// one file. A bad value is refused here, at the form, instead of
// being stored.
func setDatabaseTargetSettings(
target *database.Target,
expiry, rotation string,
) string {
archiveExpiry, err := archive.ParseExpiry(strings.TrimSpace(expiry))
if err != nil { if err != nil {
return fmt.Sprintf("Invalid archive expiry: %v", err) return "", err
} }
err = archive.ValidateRotation(rotation) return string(configBytes), nil
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
err = delivery.ValidateArchiveRotation(rotation)
if err != nil { if err != nil {
return fmt.Sprintf("Invalid archive rotation: %v", err) return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
}
if rotation != "" {
cfg["rotation"] = rotation
} }
if rotation == "" { if len(cfg) == 0 {
rotation = archiveRotationNone return "", "", nil
} }
target.ArchiveExpiry = archiveExpiry configJSON, err := marshalTargetConfig(cfg)
target.ArchiveRotation = rotation
return "" return configJSON, "", err
} }
@@ -176,7 +176,7 @@ func TestHandleTargetCreate_BodyURLStillCreatesTheTarget(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.Equal(t, targetSecretURL, targets[0].URL) assert.Contains(t, targets[0].Config, targetSecretSegments)
// The body carried the credential, so the access log must still // The body carried the credential, so the access log must still
// not have it: the log records the request line only. // not have it: the log records the request line only.
+33 -74
View File
@@ -6,114 +6,73 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// TestHandleTargetCreate_EveryType adds a target of each type and // TestHandleTargetCreate_EveryType adds a target of each type. Each
// checks the settings it stores. Each submission carries a url and an // submission carries a url: only the http and slack types store one.
// expiry: only the http and slack types store the url, only the
// database type the expiry, and every setting a type does not use
// stays empty.
func TestHandleTargetCreate_EveryType(t *testing.T) { func TestHandleTargetCreate_EveryType(t *testing.T) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) env := setupSourceTest(t)
// fields is the rest of each submission, as a query string, and // fields is the rest of each submission, as a query string.
// want holds the settings stored.
cases := []struct { cases := []struct {
targetType database.TargetType targetType database.TargetType
fields string fields string
want database.Target wantConfig string
wantAttempts int wantAttempts int
}{ }{
{ {
database.TargetTypeHTTP, database.TargetTypeHTTP, "timeout=12&max_attempts=3",
"timeout=12&max_attempts=3&forward_query=on", `{"url":"` + editOriginalURL + `","timeout":12}`, 3,
database.Target{
URL: editOriginalURL,
Headers: map[string]string{},
TimeoutSeconds: 12,
ForwardQuery: true,
}, 3,
}, },
{ {
database.TargetTypeSlack, "max_attempts=4", database.TargetTypeSlack, "max_attempts=4",
database.Target{URL: editOriginalURL}, 4, `{"webhookUrl":"` + editOriginalURL + `"}`, 4,
}, },
{ {
database.TargetTypeDatabase, "rotation=daily", database.TargetTypeDatabase, "expiry=720h",
database.Target{ `{"expiry":"720h"}`, 1,
ArchiveExpiry: 720 * time.Hour,
ArchiveRotation: rotationDaily,
}, 1,
}, },
{database.TargetTypeLog, "", database.Target{}, 1}, {database.TargetTypeLog, "", "", 1},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) { t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel() t.Parallel()
_, got := addTargetThroughForm( webhook := seedWebhookWithRetention(t, env.db, 30)
t, env, tc.targetType,
"url="+editOriginalURL+"&expiry=720h&"+tc.fields, form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(tc.targetType))
form.Set("url", editOriginalURL)
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
) )
assert.Equal(t, tc.targetType, got.Type) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(t, tc.wantAttempts, got.MaxAttempts)
assert.Equal(t, tc.want, targetSettingsOf(got)) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts)
if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config)
} else {
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
}
}) })
} }
} }
// addTargetThroughForm adds a target of targetType named every-type to
// a new webhook through the add target form, with the rest of the form
// given as a query string, and returns the webhook and the target the
// form stored.
func addTargetThroughForm(
t *testing.T,
env *sourceTestEnv,
targetType database.TargetType,
fields string,
) (database.Webhook, database.Target) {
t.Helper()
webhook := seedWebhookWithRetention(t, env.db, 30)
form, err := url.ParseQuery(fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(targetType))
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
return webhook, targets[0]
}
// targetSettingsOf returns a target's settings, URL to
// ArchiveRotation, and nothing else, for comparing with the settings a
// test expects.
func targetSettingsOf(target database.Target) database.Target {
return database.Target{
URL: target.URL,
Headers: target.Headers,
TimeoutSeconds: target.TimeoutSeconds,
ForwardQuery: target.ForwardQuery,
ArchiveExpiry: target.ArchiveExpiry,
ArchiveRotation: target.ArchiveRotation,
}
}
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each // TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
// type and checks that the webhook page comes back with the add target // type and checks that the webhook page comes back with the add target
// form open on that type, the values entered, and the reason. // form open on that type, the values entered, and the reason.
+16 -16
View File
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// downloadWriteTimeout is how long one write of a download may wait // downloadWriteTimeout is how long one write of a download may wait
@@ -16,7 +16,7 @@ const downloadWriteTimeout = 60 * time.Second
// HandleTargetDownload serves a database target's archive as one // HandleTargetDownload serves a database target's archive as one
// gzipped JSON file, named for the webhook, the target and the time; // gzipped JSON file, named for the webhook, the target and the time;
// see archive.Export.WriteGzipJSON for what it holds. Other // see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other
// target types have no archive and are a 404. // target types have no archive and are a 404.
// //
// A download runs for as long as the client keeps reading: it reads // A download runs for as long as the client keeps reading: it reads
@@ -37,7 +37,7 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Type", "application/gzip")
w.Header().Set( w.Header().Set(
"Content-Disposition", "Content-Disposition",
`attachment; filename="`+archive.ExportFileName( `attachment; filename="`+delivery.ArchiveExportFileName(
webhook.Name, target.Name, now, webhook.Name, target.Name, now,
)+`"`, )+`"`,
) )
@@ -82,17 +82,17 @@ func (d downloadWriter) Write(b []byte) (int, error) {
// listTargetArchive lists the archive files of the request's database // listTargetArchive lists the archive files of the request's database
// target for export. It reports false once it has written the response. // target for export. It reports false once it has written the response.
// //
// It holds the rename lock, which every archive rename runs under, // It holds renameMu, which every archive rename runs under, while it
// while it reads the stored names and lists the files, so the files it // reads the stored names and lists the files, so the files it lists
// lists are the ones those names give. It lets go before the export is // are the ones those names give. It lets go before the export is
// streamed, which takes the lock again for each file only while it // streamed, which takes renameMu again for each file only while it
// finds the file under the names stored then and opens it. // finds the file under the names stored then and opens it.
func (h *Handlers) listTargetArchive( func (h *Handlers) listTargetArchive(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
) (database.Webhook, *database.Target, *archive.Export, bool) { ) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
h.archives.RenameLock().Lock() h.renameMu.Lock()
defer h.archives.RenameLock().Unlock() defer h.renameMu.Unlock()
webhook, target, ok := h.ownedTarget(w, r) webhook, target, ok := h.ownedTarget(w, r)
if !ok { if !ok {
@@ -105,9 +105,9 @@ func (h *Handlers) listTargetArchive(
return database.Webhook{}, nil, nil, false return database.Webhook{}, nil, nil, false
} }
export, err := archive.NewExport( export, err := delivery.NewArchiveExport(
archive.Path(h.dbMgr, &webhook, target), delivery.ArchivePath(h.dbMgr, &webhook, target),
h.archives.RenameLock(), &h.renameMu,
func() (string, error) { func() (string, error) {
return h.storedArchivePath(webhook.ID, target.ID) return h.storedArchivePath(webhook.ID, target.ID)
}, },
@@ -122,9 +122,9 @@ func (h *Handlers) listTargetArchive(
return webhook, target, export, true return webhook, target, export, true
} }
// storedArchivePath returns the path archive.Path gives a // storedArchivePath returns the path delivery.ArchivePath gives a
// database target under the names stored for it and its webhook now. // database target under the names stored for it and its webhook now.
// Its caller holds the rename lock. A webhook or target deleted since is still // Its caller holds renameMu. A webhook or target deleted since is still
// found, since deleting one leaves its archive files under their names. // found, since deleting one leaves its archive files under their names.
func (h *Handlers) storedArchivePath( func (h *Handlers) storedArchivePath(
webhookID, targetID string, webhookID, targetID string,
@@ -143,5 +143,5 @@ func (h *Handlers) storedArchivePath(
return "", err return "", err
} }
return archive.Path(h.dbMgr, &webhook, &target), nil return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil
} }
+11 -11
View File
@@ -21,9 +21,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
) )
@@ -223,8 +223,8 @@ func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7) wh := seedWebhookWithRetention(t, env.db, 7)
target := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
oldPath := archive.Path(env.dbMgr, &wh, target) oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive)
month := func(path string) string { month := func(path string) string {
return strings.TrimSuffix(path, ".db") + "-2026-10.db" return strings.TrimSuffix(path, ".db") + "-2026-10.db"
@@ -233,19 +233,19 @@ func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) {
seedArchive(t, oldPath, 1, 16) seedArchive(t, oldPath, 1, 16)
seedArchive(t, month(oldPath), 1, 16) seedArchive(t, month(oldPath), 1, 16)
sw, downloaded := startStalledDownload(t, env, wh.ID, target.ID) sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
require.Equal(t, require.Equal(t,
http.StatusSeeOther, renameTarget(env, wh.ID, target.ID).Code, http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code,
) )
// The test's archives record a rename without moving any file, so // The test's archives record a rename without moving any file, so
// the files are moved here, as the archive writers move them. // the files are moved here, as the delivery engine moves them.
var renamed database.Target var renamed database.Target
require.NoError(t, env.db.DB().First(&renamed, "id = ?", target.ID).Error) require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error)
newPath := archive.Path(env.dbMgr, &wh, &renamed) newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed)
require.NoError(t, os.Rename(oldPath, newPath)) require.NoError(t, os.Rename(oldPath, newPath))
require.NoError(t, os.Rename(month(oldPath), month(newPath))) require.NoError(t, os.Rename(month(oldPath), month(newPath)))
@@ -355,16 +355,16 @@ func TestHandleTargetDownload_OutlastsTheRequestLimit(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7) wh := seedWebhookWithRetention(t, env.db, 7)
target := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
seedArchive( seedArchive(
t, archive.Path(env.dbMgr, &wh, target), rows, bodySize, t, delivery.ArchivePath(env.dbMgr, &wh, archive), rows, bodySize,
) )
srv, accessLog := limitedServer(t, env, limit) srv, accessLog := limitedServer(t, env, limit)
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, t.Context(), http.MethodGet,
srv.URL+downloadPath(wh.ID, target.ID), nil, srv.URL+downloadPath(wh.ID, archive.ID), nil,
) )
require.NoError(t, err) require.NoError(t, err)
+56 -32
View File
@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -25,15 +24,22 @@ const (
tmplKeyMaxTimeout = "MaxTimeout" tmplKeyMaxTimeout = "MaxTimeout"
) )
// configUnreadableMessage is shown when a target's stored
// configuration does not parse. It says plainly that saving replaces
// the stored value rather than preserving it, because the form
// cannot pre-fill what it could not read.
const configUnreadableMessage = "The stored configuration for this " +
"target could not be read. Enter the values below; saving " +
"replaces the stored configuration."
// targetEditView is the display model for the target edit page: the // targetEditView is the display model for the target edit page: the
// target's row fields as stored. The values the form shows, the // target's row fields as stored. The values the form shows, the
// UNMASKED URL and headers among them, come separately, as a // UNMASKED configuration among them, come separately, as a
// targetFormInput. // targetFormInput.
// //
// It deliberately omits database.Target's settings: the form renders // It deliberately omits database.Target's raw Config blob: the form
// them from the targetFormInput, and giving the template the target's // renders named fields, and giving the template the blob as well
// URL and headers as well would put an unreviewed second path to the // would put an unreviewed second path to the credential on the page.
// credential on the page.
type targetEditView struct { type targetEditView struct {
ID string ID string
Name string Name string
@@ -41,16 +47,13 @@ type targetEditView struct {
Active bool Active bool
} }
// HandleTargetEdit shows the form to edit a target, filled with the // HandleTargetEdit shows the form to edit a target.
// target's settings as stored.
// //
// This page is the one place the full destination URL and header // This page is the one place the full destination URL and header
// values are shown: an operator cannot correct a value they cannot // values are shown. It is reachable only through the
// see. It is reachable only through the /hook/{sourceID} route // /hook/{sourceID} route group, which supplies RequireAuth and
// group, which supplies RequireAuth and NoCache, and only for a // NoCache, and only for a target of a webhook the session's user
// target of a webhook the session's user owns; masking // owns; masking (delivery.TargetView) is unchanged everywhere else.
// (delivery.TargetView) is unchanged everywhere else. Do not fill a
// form like this one on any other page.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc { func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
webhook, target, ok := h.ownedTarget(w, r) webhook, target, ok := h.ownedTarget(w, r)
@@ -58,19 +61,34 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
return return
} }
cfg, err := delivery.NewTargetConfigForm(target)
msg := ""
if err != nil {
// The error carries the parse failure, never the
// blob, so it is safe to log against the target id.
h.log.Warn(
"stored target config could not be read for editing",
"target_id", target.ID,
"error", err,
)
msg = configUnreadableMessage
}
form := targetFormInput{ form := targetFormInput{
Name: target.Name, Name: target.Name,
URL: target.URL, URL: cfg.URL,
Headers: delivery.FormatTargetHeaders(target.Headers), Headers: cfg.Headers,
Timeout: delivery.FormatTargetTimeout(target.TimeoutSeconds), Timeout: cfg.Timeout,
ForwardQuery: target.ForwardQuery, ForwardQuery: cfg.ForwardQuery,
MaxAttempts: strconv.Itoa(target.MaxAttempts), MaxAttempts: strconv.Itoa(target.MaxAttempts),
Expiry: formatArchiveExpiry(target.ArchiveExpiry), Expiry: cfg.Expiry,
Rotation: target.ArchiveRotation, Rotation: cfg.Rotation,
} }
h.renderTargetEdit( h.renderTargetEdit(
w, r, webhook, target, form, "", http.StatusOK, w, r, webhook, target, form, msg, http.StatusOK,
) )
} }
} }
@@ -78,8 +96,8 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
// HandleTargetEditSubmit handles the target edit form submission. // HandleTargetEditSubmit handles the target edit form submission.
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
h.archives.RenameLock().Lock() h.renameMu.Lock()
defer h.archives.RenameLock().Unlock() defer h.renameMu.Unlock()
webhook, target, ok := h.ownedTarget(w, r) webhook, target, ok := h.ownedTarget(w, r)
if !ok { if !ok {
@@ -106,11 +124,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// does, so an edited destination is SSRF-validated exactly as a new // does, so an edited destination is SSRF-validated exactly as a new
// one is. // one is.
// //
// The target's type is not editable. Each type uses different // The target's type is not editable. Each type stores a different
// settings and its delivery history is recorded against the target // configuration shape and its delivery history is recorded against
// row, so changing the type of an existing target is really the // the target row, so changing the type of an existing target is
// creation of a different one. The stored type decides which fields // really the creation of a different one. The stored type decides
// the form offers and which settings are set. // which fields the form offers and which builder runs.
func (h *Handlers) applyTargetEdit( func (h *Handlers) applyTargetEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -123,7 +141,13 @@ func (h *Handlers) applyTargetEdit(
// as stored, for the page shown again when the save is refused. // as stored, for the page shown again when the save is refused.
edited := *target edited := *target
errMsg := h.setTargetFromForm(r.Context(), &edited, in) errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" { if errMsg != "" {
h.renderTargetEdit( h.renderTargetEdit(
w, r, webhook, target, in, errMsg, http.StatusBadRequest, w, r, webhook, target, in, errMsg, http.StatusBadRequest,
@@ -133,9 +157,9 @@ func (h *Handlers) applyTargetEdit(
} }
// A new name renames the archive file before it is saved (see // A new name renames the archive file before it is saved (see
// archive.Writers.Rename). If either step fails, it goes back to // delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored. // the name that is still stored.
err := h.renameTargetArchive( err = h.renameTargetArchive(
target, webhook.Name, target.Name, edited.Name, target, webhook.Name, target.Name, edited.Name,
) )
if err == nil { if err == nil {
@@ -154,7 +178,7 @@ func (h *Handlers) applyTargetEdit(
) )
} }
if errors.Is(err, archive.ErrNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderTargetEdit( h.renderTargetEdit(
w, r, webhook, target, in, w, r, webhook, target, in,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
+64 -116
View File
@@ -2,18 +2,19 @@ package handlers_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// The destinations the target edit tests configure. Both are literal // The destinations the target edit tests configure. Both are literal
@@ -149,6 +150,27 @@ func storedTarget(
return target return target
} }
// storedHTTPConfig reloads a target and parses its stored HTTP
// configuration.
func storedHTTPConfig(
t *testing.T,
env *sourceTestEnv,
targetID string,
) delivery.HTTPTargetConfig {
t.Helper()
var cfg delivery.HTTPTargetConfig
require.NoError(
t,
json.Unmarshal(
[]byte(storedTarget(t, env, targetID).Config), &cfg,
),
)
return cfg
}
// editForm is the fully populated edit submission for an HTTP // editForm is the fully populated edit submission for an HTTP
// target. // target.
func editForm(targetURL, headers, timeout string) url.Values { func editForm(targetURL, headers, timeout string) url.Values {
@@ -175,9 +197,12 @@ func submitTargetEdit(
) )
} }
// TestHandleTargetCreate_Configuration proves the add target form // TestHandleTargetCreate_Configuration covers the half of the gap
// stores an HTTP target's headers and timeout, and leaves them empty // that is not about editing at all: HTTPTargetConfig has carried
// when the form leaves them empty. // Headers and Timeout, and the delivery path has honoured them, but
// the create form wrote {"url":...} and nothing else, so a
// destination needing an Authorization header could not be
// configured through the UI at all.
func TestHandleTargetCreate_Configuration(t *testing.T) { func TestHandleTargetCreate_Configuration(t *testing.T) {
t.Parallel() t.Parallel()
@@ -188,9 +213,9 @@ func TestHandleTargetCreate_Configuration(t *testing.T) {
assertCreateStoresHeadersAndTimeout(t, env) assertCreateStoresHeadersAndTimeout(t, env)
}) })
t.Run("without them stores only the url", func(t *testing.T) { t.Run("without them keeps a url-only config", func(t *testing.T) {
t.Parallel() t.Parallel()
assertCreateStoresOnlyTheURL(t, env) assertCreateKeepsURLOnlyConfig(t, env)
}) })
} }
@@ -203,31 +228,33 @@ func assertCreateStoresHeadersAndTimeout(
t, env, editAuthHeader+"\nX-Tenant: acme\n", "12", t, env, editAuthHeader+"\nX-Tenant: acme\n", "12",
) )
stored := storedTarget(t, env, target.ID) cfg := storedHTTPConfig(t, env, target.ID)
assert.Equal(t, editOriginalURL, stored.URL) assert.Equal(t, editOriginalURL, cfg.URL)
assert.Equal(t, 12, stored.TimeoutSeconds) assert.Equal(t, 12, cfg.Timeout)
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Authorization": "Bearer " + editBearerSecret, "Authorization": "Bearer " + editBearerSecret,
"X-Tenant": "acme", "X-Tenant": "acme",
}, },
stored.Headers, cfg.Headers,
) )
} }
func assertCreateStoresOnlyTheURL( // Without the new fields the stored shape must be the same
// {"url":...} the create form wrote before they existed, so no
// existing target's configuration is rewritten by this change.
func assertCreateKeepsURLOnlyConfig(
t *testing.T, env *sourceTestEnv, t *testing.T, env *sourceTestEnv,
) { ) {
t.Helper() t.Helper()
_, target := seedHTTPTarget(t, env, "", "") _, target := seedHTTPTarget(t, env, "", "")
assert.Equal(t, editOriginalURL, target.URL) assert.JSONEq(
assert.Empty(t, target.Headers) t, `{"url":"`+editOriginalURL+`"}`, target.Config,
assert.Zero(t, target.TimeoutSeconds) )
assert.False(t, target.ForwardQuery)
} }
// TestHandleTargetEditSubmit_Saves is the round trip the issue asks // TestHandleTargetEditSubmit_Saves is the round trip the issue asks
@@ -272,8 +299,13 @@ func assertEditChangesDestination(
) )
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t,
editReplacedURL,
storedHTTPConfig(t, env, target.ID).URL,
)
reloaded := storedTarget(t, env, target.ID) reloaded := storedTarget(t, env, target.ID)
assert.Equal(t, editReplacedURL, reloaded.URL)
assert.Equal(t, "edited-name", reloaded.Name) assert.Equal(t, "edited-name", reloaded.Name)
assert.Equal(t, 5, reloaded.MaxAttempts) assert.Equal(t, 5, reloaded.MaxAttempts)
assert.Equal( assert.Equal(
@@ -301,16 +333,16 @@ func assertEditRoundTripsHeadersAndTimeout(
) )
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
stored := storedTarget(t, env, target.ID) cfg := storedHTTPConfig(t, env, target.ID)
assert.Equal(t, 21, stored.TimeoutSeconds) assert.Equal(t, 21, cfg.Timeout)
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Authorization": "Bearer rotated", "Authorization": "Bearer rotated",
"X-Trace": "on", "X-Trace": "on",
}, },
stored.Headers, cfg.Headers,
) )
} }
@@ -330,10 +362,10 @@ func assertEditClearingRemovesThem(
) )
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
stored := storedTarget(t, env, target.ID) cfg := storedHTTPConfig(t, env, target.ID)
assert.Empty(t, stored.Headers) assert.Empty(t, cfg.Headers)
assert.Zero(t, stored.TimeoutSeconds) assert.Zero(t, cfg.Timeout)
} }
// Delivery attempts are offered only by the forms for target types // Delivery attempts are offered only by the forms for target types
@@ -410,90 +442,6 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
assert.Contains(t, page, `class="label">Archive rotation</label>`) assert.Contains(t, page, `class="label">Archive rotation</label>`)
} }
// TestHandleTargetEdit_EveryType edits a target of each type: the edit
// form starts on the settings the add target form stored, and saving
// it stores the settings submitted.
func TestHandleTargetEdit_EveryType(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// created and edited are the add target form's and the edit
// form's fields, as query strings; shown is what the edit form
// must show of created; want is the settings edited stores.
cases := []struct {
targetType database.TargetType
created, edited string
shown []string
want database.Target
}{
{
database.TargetTypeHTTP,
"url=" + editOriginalURL +
"&headers=X-Tenant:+acme&timeout=7&forward_query=on",
"url=" + editReplacedURL + "&timeout=9",
[]string{
`value="` + editOriginalURL + `"`,
"X-Tenant: acme",
`value="7"`,
`name="forward_query" value="on" checked`,
},
database.Target{
URL: editReplacedURL,
Headers: map[string]string{},
TimeoutSeconds: 9,
},
},
{
database.TargetTypeSlack,
"url=" + editOriginalURL,
"url=" + editReplacedURL,
[]string{`value="` + editOriginalURL + `"`},
database.Target{URL: editReplacedURL},
},
{
database.TargetTypeDatabase,
"expiry=720h&rotation=daily",
"expiry=24h&rotation=hourly",
[]string{
`<option value="720h" selected>`,
`<option value="daily" selected>`,
},
database.Target{
ArchiveExpiry: 24 * time.Hour,
ArchiveRotation: rotationHourly,
},
},
{database.TargetTypeLog, "", "", nil, database.Target{}},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook, target := addTargetThroughForm(
t, env, tc.targetType, tc.created,
)
page := targetEditPage(t, env, webhook.ID, target.ID)
for _, shown := range tc.shown {
assert.Contains(t, page, shown)
}
form, err := url.ParseQuery(tc.edited)
require.NoError(t, err)
form.Set("name", "edited")
w := submitTargetEdit(env, webhook.ID, target.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
got := storedTarget(t, env, target.ID)
assert.Equal(t, "edited", got.Name)
assert.Equal(t, tc.want, targetSettingsOf(got))
})
}
}
// TestHandleTarget_ForwardQuery covers the HTTP target's setting that // TestHandleTarget_ForwardQuery covers the HTTP target's setting that
// passes each event's query string on to it: the add target form // passes each event's query string on to it: the add target form
// stores it checked, the edit form starts with it checked and turns it // stores it checked, the edit form starts with it checked and turns it
@@ -519,7 +467,7 @@ func TestHandleTarget_ForwardQuery(t *testing.T) {
targets := targetsForWebhook(t, env.db, webhook.ID) targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1) require.Len(t, targets, 1)
assert.True(t, storedTarget(t, env, targets[0].ID).ForwardQuery) assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
w = serveTarget( w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil, env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
@@ -531,7 +479,7 @@ func TestHandleTarget_ForwardQuery(t *testing.T) {
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedTarget(t, env, targets[0].ID).ForwardQuery) assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL) edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on") edit.Set("forward_query", "on")
@@ -620,7 +568,7 @@ func assertEditRejectsBlockedDestination(
assert.Contains(t, w.Body.String(), "Invalid target URL") assert.Contains(t, w.Body.String(), "Invalid target URL")
assert.Equal( assert.Equal(
t, editOriginalURL, t, editOriginalURL,
storedTarget(t, env, target.ID).URL, storedHTTPConfig(t, env, target.ID).URL,
"a rejected edit must leave the stored config alone", "a rejected edit must leave the stored config alone",
) )
} }
@@ -649,9 +597,9 @@ func assertEditIgnoresQueryString(
assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, http.StatusBadRequest, w.Code)
stored := storedTarget(t, env, target.ID) cfg := storedHTTPConfig(t, env, target.ID)
assert.Equal(t, editOriginalURL, stored.URL) assert.Equal(t, editOriginalURL, cfg.URL)
assert.Empty(t, stored.Headers) assert.Empty(t, cfg.Headers)
} }
func assertEditRejectsHeaders( func assertEditRejectsHeaders(
@@ -669,7 +617,7 @@ func assertEditRejectsHeaders(
assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid headers") assert.Contains(t, w.Body.String(), "Invalid headers")
assert.Empty( assert.Empty(
t, storedTarget(t, env, target.ID).Headers, t, storedHTTPConfig(t, env, target.ID).Headers,
"a rejected header must not be stored", "a rejected header must not be stored",
) )
} }
@@ -689,7 +637,7 @@ func assertEditRejectsTimeout(
assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid timeout") assert.Contains(t, w.Body.String(), "Invalid timeout")
assert.Equal( assert.Equal(
t, 9, storedTarget(t, env, target.ID).TimeoutSeconds, t, 9, storedHTTPConfig(t, env, target.ID).Timeout,
"a rejected timeout must leave the stored one alone", "a rejected timeout must leave the stored one alone",
) )
} }
@@ -809,7 +757,7 @@ func assertTargetOfAnotherWebhook404s(
assert.Equal( assert.Equal(
t, editOriginalURL, t, editOriginalURL,
storedTarget(t, env, target.ID).URL, storedHTTPConfig(t, env, target.ID).URL,
) )
} }
@@ -830,7 +778,7 @@ func assertWebhookOfAnotherUser404s(
target := seedConfiguredTarget( target := seedConfiguredTarget(
t, env.db, other.ID, database.TargetTypeHTTP, t, env.db, other.ID, database.TargetTypeHTTP,
database.Target{URL: editOriginalURL}, `{"url":"`+editOriginalURL+`"}`,
) )
w := serveTarget( w := serveTarget(
+6 -4
View File
@@ -10,7 +10,6 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -243,7 +242,10 @@ func readTargetDeliveries(
func (h *Handlers) archiveFileView( func (h *Handlers) archiveFileView(
webhook *database.Webhook, target *database.Target, now time.Time, webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView { ) *ArchiveFileView {
current := archive.PathAt(h.dbMgr, webhook, target, now) current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
if err != nil {
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
}
view := &ArchiveFileView{Name: filepath.Base(current)} view := &ArchiveFileView{Name: filepath.Base(current)}
@@ -252,8 +254,8 @@ func (h *Handlers) archiveFileView(
view.Note = "not created yet" view.Note = "not created yet"
} }
files, err := archive.Stat( files, err := delivery.StatArchive(
archive.Path(h.dbMgr, webhook, target), delivery.ArchivePath(h.dbMgr, webhook, target),
) )
switch { switch {
+3 -3
View File
@@ -10,8 +10,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
@@ -38,10 +38,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
target := seedTarget(t, db, wh.ID, database.TargetTypeDatabase) archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
seedTarget(t, db, wh.ID, database.TargetTypeLog) seedTarget(t, db, wh.ID, database.TargetTypeLog)
path := archive.Path(dbMgr, wh, target) path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive file:")) assert.Equal(t, 1, strings.Count(body, "Archive file:"))
+3 -1
View File
@@ -60,5 +60,7 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
assert.False(t, stored.Active) assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name) assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxAttempts) assert.Equal(t, 5, stored.MaxAttempts)
assert.Equal(t, editReplacedURL, stored.URL) assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
)
} }
+259 -20
View File
@@ -2,11 +2,14 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
@@ -67,9 +70,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
} }
} }
// processWebhookRequest reads the request into an event and has the // processWebhookRequest reads the body, serializes headers, loads
// delivery engine store it, with a delivery for each of the webhook's // targets, and delivers the event.
// active targets, and answers the sender.
// //
// Nothing about the request itself is authenticated: the entrypoint // Nothing about the request itself is authenticated: the entrypoint
// UUID in the path is the credential, and reaching here means it // UUID in the path is the credential, and reaching here means it
@@ -91,24 +93,30 @@ func (h *Handlers) processWebhookRequest(
return return
} }
event := &database.Event{ targets, err := h.loadActiveTargets(entrypoint.WebhookID)
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
RawQuery: r.URL.RawQuery,
Headers: string(headersJSON),
ContentType: r.Header.Get("Content-Type"),
Body: string(body),
}
deliveries, err := h.engine.StoreEvent(event)
if err != nil { if err != nil {
h.receiverError(w, "failed to store webhook event", err) h.receiverError(w, "failed to query targets", err)
return return
} }
h.finishWebhookResponse(w, event, entrypoint, deliveries) h.createAndDeliverEvent(
w, r, entrypoint, body, headersJSON, targets,
)
}
// loadActiveTargets returns all active targets for a webhook.
func (h *Handlers) loadActiveTargets(
webhookID string,
) ([]database.Target, error) {
var targets []database.Target
err := h.db.DB().Where(
"webhook_id = ? AND active = ?",
webhookID, true,
).Find(&targets).Error
return targets, err
} }
// lookupEntrypoint finds an entrypoint by UUID path. // lookupEntrypoint finds an entrypoint by UUID path.
@@ -169,6 +177,28 @@ func (h *Handlers) readWebhookBody(
return body, true return body, true
} }
// createAndDeliverEvent stores the received event, fans it out to the
// webhook's targets, and answers the sender.
func (h *Handlers) createAndDeliverEvent(
w http.ResponseWriter,
r *http.Request,
entrypoint database.Entrypoint,
body, headersJSON []byte,
targets []database.Target,
) {
event, tasks, err := h.createAndFanOut(
requestEventSource(r, entrypoint, headersJSON, body),
targets,
)
if err != nil {
h.receiverError(w, "failed to store webhook event", err)
return
}
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// receiverError logs an error and answers the sender with a plain-text // 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 // 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses. // error page the web UI uses.
@@ -182,19 +212,172 @@ func (h *Handlers) receiverError(
) )
} }
// finishWebhookResponse logs the stored event, with how many // eventSource carries the fields a new event is built from. The
// deliveries it has, and writes the HTTP response. // receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an
// event is constructed and fanned out in one place however it entered
// the system.
type eventSource struct {
WebhookID string
EntrypointID string
Method string
RawQuery string
HeadersJSON string
ContentType string
Body []byte
// ResubmittedFromID names the event this one copies. Only the
// resubmit path sets it.
ResubmittedFromID *string
}
// event builds the row this source stores.
func (s eventSource) event() *database.Event {
return &database.Event{
WebhookID: s.WebhookID,
EntrypointID: s.EntrypointID,
Method: s.Method,
RawQuery: s.RawQuery,
Headers: s.HeadersJSON,
Body: string(s.Body),
BodyBytes: int64(len(s.Body)),
ContentType: s.ContentType,
ResubmittedFromID: s.ResubmittedFromID,
}
}
// requestEventSource describes the event a live receiver request
// stores.
func requestEventSource(
r *http.Request,
entrypoint database.Entrypoint,
headersJSON, body []byte,
) eventSource {
return eventSource{
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
RawQuery: r.URL.RawQuery,
HeadersJSON: string(headersJSON),
ContentType: r.Header.Get("Content-Type"),
Body: body,
}
}
// 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. Every
// event is created here, received or resubmitted, so a resubmitted
// event is retried, SSRF-guarded and circuit-broken exactly as a
// received one is. Per-delivery replay is the one other path that
// creates a delivery: it adds one to an existing event without
// coming through here.
//
// The tasks are returned as well as queued, so a caller can report how
// many targets the event went to.
func (h *Handlers) createAndFanOut(
src eventSource,
targets []database.Target,
) (*database.Event, []delivery.Task, error) {
webhookDB, err := h.dbMgr.GetDB(src.WebhookID)
if err != nil {
return nil, nil, fmt.Errorf(
"getting webhook database: %w", err,
)
}
tx := webhookDB.Begin()
if tx.Error != nil {
return nil, nil, fmt.Errorf(
"beginning transaction: %w", tx.Error,
)
}
event := src.event()
err = tx.Create(event).Error
if err != nil {
tx.Rollback()
return nil, nil, fmt.Errorf("creating event: %w", err)
}
tasks, err := buildDeliveryTasks(
tx, event, targets, inlineBody(src.Body),
)
if err != nil {
tx.Rollback()
return nil, nil, err
}
err = database.AddEventTotals(tx, database.EventTotals{
Events: 1, LastEventAt: &event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
// A resubmitted copy did not arrive on its entrypoint's URL, so it
// leaves the entrypoint's last event as it is.
if src.ResubmittedFromID == nil {
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
}
err = tx.Commit().Error
if err != nil {
return nil, nil, fmt.Errorf(
"committing transaction: %w", err,
)
}
// Counted here, after the commit: an event exists once it is
// durably stored, which is what the delivery counters are
// compared against on a dashboard. A resubmitted event counts
// too, because it produces deliveries that the delivery side
// counts; the resubmit counter is what separates the two.
h.mtr.EventReceived()
if len(tasks) > 0 {
h.notifier.Notify(tasks)
}
return event, tasks, nil
}
// inlineBody returns a pointer to body as a string if it fits
// within the inline size limit, or nil otherwise.
func inlineBody(body []byte) *string {
if len(body) < delivery.MaxInlineBodySize {
s := string(body)
return &s
}
return nil
}
// finishWebhookResponse logs the event and writes the HTTP response.
func (h *Handlers) finishWebhookResponse( func (h *Handlers) finishWebhookResponse(
w http.ResponseWriter, w http.ResponseWriter,
event *database.Event, event *database.Event,
entrypoint database.Entrypoint, entrypoint database.Entrypoint,
deliveries int, tasks []delivery.Task,
) { ) {
h.log.Info("webhook event created", h.log.Info("webhook event created",
"event_id", event.ID, "event_id", event.ID,
"webhook_id", entrypoint.WebhookID, "webhook_id", entrypoint.WebhookID,
"entrypoint_id", entrypoint.ID, "entrypoint_id", entrypoint.ID,
"target_count", deliveries, "target_count", len(tasks),
) )
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -206,3 +389,59 @@ func (h *Handlers) finishWebhookResponse(
) )
} }
} }
// buildDeliveryTasks creates one pending delivery per target in the
// 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,
targets []database.Target,
bodyPtr *string,
) ([]delivery.Task, error) {
tasks := make([]delivery.Task, 0, len(targets))
for i := range targets {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targets[i].ID,
Status: database.DeliveryStatusPending,
}
err := tx.Create(dlv).Error
if err != nil {
return nil, fmt.Errorf(
"creating delivery for target %s: %w",
targets[i].ID, err,
)
}
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,
WebhookID: event.WebhookID,
EntrypointID: event.EntrypointID,
TargetID: targets[i].ID,
TargetName: targets[i].Name,
TargetType: targets[i].Type,
TargetConfig: targets[i].Config,
MaxAttempts: targets[i].MaxAttempts,
Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
AttemptNum: 1,
})
}
return tasks, nil
}
+12 -6
View File
@@ -115,7 +115,13 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return return
} }
targets, errMsg := h.newWebhookTargets(r.Context(), in) targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" { if errMsg != "" {
refuse(errMsg) refuse(errMsg)
@@ -141,7 +147,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
func (h *Handlers) newWebhookTargets( func (h *Handlers) newWebhookTargets(
ctx context.Context, ctx context.Context,
in sourceFormInput, in sourceFormInput,
) ([]*database.Target, string) { ) ([]*database.Target, string, error) {
var requested []targetFormInput var requested []targetFormInput
if in.HTTPURL != "" { if in.HTTPURL != "" {
@@ -164,15 +170,15 @@ func (h *Handlers) newWebhookTargets(
targets := make([]*database.Target, 0, len(requested)) targets := make([]*database.Target, 0, len(requested))
for _, form := range requested { for _, form := range requested {
target, errMsg := h.newTarget(ctx, "", form) target, errMsg, err := h.newTarget(ctx, "", form)
if errMsg != "" { if err != nil || errMsg != "" {
return nil, errMsg return nil, errMsg, err
} }
targets = append(targets, target) targets = append(targets, target)
} }
return targets, "" return targets, "", nil
} }
// createWebhookWithEntrypoint creates a webhook, its default // createWebhookWithEntrypoint creates a webhook, its default
+5 -5
View File
@@ -77,8 +77,8 @@ func (h *Handlers) deleteWebhookResources(
"user_id", userID, "user_id", userID,
) )
// Release the webhook's archive writers so a deleted // Release the delivery engine's per-webhook archiving state
// webhook's archive writer (and any handle open // so a deleted webhook's archive writer (and any handle open
// within its debounce window) does not linger for the // within its debounce window) does not linger for the
// process lifetime. The archive file itself is deliberately // process lifetime. The archive file itself is deliberately
// left on disk; see evictArchiveWriter. // left on disk; see evictArchiveWriter.
@@ -150,9 +150,9 @@ func (h *Handlers) commitWebhookDeletion(
return tx.Commit().Error return tx.Commit().Error
} }
// evictArchiveWriter asks the archive writers to drop the cached // evictArchiveWriter asks the delivery engine to drop the cached
// writers of a webhook's database targets, closing their archive // archive writers of a webhook's database targets, closing their
// file handles. // archive file handles.
// //
// The archive database files are NOT deleted. Unlike the event // The archive database files are NOT deleted. Unlike the event
// database — which is per-webhook working storage and is // database — which is per-webhook working storage and is
+1 -1
View File
@@ -109,7 +109,7 @@ func (h *Handlers) renderSourceDetail(
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: &webhook, tmplKeyWebhook: &webhook,
// Targets are projected to a display-safe view: a // Targets are projected to a display-safe view: a
// target's URL and headers hold a credential, and they // target's stored config blob holds a credential, and it
// must never reach a template. // must never reach a template.
"Entrypoints": entrypointViews, "Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets), "Targets": h.targetRows(&webhook, targets),
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// HandleSourceEdit shows the form to edit a webhook. // HandleSourceEdit shows the form to edit a webhook.
@@ -59,8 +59,8 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
sourceID := chi.URLParam(r, "sourceID") sourceID := chi.URLParam(r, "sourceID")
h.archives.RenameLock().Lock() h.renameMu.Lock()
defer h.archives.RenameLock().Unlock() defer h.renameMu.Unlock()
var webhook database.Webhook var webhook database.Webhook
@@ -130,7 +130,7 @@ func (h *Handlers) applyWebhookEdit(
edited.RetentionDays = retentionDays edited.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see // A new name renames the archive files before it is saved (see
// archive.Writers.Rename). If either step fails, the same targets' // delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without // archives go back to the name that is still stored, without
// reading the main database again. // reading the main database again.
targets, err := h.renameWebhookArchives( targets, err := h.renameWebhookArchives(
@@ -150,7 +150,7 @@ func (h *Handlers) applyWebhookEdit(
) )
} }
if errors.Is(err, archive.ErrNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderWebhookEdit( h.renderWebhookEdit(
w, r, webhook, name, description, retention, w, r, webhook, name, description, retention,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
+1 -1
View File
@@ -153,7 +153,7 @@ func seedStatsHistory(
first := seedConfiguredTarget( first := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP, t, db, wh.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL}, `{"url":"`+replayTargetURL+`"}`,
) )
second := seedTarget(t, db, wh.ID, database.TargetTypeLog) second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
-84
View File
@@ -206,87 +206,3 @@ func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
return found[0] return found[0]
} }
// TestHandleWebhook_StoresTheEvent proves an accepted request is
// answered with the receiver's 200 and body, and that the event is
// stored with a pending delivery to the webhook's active target.
func TestHandleWebhook_StoresTheEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
webhook := seedWebhook(t, db)
entrypoint := seedEntrypoint(t, db, webhook.ID)
target := seedConfiguredTarget(
t, db, webhook.ID, database.TargetTypeHTTP,
database.Target{URL: replayTargetURL},
)
w := httptest.NewRecorder()
h.HandleWebhook().ServeHTTP(w, postRequest(
"/h/"+entrypoint.Path, nil,
map[string]string{"uuid": entrypoint.Path},
))
require.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, `{"status":"ok"}`, w.Body.String())
webhookDB, err := dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, 1)
assert.Equal(t, entrypoint.ID, events[0].EntrypointID)
deliveries := listDeliveries(t, webhookDB, events[0].ID)
require.Len(t, deliveries, 1)
assert.Equal(t, target.ID, deliveries[0].TargetID)
assert.Equal(t, database.DeliveryStatusPending, deliveries[0].Status)
}
// TestHandleWebhook_FailedStoreAnswersPlainError proves that an event
// the engine cannot store is answered with the receiver's plain-text
// 500, and that nothing of it is left stored.
func TestHandleWebhook_FailedStoreAnswersPlainError(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
webhook := seedWebhook(t, db)
entrypoint := seedEntrypoint(t, db, webhook.ID)
// Without its event totals table the webhook's database refuses
// the totals write, which comes after the event row.
webhookDB, err := dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
require.NoError(t,
webhookDB.Migrator().DropTable(&database.EventTotals{}))
w := httptest.NewRecorder()
h.HandleWebhook().ServeHTTP(w, postRequest(
"/h/"+entrypoint.Path, nil,
map[string]string{"uuid": entrypoint.Path},
))
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal server error\n", w.Body.String())
assert.Empty(t, listEvents(t, webhookDB))
}
+6 -15
View File
@@ -10,7 +10,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -18,7 +17,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -131,13 +129,11 @@ func run(
return code, stdout.String(), stderr.String() return code, stdout.String(), stderr.String()
} }
type noopArchives struct { type noopNotifier struct{}
renameMu sync.Mutex
}
func (n *noopArchives) RenameLock() *sync.Mutex { func (n *noopNotifier) Notify([]delivery.Task) {}
return &n.renameMu
} type noopArchives struct{}
func (n *noopArchives) EvictWebhook(string) {} func (n *noopArchives) EvictWebhook(string) {}
@@ -186,13 +182,8 @@ func newServerApp(
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
session.New, session.New,
archive.NewWriters, func() delivery.Notifier { return &noopNotifier{} },
// The engine is built on a lifecycle that is never started, func() delivery.Archives { return &noopArchives{} },
// so no worker runs.
func(p delivery.EngineParams) *delivery.Engine {
return delivery.New(fxtest.NewLifecycle(t), p)
},
func() archive.Archives { return &noopArchives{} },
func() delivery.CircuitBreakers { func() delivery.CircuitBreakers {
return &noopCircuitBreakers{} return &noopCircuitBreakers{}
}, },
+19 -39
View File
@@ -1183,26 +1183,16 @@ func checkNewWebhookTargets(
) { ) {
t.Helper() t.Helper()
// Each value the pruning choice submits and the expiry it stores, // Each value the pruning choice submits, after an empty one that
// after an empty one that stands for archive left off. // stands for archive left off.
expiries := []struct { expiries := []string{
value string "", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
stored time.Duration
}{
{"", 0},
{"never", 0},
{"1h", time.Hour},
{"12h", 12 * time.Hour},
{"24h", 24 * time.Hour},
{"720h", 720 * time.Hour},
{"2160h", 2160 * time.Hour},
{"8760h", 8760 * time.Hour},
} }
for _, httpURL := range []string{"", publicTargetURL} { for _, httpURL := range []string{"", publicTargetURL} {
for _, expiry := range expiries { for _, expiry := range expiries {
name := "url=" + httpURL + " archive=" + expiry.value name := "url=" + httpURL + " archive=" + expiry
want := map[database.TargetType]database.Target{} want := map[database.TargetType]string{}
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
@@ -1215,12 +1205,10 @@ func checkNewWebhookTargets(
"#http_url", httpURL, chromedp.ByQuery, "#http_url", httpURL, chromedp.ByQuery,
))) )))
want[database.TargetTypeHTTP] = database.Target{ want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
URL: httpURL, Headers: map[string]string{},
}
} }
if expiry.value != "" { if expiry != "" {
// The choice showing moves Create down, so it is // The choice showing moves Create down, so it is
// waited for before Create is clicked. // waited for before Create is clicked.
click(ctx, t, archiveBox) click(ctx, t, archiveBox)
@@ -1228,30 +1216,29 @@ func checkNewWebhookTargets(
"%s: checking archive does not show the pruning choice", "%s: checking archive does not show the pruning choice",
name) name)
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue( require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
pruningChoice, expiry.value, chromedp.BySearch, pruningChoice, expiry, chromedp.BySearch,
))) )))
// The rotation choice is left on none. // The rotation choice is left on none.
want[database.TargetTypeDatabase] = database.Target{ want[database.TargetTypeDatabase] = `{"expiry":"` + expiry +
ArchiveExpiry: expiry.stored, ArchiveRotation: "none", `","rotation":"none"}`
}
} }
clickAndLoad(ctx, t, createButton) clickAndLoad(ctx, t, createButton)
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`), require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
"%s: the new webhook's page does not open", name) "%s: the new webhook's page does not open", name)
assert.Equalf(t, want, targetSettings(t, env, name), assert.Equalf(t, want, targetConfigs(t, env, name),
"%s: the webhook does not have the targets asked for", name) "%s: the webhook does not have the targets asked for", name)
} }
} }
} }
// targetSettings reads the targets of the webhook named name, and // targetConfigs reads the targets of the webhook named name, and
// returns each one's stored settings by its type. // returns each one's stored configuration by its type.
func targetSettings( func targetConfigs(
t *testing.T, env *testEnv, name string, t *testing.T, env *testEnv, name string,
) map[database.TargetType]database.Target { ) map[database.TargetType]string {
t.Helper() t.Helper()
var ( var (
@@ -1264,19 +1251,12 @@ func targetSettings(
require.NoError(t, env.db.DB(). require.NoError(t, env.db.DB().
Where("webhook_id = ?", webhook.ID).Find(&targets).Error) Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
settings := map[database.TargetType]database.Target{} configs := map[database.TargetType]string{}
for _, target := range targets { for _, target := range targets {
settings[target.Type] = database.Target{ configs[target.Type] = target.Config
URL: target.URL,
Headers: target.Headers,
TimeoutSeconds: target.TimeoutSeconds,
ForwardQuery: target.ForwardQuery,
ArchiveExpiry: target.ArchiveExpiry,
ArchiveRotation: target.ArchiveRotation,
}
} }
return settings return configs
} }
// checkRefusedNewWebhook submits the new webhook page with archive // checkRefusedNewWebhook submits the new webhook page with archive
+1 -1
View File
@@ -311,7 +311,7 @@ func (s *Server) setupSourceRoutes() {
r.Post("/targets", s.h.HandleTargetCreate()) r.Post("/targets", s.h.HandleTargetCreate())
// The edit form is the one page that renders a target's // The edit form is the one page that renders a target's
// destination URL and header values in full; see // destination URL and header values in full; see
// Handlers.HandleTargetEdit. It belongs to this group for // delivery.TargetConfigForm. It belongs to this group for
// its RequireAuth and NoCache, which are what keep that // its RequireAuth and NoCache, which are what keep that
// exception from reaching an unauthenticated request or a // exception from reaching an unauthenticated request or a
// shared cache. // shared cache.
+10 -19
View File
@@ -11,7 +11,6 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -21,7 +20,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
@@ -48,16 +46,14 @@ const (
metricsAuthValue = "s3cret" metricsAuthValue = "s3cret"
) )
// noopArchives satisfies handlers.New's archive.Archives type noopNotifier struct{}
// dependency. No test here checks what gets evicted or renamed, so
// it records nothing. Its rename lock is a plain mutex.
type noopArchives struct {
renameMu sync.Mutex
}
func (e *noopArchives) RenameLock() *sync.Mutex { func (n *noopNotifier) Notify([]delivery.Task) {}
return &e.renameMu
} // noopArchives satisfies handlers.New's delivery.Archives
// dependency. No test here checks what gets evicted or renamed, so
// it records nothing.
type noopArchives struct{}
func (e *noopArchives) EvictWebhook(string) {} func (e *noopArchives) EvictWebhook(string) {}
@@ -141,13 +137,8 @@ func newTestEnvWithConfig(
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
session.New, session.New,
archive.NewWriters, func() delivery.Notifier { return &noopNotifier{} },
// The engine is built on a lifecycle that is never started, func() delivery.Archives { return &noopArchives{} },
// so no worker runs and nothing is sent anywhere.
func(p delivery.EngineParams) *delivery.Engine {
return delivery.New(fxtest.NewLifecycle(t), p)
},
func() archive.Archives { return &noopArchives{} },
func() delivery.CircuitBreakers { func() delivery.CircuitBreakers {
return &noopCircuitBreakers{} return &noopCircuitBreakers{}
}, },
@@ -415,7 +406,7 @@ func (e *testEnv) seedTarget(
Name: "routed-target", Name: "routed-target",
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Active: true, Active: true,
URL: "http://93.184.216.34/hook", Config: `{"url":"http://93.184.216.34/hook"}`,
MaxAttempts: 1, MaxAttempts: 1,
} }