Compare commits
1
Commits
next
..
6257c6ec23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6257c6ec23 |
@@ -33,5 +33,5 @@ jobs:
|
|||||||
# report success from cache.
|
# report success from cache.
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
- name: Build Docker image (runs make check)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+1
-2
@@ -25,9 +25,8 @@ COPY . .
|
|||||||
# would need a docker daemon inside the build. Keep these steps in step with
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN script/assets
|
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Browser test image, built by script/test-browser (make test-browser). It
|
|
||||||
# runs the test in internal/server that loads the pages in a headless
|
|
||||||
# browser under the real Content-Security-Policy. That test is built only
|
|
||||||
# with the browser build tag, so make test leaves it out. Here the browser
|
|
||||||
# comes from a digest-pinned image, and if it is missing the test fails.
|
|
||||||
|
|
||||||
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The test binary embeds the templates and static files, so the browser
|
|
||||||
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
|
||||||
# script/test.
|
|
||||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
|
||||||
|
|
||||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
|
||||||
# browser is on PATH as headless-shell, where the test's browser library
|
|
||||||
# looks for it.
|
|
||||||
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
|
||||||
|
|
||||||
COPY --from=build /browser.test /browser.test
|
|
||||||
|
|
||||||
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
|
||||||
+1
-7
@@ -31,13 +31,7 @@ FROM deps AS lint
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
|
|
||||||
# it the static package does not compile and cannot be linted.
|
|
||||||
RUN script/assets
|
|
||||||
|
|
||||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
# disable a setting without a word. `config verify` is what catches that.
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
# --build-tags browser also lints the browser test, which is built only with
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
# that tag (make test-browser).
|
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -33,9 +33,6 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-browser:
|
|
||||||
@script/test-browser
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for `make lint` and so for `make check`, for the browser test in
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
`make test-browser`, for the CI gate, and for containerized deployment)
|
containerized deployment)
|
||||||
|
|
||||||
golangci-lint is not a prerequisite and must not be installed on the
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
@@ -58,7 +58,6 @@ make fmt # Format code (gofmt + goimports)
|
|||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
@@ -136,20 +135,16 @@ TTY detection, and security headers are always applied.
|
|||||||
| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
|
| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
|
||||||
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||||
| `DEBUG` | Enable debug logging | `false` |
|
| `DEBUG` | Enable debug logging | `false` |
|
||||||
|
| `MAINTENANCE_MODE` | Report `maintenanceMode: true` in the healthcheck JSON. It does not change how any request is served — no maintenance page exists | `false` |
|
||||||
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
|
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
|
||||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
|
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
|
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
|
||||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
|
||||||
every one of these with the value the running server loaded. It is
|
|
||||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
|
||||||
set or not set, never their values.
|
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -163,20 +158,19 @@ WireServer, which serves an Azure VM its credentials. Because it is a
|
|||||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||||
|
|
||||||
That is all the default blocklist covers: the IPv4 private and reserved
|
That is all the default blocklist covers: the IPv4 private and reserved
|
||||||
ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`),
|
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
||||||
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
|
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
||||||
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and
|
addresses. A public address belongs on the default blocklist only if it
|
||||||
certain public addresses. A public address belongs on the default
|
hands credentials, user data or bootstrap material to whatever can reach
|
||||||
blocklist only if it hands credentials, user data or bootstrap material
|
it, without the caller presenting anything. A provider's other public
|
||||||
to whatever can reach it, without the caller presenting anything. A
|
addresses are not refused. IBM Cloud, for example, serves its package
|
||||||
provider's other public addresses are not refused. IBM Cloud, for
|
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
||||||
example, serves its package mirrors, time servers and object storage on
|
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
||||||
`161.26.0.0/16`, and the private endpoints of its own cloud services on
|
range hands out credentials that way: the token service among those
|
||||||
`166.8.0.0/14`. Neither range hands out credentials that way: the token
|
endpoints issues a token only in exchange for something the caller
|
||||||
service among those endpoints issues a token only in exchange for
|
presents, such as an API key. Reaching these services can be a
|
||||||
something the caller presents, such as an API key. Reaching these
|
legitimate delivery, and every cloud has some, so a partial list would
|
||||||
services can be a legitimate delivery, and every cloud has some, so a
|
promise coverage it does not give.
|
||||||
partial list would promise coverage it does not give.
|
|
||||||
|
|
||||||
That default is also inconvenient for the thing webhooker is mostly
|
That default is also inconvenient for the thing webhooker is mostly
|
||||||
for: taking a public webhook and forwarding it to something on your own
|
for: taking a public webhook and forwarding it to something on your own
|
||||||
@@ -216,16 +210,16 @@ Two things this setting cannot do:
|
|||||||
the list is always an allowlist; an empty list (the default) means
|
the list is always an allowlist; an empty list (the default) means
|
||||||
every private and reserved range stays refused. Note that
|
every private and reserved range stays refused. Note that
|
||||||
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
||||||
- **It cannot open link-local, the unspecified addresses, or a cloud
|
- **It cannot open link-local, or a cloud metadata endpoint at a
|
||||||
metadata endpoint at a non-public address that discloses credentials
|
non-public address that discloses credentials or user data.** An
|
||||||
or user data.** A metadata address is on the list below when it is not
|
address is on the list below when it is not a public address and both
|
||||||
a public address and both of these hold: the provider fixes it, so it
|
of these hold: the provider fixes it, so it cannot collide with
|
||||||
cannot collide with anything you run; and reaching it hands out
|
anything you run; and reaching it hands out credentials, user data or
|
||||||
credentials, user data or bootstrap material. Those stay blocked no
|
bootstrap material. Those stay blocked no matter what you list,
|
||||||
matter what you list, including when you list them outright or list a
|
including when you list them outright or list a supernet such as
|
||||||
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
|
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
|
||||||
Treat this as best effort rather than a guarantee — it is a
|
effort rather than a guarantee — it is a hand-maintained list and the
|
||||||
hand-maintained list and the caveat below the table applies:
|
caveat below the table applies:
|
||||||
|
|
||||||
| Blocked unconditionally | What it is |
|
| Blocked unconditionally | What it is |
|
||||||
| ----------------------- | ---------- |
|
| ----------------------- | ---------- |
|
||||||
@@ -239,25 +233,14 @@ Two things this setting cannot do:
|
|||||||
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
||||||
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
||||||
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
||||||
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
|
|
||||||
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
|
|
||||||
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
||||||
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
||||||
|
|
||||||
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
||||||
`169.254.0.0/16` entry. Reaching any of these but the two unspecified
|
`169.254.0.0/16` entry. Reaching any of these is credential or
|
||||||
addresses is credential or user-data theft rather than delivery to an
|
user-data theft rather than delivery to an internal service. Every
|
||||||
internal service. Every entry outside the two link-local blocks is a
|
entry outside the two link-local blocks is a single address, so
|
||||||
single address, so blocking it costs you nothing else on the network
|
blocking it costs you nothing else on the network around it.
|
||||||
around it.
|
|
||||||
|
|
||||||
The unspecified addresses `0.0.0.0` and `::` hand out nothing
|
|
||||||
themselves, but no host can have either, and on Linux a connection to
|
|
||||||
one reaches this host's own loopback. They are listed so that an
|
|
||||||
allowlist reaches loopback only through an entry that covers a loopback
|
|
||||||
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
|
|
||||||
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
|
|
||||||
open loopback.
|
|
||||||
|
|
||||||
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
||||||
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
||||||
@@ -457,19 +440,6 @@ Your proxy must therefore **append** the peer address to
|
|||||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
||||||
bare address with no port.
|
bare address with no port.
|
||||||
|
|
||||||
Every log line that names a client carries two addresses: `remoteIP`,
|
|
||||||
the connecting peer, which behind a proxy is the proxy; and `clientIP`,
|
|
||||||
the client the rate limiters identify by the rules above, which is the
|
|
||||||
field to read when tracing who sent what. Those lines are the
|
|
||||||
`http request` access log line, the rate-limit rejection lines
|
|
||||||
(`login failure limit exceeded` among them), the
|
|
||||||
`csrf: token validation failed` warning and the receiver's
|
|
||||||
`webhook request received` line. `clientIP` is only as trustworthy as
|
|
||||||
`TRUSTED_PROXIES`: for a request from a peer inside the list, it is
|
|
||||||
read out of the `X-Forwarded-For` that peer sent, so a peer that does
|
|
||||||
not belong in the list can make it name any address it likes. For a
|
|
||||||
request from any other peer, both fields name the peer.
|
|
||||||
|
|
||||||
#### Sessions
|
#### Sessions
|
||||||
|
|
||||||
Sessions are bounded by two independent clocks, and end at whichever
|
Sessions are bounded by two independent clocks, and end at whichever
|
||||||
@@ -528,8 +498,8 @@ no report is being sent — which is why it aborts rather than starting
|
|||||||
with reporting off. Leaving it unset is not a mistake and not affected:
|
with reporting off. Leaving it unset is not a mistake and not affected:
|
||||||
error reporting is simply off and startup is normal.
|
error reporting is simply off and startup is normal.
|
||||||
|
|
||||||
The boolean variable `DEBUG` accepts exactly the spellings Go's
|
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
|
||||||
`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||||
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
|
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
|
||||||
else. `yes`, `on`, and `off` are rejected rather than quietly treated
|
else. `yes`, `on`, and `off` are rejected rather than quietly treated
|
||||||
as false.
|
as false.
|
||||||
@@ -557,8 +527,8 @@ If it is lost, run `webhooker resetpw admin` on a stopped deployment.
|
|||||||
```
|
```
|
||||||
|
|
||||||
It is a banner rather than a log line because that is the only time it
|
It is a banner rather than a log line because that is the only time it
|
||||||
is ever shown: as one `INFO` record it would sit among the records fx
|
is ever shown: as one `INFO` record it sat among the roughly 45 fx
|
||||||
writes as each start hook runs, and under `docker run -d`
|
`PROVIDE`/`RUN`/`HOOK` lines a boot writes, and under `docker run -d`
|
||||||
it is one line in a log subject to rotation. The database stores only
|
it is one line in a log subject to rotation. The database stores only
|
||||||
its Argon2id hash. There is no second account and no forgot-password
|
its Argon2id hash. There is no second account and no forgot-password
|
||||||
flow, so the banner and the reset command below are the only two ways
|
flow, so the banner and the reset command below are the only two ways
|
||||||
@@ -628,8 +598,7 @@ Changing a password you still know needs none of this — use
|
|||||||
|
|
||||||
`DEBUG=true` lowers the log level to `DEBUG`, which turns on every
|
`DEBUG=true` lowers the log level to `DEBUG`, which turns on every
|
||||||
statement GORM runs, the two by-design lookup misses on the
|
statement GORM runs, the two by-design lookup misses on the
|
||||||
unauthenticated routes, the rate limiter's own rejections, and fx's
|
unauthenticated routes, and the rate limiter's own rejections. It is
|
||||||
records of building the dependency graph at startup. It is
|
|
||||||
meant to be safe to turn on while diagnosing a live service and safe to
|
meant to be safe to turn on while diagnosing a live service and safe to
|
||||||
paste the output of into a bug report.
|
paste the output of into a bug report.
|
||||||
|
|
||||||
@@ -855,9 +824,9 @@ reports.
|
|||||||
was given, so on any port other than 443 `$host` makes every form
|
was given, so on any port other than 443 `$host` makes every form
|
||||||
POST — including login — fail with `403 origin invalid`, with
|
POST — including login — fail with `403 origin invalid`, with
|
||||||
nothing in the error naming the cause.
|
nothing in the error naming the cause.
|
||||||
5. **Keep the proxy's access log.** webhooker's own access log names
|
5. **Keep the proxy's access log.** webhooker's own access log records
|
||||||
the client in its `clientIP` field only while `TRUSTED_PROXIES`
|
the peer address, which behind a proxy is always the proxy. The
|
||||||
covers the proxy; the proxy's log names it regardless. nginx's
|
proxy's log is the only record of which client sent what. nginx's
|
||||||
default `combined` format already logs `$remote_addr`; do not
|
default `combined` format already logs `$remote_addr`; do not
|
||||||
replace it with one that drops the client address, and retain those
|
replace it with one that drops the client address, and retain those
|
||||||
logs as long as you would want to answer a question about traffic.
|
logs as long as you would want to answer a question about traffic.
|
||||||
@@ -886,8 +855,9 @@ server {
|
|||||||
# webhooker's message.
|
# webhooker's message.
|
||||||
client_max_body_size 1m;
|
client_max_body_size 1m;
|
||||||
|
|
||||||
# $remote_addr is the client. webhooker's own log names it, as
|
# $remote_addr is the client. webhooker's own log records this
|
||||||
# clientIP, only while TRUSTED_PROXIES covers this proxy.
|
# proxy and nothing else, so this file is the only place the
|
||||||
|
# client's address is written down.
|
||||||
access_log /var/log/nginx/webhooker.access.log combined;
|
access_log /var/log/nginx/webhooker.access.log combined;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -1050,8 +1020,7 @@ Archive databases are the one exception the service is built for: the
|
|||||||
archive writer closes and reopens its handle around writes (debounced
|
archive writer closes and reopens its handle around writes (debounced
|
||||||
to at most one reopen per second), so an operator can move an
|
to at most one reopen per second), so an operator can move an
|
||||||
`archive-….db` away for offline retention while the service runs,
|
`archive-….db` away for offline retention while the service runs,
|
||||||
and it is recreated on the next write. The webhook page names each
|
and it is recreated on the next write. See
|
||||||
`database` target's archive file. See
|
|
||||||
[Database Architecture](#database-architecture). That is a
|
[Database Architecture](#database-architecture). That is a
|
||||||
move-the-file-away workflow, not a substitute for the backup procedures
|
move-the-file-away workflow, not a substitute for the backup procedures
|
||||||
above.
|
above.
|
||||||
@@ -1272,7 +1241,7 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Eleven of the Makefile's eighteen targets are thin
|
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
`version` are inline commands with no script behind them, though `build`,
|
`version` are inline commands with no script behind them, though `build`,
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||||
@@ -1293,8 +1262,6 @@ We provide:
|
|||||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/test-browser` — run the browser test in Docker (see
|
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
|
||||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
@@ -1315,33 +1282,11 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||||
the standard `alpinejs` build needs to run the expressions written in the
|
publishes it. It is a dependency, not this repo's build output, so
|
||||||
markup. The CSP build runs no expressions, so every Alpine directive in
|
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||||
`templates/` only names a property or method of a component registered in
|
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page and the event log
|
|
||||||
under the real policy and checks that: both add forms stay hidden until Add is
|
|
||||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
|
||||||
what it submits, also after leaving the page and going back to it, when the
|
|
||||||
browser restores the choice; the Copy button beside an entrypoint URL reads
|
|
||||||
"Copied" once clicked; an event expands and collapses, and so do a delivery's
|
|
||||||
attempts inside it; and at phone width the menu button opens and closes the
|
|
||||||
mobile menu. It also fails if the browser reports a console warning or error,
|
|
||||||
an uncaught exception, or anything the policy refused. `make check` and the
|
|
||||||
image build lint it but do not run it, and `make test` leaves it out (its file
|
|
||||||
is built only with the `browser` build tag). Run it with `make test-browser`
|
|
||||||
after changing `templates/` or `static/js/`: that builds `Dockerfile.browser`,
|
|
||||||
which runs the test in a digest-pinned headless browser image, so the host
|
|
||||||
needs no browser.
|
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
|
||||||
output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not
|
|
||||||
apply. The directory is `3p/` rather than `vendor/` because Go treats a root
|
|
||||||
`vendor/` directory as its module vendor directory.
|
`vendor/` directory as its module vendor directory.
|
||||||
|
|
||||||
`script/assets` (`make assets`) extracts the browser build,
|
`script/assets` (`make assets`) extracts the browser build,
|
||||||
@@ -1349,16 +1294,13 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
|
|||||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||||
`.dockerignore` keeps any host copy out of the build context. `static/static.go`
|
`.dockerignore` keeps any host copy out of the build context.
|
||||||
names every file it embeds, so a build that skips the extraction, such as a
|
|
||||||
bare `go build`, fails with an error naming `js/alpine.min.js`.
|
|
||||||
|
|
||||||
To move to a new version: download
|
To move to a new version: download
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
|
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||||
the `dist.integrity` hash listed at
|
against the `dist.integrity` hash listed at
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
with it, update its file name in `script/assets`, and run `make check`.
|
||||||
`script/assets`, and run `make check` and `make test-browser`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -1828,7 +1770,7 @@ retries) is individually logged for full observability.
|
|||||||
#### EventTotals and TargetTotals
|
#### EventTotals and TargetTotals
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
Running counts in each event database, read by the statistics pane at the
|
||||||
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
top of the webhook page. `EventTotals` is one row:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ----------- |
|
| ---------------- | --------- | ----------- |
|
||||||
@@ -1860,14 +1802,6 @@ target. Its failure percentage for a window is the deliveries that became
|
|||||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
a dash when none did.
|
a dash when none did.
|
||||||
|
|
||||||
The webhook list at `/hooks` shows three of the pane's figures for each
|
|
||||||
webhook: its events within retention and its last event, both from
|
|
||||||
`EventTotals`, and its deliveries that failed in the last 24 hours,
|
|
||||||
counted with the pane's query. It opens each webhook's event database once
|
|
||||||
(the handle stays open) and runs those two reads there, so its cost grows
|
|
||||||
with the number of webhooks and, for each, with the deliveries that
|
|
||||||
finished in the last 24 hours, never with the events stored.
|
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
@@ -1875,23 +1809,20 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
|
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
rates the `deleted_at` index, which every live row matches, above an index on
|
||||||
a column matched against several values or compared with a range. So every
|
a column matched against several values or compared with a range. So every
|
||||||
index but the last also covers `deleted_at`. It comes second in the `event_id`
|
index but the last also covers `deleted_at`. It comes second, so that
|
||||||
and `delivery_id` indexes, so that retention can use them without it. The event
|
retention can use the index without it, except in `events`, where the
|
||||||
log's count, the one query on the `resubmitted_from_id` index, always carries
|
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||||
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index
|
range only on the last column it uses.
|
||||||
`deleted_at` comes first, because they compare `created_at` with a range (`>=`)
|
|
||||||
and SQLite narrows by a range only on the last column it uses.
|
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
@@ -1988,10 +1919,8 @@ when nothing is left. The target UUID keeps the file name unique. A
|
|||||||
webhook named `Orders (EU)` with a target named `Long-term archive`
|
webhook named `Orders (EU)` with a target named `Long-term archive`
|
||||||
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
||||||
Renaming the webhook or the target renames the file, under the same
|
Renaming the webhook or the target renames the file, under the same
|
||||||
lock the archive writes and the archive sweeper take. Webhook edits,
|
lock the archive writes and the archive sweeper take, so the name on
|
||||||
target edits and target creation run one at a time, so no edit can
|
disk matches the UI. A rename never replaces a file: if one already has
|
||||||
rename the file between another's rename and save, and the name on disk
|
|
||||||
matches the UI. A rename never replaces a file: if one already has
|
|
||||||
the new name, the edit is refused with an error naming that file, and
|
the new name, the edit is refused with an error naming that file, and
|
||||||
the stored name stays. If the archive is not there (the operator moved
|
the stored name stays. If the archive is not there (the operator moved
|
||||||
it away), the rename is not an error, and the next write creates the
|
it away), the rename is not an error, and the next write creates the
|
||||||
@@ -2001,11 +1930,9 @@ The file is moved just before the new name is saved. If the process
|
|||||||
stops between the two, the archive is left under the new name while the
|
stops between the two, the archive is left under the new name while the
|
||||||
UI still shows the old one, and the next delivery starts a second
|
UI still shows the old one, and the next delivery starts a second
|
||||||
archive under the name shown. To bring them back together, stop the
|
archive under the name shown. To bring them back together, stop the
|
||||||
service before moving anything, and move each archive as its `.db`
|
service before moving either file. If no file has the name shown, move
|
||||||
together with any `-wal` and `-shm` beside it, since the `-wal` can hold
|
the file under the new name back to it. If a second archive already has
|
||||||
rows that are not yet in the `.db`. If no file has the name shown, move
|
the name shown, move the file under the new name out of the data
|
||||||
the archive under the new name back to it. If a second archive already
|
|
||||||
has the name shown, move the archive under the new name out of the data
|
|
||||||
directory instead and keep it as you would any archive moved away. Then
|
directory instead and keep it as you would any archive moved away. Then
|
||||||
start the service again.
|
start the service again.
|
||||||
|
|
||||||
@@ -2037,37 +1964,6 @@ Because each `database` target has its own archive file, a target's
|
|||||||
webhook with different expiries keep two archives, each pruned on its
|
webhook with different expiries keep two archives, each pruned on its
|
||||||
own schedule.
|
own schedule.
|
||||||
|
|
||||||
The webhook page shows, for each `database` target, its archive file's
|
|
||||||
name, its size on disk and when it was last written. The size counts
|
|
||||||
the `.db` and its `-wal` together, and the last write is the later of
|
|
||||||
their two modification times, since a write lands in the `-wal` first.
|
|
||||||
Both are read from the files' metadata; the archive is never opened.
|
|
||||||
Before the first write, and after the file has been moved away, the page
|
|
||||||
shows `not created yet` beside the name.
|
|
||||||
|
|
||||||
Each `database` target on the webhook page has a **Download** button,
|
|
||||||
which returns its archive as one gzipped JSON file,
|
|
||||||
`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the
|
|
||||||
names made safe as above and the time in UTC. The file holds one
|
|
||||||
object: `webhook` and `target`, each an `id` and a `name`;
|
|
||||||
`exported_at`; and `archived_events`, one object per archived row with
|
|
||||||
every column, keyed by column name. A body that is not valid UTF-8 is
|
|
||||||
written in base64, with `"body_encoding": "base64"` beside it. An
|
|
||||||
archive that does not exist yet, or was moved away, downloads with an
|
|
||||||
empty `archived_events`; the download never creates the file.
|
|
||||||
|
|
||||||
The download streams: each row is read and written out compressed
|
|
||||||
before the next is read, so neither the archive nor the JSON is held in
|
|
||||||
memory. It reads on a connection of its own, inside one read-only
|
|
||||||
transaction, so the file holds the archive as it stood when the
|
|
||||||
download started, and archive writes go on meanwhile, since under WAL a
|
|
||||||
reader never blocks a writer. While it runs, the `-wal` cannot be
|
|
||||||
checkpointed past what it reads, so a long download lets the `-wal`
|
|
||||||
grow. It finds the file by the stored names under the lock that webhook
|
|
||||||
edits, target edits and target creation hold, and lets go once the file
|
|
||||||
is open: a rename during the download moves the file without affecting
|
|
||||||
it.
|
|
||||||
|
|
||||||
Deleting a webhook releases its archives: the delivery engine's cached
|
Deleting a webhook releases its archives: the delivery engine's cached
|
||||||
archive writers are dropped and their file handles closed, so nothing
|
archive writers are dropped and their file handles closed, so nothing
|
||||||
lingers after the webhook is gone. The archive **files themselves are
|
lingers after the webhook is gone. The archive **files themselves are
|
||||||
@@ -2524,21 +2420,20 @@ trade.
|
|||||||
Net: **one `INFO` line per request, of at most 2,560 bytes.** That
|
Net: **one `INFO` line per request, of at most 2,560 bytes.** That
|
||||||
ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`,
|
ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`,
|
||||||
`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11
|
`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11
|
||||||
for `method`, plus a 405-byte fixed portion (the field names, the
|
for `method`, plus a 336-byte fixed portion (the field names, the
|
||||||
punctuation, both timestamps at their longest, `remoteIP` and
|
punctuation, both timestamps at their longest, an IPv6 `remoteIP` with
|
||||||
`clientIP` each charged as an IPv6 address with a zone, the status and
|
a zone, the status and the latency) — 2,087 bytes, stated at 2,560 so
|
||||||
the latency) — 2,156 bytes, stated at 2,560 so the figure has headroom.
|
the figure has headroom. `internal/middleware/accesslog_test.go`
|
||||||
`internal/middleware/accesslog_test.go` asserts it against 8 KB of
|
asserts it against 8 KB of client-chosen text in the path, in the
|
||||||
client-chosen text in the path, in the query, and in each of
|
query, and in each of `User-Agent`, `Referer` and `X-Request-Id`,
|
||||||
`User-Agent`, `Referer`, `X-Request-Id` and `X-Forwarded-For`,
|
|
||||||
including cases built from the characters the handlers escape, and
|
including cases built from the characters the handlers escape, and
|
||||||
against a 5xx that keeps its concrete path while all three header fields
|
against the widest access log line the service can be made to write: a
|
||||||
are also at their budget and an `X-Forwarded-For` sent from a trusted
|
5xx that keeps its concrete path while all three header fields are also
|
||||||
proxy ends in an IPv6 client address at its longest followed by an 8 KB
|
at their budget. Every case runs through both handlers
|
||||||
zone, where `clientIP` must name the address without the zone. Every
|
`internal/logger` can select — the JSON one and the text one it installs
|
||||||
case runs through both handlers `internal/logger` can select — the JSON
|
on a tty — since the two do not escape alike and the ceiling is quoted
|
||||||
one and the text one it installs on a tty — since the two do not escape
|
unqualified. Measured over a real connection, the widest access log line
|
||||||
alike and the ceiling is quoted unqualified.
|
is 1,972 bytes.
|
||||||
|
|
||||||
Multiply that ceiling by the request rate to size log storage. Note
|
Multiply that ceiling by the request rate to size log storage. Note
|
||||||
that the rate is not bounded by the limits above on every route:
|
that the rate is not bounded by the limits above on every route:
|
||||||
@@ -2646,10 +2541,11 @@ on all three arms of `Trace`, including the routine one an operator
|
|||||||
reaches at `DEBUG`, which is the only level at which a successful
|
reaches at `DEBUG`, which is the only level at which a successful
|
||||||
`INSERT` is written at all. One GORM path does not consult the filter —
|
`INSERT` is written at all. One GORM path does not consult the filter —
|
||||||
`(*gorm.DB).Scan`, which records the statement through GORM's own trace
|
`(*gorm.DB).Scan`, which records the statement through GORM's own trace
|
||||||
recorder. No production code path calls it; only tests do, and what a
|
recorder. No production code path calls it; its one caller is
|
||||||
test binds is fixture data. `internal/gormlog/scan_guard_test.go` fails
|
`internal/database/database_test.go:91`, whose `SELECT 1` binds
|
||||||
if a non-test file calls it. `Pluck`, `Row` and `Raw` all run through
|
nothing, and `internal/gormlog/scan_guard_test.go` fails if a non-test
|
||||||
the normal callback processor and are filtered.
|
file calls it. `Pluck`, `Row` and `Raw` all run through the normal
|
||||||
|
callback processor and are filtered.
|
||||||
See `#### What DEBUG=true exposes` under Configuration.
|
See `#### What DEBUG=true exposes` under Configuration.
|
||||||
|
|
||||||
What that ceiling does **not** cover, stated here so the figure is not
|
What that ceiling does **not** cover, stated here so the figure is not
|
||||||
@@ -2675,20 +2571,16 @@ read as more than it is:
|
|||||||
that type on a specific webhook, and each line it writes is bounded
|
that type on a specific webhook, and each line it writes is bounded
|
||||||
per event by the 1 MB receiver body cap. Adding one is a decision to
|
per event by the 1 MB receiver body cap. Adding one is a decision to
|
||||||
spend log volume on that webhook's payloads.
|
spend log volume on that webhook's payloads.
|
||||||
- **The Go runtime**, which does not go through `internal/logger`. The
|
- **Two writers that do not go through `internal/logger` at all**, both
|
||||||
runtime writes an unrecovered panic or a fatal error itself, as plain
|
on standard error. `fx` prints the dependency graph and the lifecycle
|
||||||
text on standard error, and that output cannot be redirected. A panic
|
hooks through its default console logger at startup and shutdown —
|
||||||
in a background worker rather than in a request handler is the case
|
nothing calls `fx.WithLogger`, and `fx.New` builds that logger over
|
||||||
that reaches it, since nothing recovers those. It carries no
|
`os.Stderr`. The Go runtime writes a panic or a fatal error itself; a
|
||||||
client-chosen value at a client-chosen length: the service's own
|
panic in a background worker rather than in a request handler is the
|
||||||
`panic` calls are invariant guards over constants and over
|
case that reaches it, since nothing recovers those. Neither carries a
|
||||||
`crypto/rand`, apart from the one that hands `http.ErrAbortHandler`
|
client-chosen value at a client-chosen length: the five `panic` calls
|
||||||
back to `net/http`, described below.
|
in this service are invariant guards over constants and over
|
||||||
- **A failure before fx's logger is built**, such as an invalid
|
`crypto/rand`.
|
||||||
configuration value. fx's logger takes the configuration, so when
|
|
||||||
that fails fx's own console logger still prints the failure as plain
|
|
||||||
text on standard error. Its values come from the operator's
|
|
||||||
environment, not from a client.
|
|
||||||
- **`net/http`'s own faults**, which are _not_ a separate writer.
|
- **`net/http`'s own faults**, which are _not_ a separate writer.
|
||||||
`internal/server/http.go` builds its server with a nil `ErrorLog`, so
|
`internal/server/http.go` builds its server with a nil `ErrorLog`, so
|
||||||
`net/http` falls back to the `log` package's default logger — and
|
`net/http` falls back to the `log` package's default logger — and
|
||||||
@@ -2879,9 +2771,9 @@ remedies are to block the source at the reverse proxy, or to
|
|||||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||||
applied without reintroducing the lockout, because the proxy sees the
|
applied without reintroducing the lockout, because the proxy sees the
|
||||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
||||||
The flood's source is in the `clientIP` field of webhooker's access
|
The flood's source is in the proxy's access log: webhooker's own logs
|
||||||
log while `TRUSTED_PROXIES` covers the proxy, and in the proxy's own
|
record the proxy's address, not the client's (see
|
||||||
access log either way (see [Trusted proxies](#trusted-proxies)).
|
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
||||||
|
|
||||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
enforced in the webhook handler) can layer on top of this env-level
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
@@ -2894,7 +2786,7 @@ abuse limit later; they are tracked as future work.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------------------- | ----------- |
|
| ------ | --------------------------- | ----------- |
|
||||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) |
|
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||||
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||||
|
|
||||||
@@ -2916,7 +2808,6 @@ returns to the page that was asked for.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
|
||||||
| `GET` | `/hooks` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/hooks/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/hooks/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
@@ -2934,7 +2825,6 @@ returns to the page that was asked for.
|
|||||||
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||||
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||||
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||||
| `GET` | `/hook/{id}/targets/{targetID}/download` | Download a `database` target's archive as one gzipped JSON file. See [Database Architecture](#database-architecture) |
|
|
||||||
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||||
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||||
|
|
||||||
@@ -2969,7 +2859,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
├── 3p/
|
||||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||||
├── 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/
|
||||||
@@ -2980,8 +2870,7 @@ webhooker/
|
|||||||
│ ├── resetpw/
|
│ ├── resetpw/
|
||||||
│ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only
|
│ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ ├── config.go # Configuration loading from environment variables
|
│ │ └── config.go # Configuration loading from environment variables
|
||||||
│ │ └── testing.go # ClearEnvForTest: an empty environment for one test
|
|
||||||
│ ├── database/
|
│ ├── database/
|
||||||
│ │ ├── base_model.go # BaseModel with UUID primary keys
|
│ │ ├── base_model.go # BaseModel with UUID primary keys
|
||||||
│ │ ├── database.go # GORM connection, migrations, admin seed
|
│ │ ├── database.go # GORM connection, migrations, admin seed
|
||||||
@@ -3016,7 +2905,6 @@ webhooker/
|
|||||||
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
|
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
|
||||||
│ │ ├── target_database.go # Database archive target
|
│ │ ├── target_database.go # Database archive target
|
||||||
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
|
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
|
||||||
│ │ ├── 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 config for templates
|
│ │ ├── target_config_view.go # Masked target config for templates
|
||||||
│ │ ├── archive_sweeper.go # Periodic pruning of idle archives
|
│ │ ├── archive_sweeper.go # Periodic pruning of idle archives
|
||||||
@@ -3033,7 +2921,6 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
│ │ ├── settings.go # Read-only Settings page handler
|
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -3041,7 +2928,7 @@ webhooker/
|
|||||||
│ ├── lifecycle/
|
│ ├── lifecycle/
|
||||||
│ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context
|
│ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context
|
||||||
│ ├── logger/
|
│ ├── logger/
|
||||||
│ │ └── logger.go # slog setup with TTY detection; fx's event logger
|
│ │ └── logger.go # slog setup with TTY detection
|
||||||
│ ├── metrics/
|
│ ├── metrics/
|
||||||
│ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type
|
│ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
@@ -3065,15 +2952,14 @@ webhooker/
|
|||||||
│ ├── static.go # //go:embed directive
|
│ ├── static.go # //go:embed directive
|
||||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||||
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
|
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||||
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -3122,7 +3008,7 @@ Applied to all routes in this order:
|
|||||||
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
||||||
Permissions-Policy)
|
Permissions-Policy)
|
||||||
3. **Logging** — Structured request logging (method, URL, status,
|
3. **Logging** — Structured request logging (method, URL, status,
|
||||||
latency, remote IP, client IP, user agent, request ID)
|
latency, remote IP, user agent, request ID)
|
||||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
`METRICS_PASSWORD` are both set)
|
`METRICS_PASSWORD` are both set)
|
||||||
5. **CORS** — Cross-origin resource sharing headers
|
5. **CORS** — Cross-origin resource sharing headers
|
||||||
@@ -3144,14 +3030,14 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||||
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
|
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||||
error page in the normal layout; the global one keeps the plain-text `500` for
|
with the `500` error page in the normal layout; the global one keeps
|
||||||
every other route.
|
the plain-text `500` for every other route.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -3170,7 +3056,7 @@ declared length. A chunked request, or
|
|||||||
one that lies about its length, is hard-capped by
|
one that lies about its length, is hard-capped by
|
||||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||||
|
|
||||||
Those same five route groups then apply **CSRF** and **NoCache**
|
Those same four route groups then apply **CSRF** and **NoCache**
|
||||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
@@ -3216,12 +3102,12 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
POSTs) and `/api` (stateless API). The middleware detects TLS
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
session cookie uses — to set appropriate cookie security flags and
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
Origin/Referer validation mode
|
mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission, and no shared secret or signature check will
|
||||||
@@ -3235,8 +3121,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
route through a single decision function, so they cannot disagree
|
route through a single decision function, so they cannot disagree
|
||||||
about a destination. An operator can permit specific blocks with
|
about a destination. An operator can permit specific blocks with
|
||||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
||||||
guard cannot be switched off, and link-local, the unspecified
|
guard cannot be switched off, and link-local plus a
|
||||||
addresses `0.0.0.0` and `::`, and a
|
|
||||||
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
||||||
metadata endpoints — several of which are ULAs outside link-local —
|
metadata endpoints — several of which are ULAs outside link-local —
|
||||||
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
||||||
@@ -3283,9 +3168,9 @@ each hook. The order, read off the fx stop-hook log:
|
|||||||
|
|
||||||
1. `ArchiveSweeper`
|
1. `ArchiveSweeper`
|
||||||
2. `RetentionReaper`
|
2. `RetentionReaper`
|
||||||
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout`
|
3. `server` — the HTTP drain, bounded separately by
|
||||||
(**3 seconds**) and by what the hooks before it left, then a Sentry
|
`server.ShutdownTimeout` (**3 seconds**), then a Sentry flush if
|
||||||
flush if `SENTRY_DSN` is set
|
`SENTRY_DSN` is set
|
||||||
4. `delivery.Engine` — waits for its workers, then closes the archive
|
4. `delivery.Engine` — waits for its workers, then closes the archive
|
||||||
databases
|
databases
|
||||||
5. `healthcheck`
|
5. `healthcheck`
|
||||||
@@ -3305,30 +3190,23 @@ exhaust the sequence budget at the instant it finished, and every
|
|||||||
later hook — the delivery engine, the healthcheck, the webhook DB
|
later hook — the delivery engine, the healthcheck, the webhook DB
|
||||||
manager and the database close — would be skipped in exactly the
|
manager and the database close — would be skipped in exactly the
|
||||||
case where the drain mattered. 3 seconds leaves 2 seconds
|
case where the drain mattered. 3 seconds leaves 2 seconds
|
||||||
(`server.TailHookReserve`) for the tail. The reserve is that
|
(`server.TailHookReserve`) for the tail, which is far more than the
|
||||||
remainder, not a figure sized to the tail, which takes about a
|
microseconds it needs.
|
||||||
millisecond.
|
|
||||||
|
|
||||||
That reserve belongs to the tail hooks, not to the server hook, and
|
That reserve belongs to the tail hooks, not to the server hook, and
|
||||||
the server hook could take it in two ways. The hooks before it may
|
the Sentry flush is what could take it: it runs after the drain
|
||||||
already have spent part of the budget, so a full 3-second drain
|
**inside the same hook**, and `sentry.Flush` takes a bare duration
|
||||||
would come out of the reserve; the drain is therefore also bounded
|
and honours no context, so an unreachable Sentry endpoint would add
|
||||||
by whatever is left on the stop context minus the reserve. And the
|
its own timeout on top of a full-length drain and consume the whole
|
||||||
Sentry flush runs after the drain **inside the same hook**, and
|
sequence budget by itself. It is therefore clamped to whatever is
|
||||||
`sentry.Flush` takes a bare duration and honours no context, so an
|
left on the stop context minus the reserve, and skipped when that
|
||||||
unreachable Sentry endpoint would add its own timeout on top of a
|
leaves too little to be worth attempting — so a full-length drain
|
||||||
full-length drain and consume the whole sequence budget by itself.
|
means Sentry events are dropped rather than the database close being
|
||||||
It is clamped the same way, and skipped when that leaves too little
|
skipped.
|
||||||
to be worth attempting — so a full-length drain means Sentry events
|
|
||||||
are dropped rather than the database close being skipped.
|
|
||||||
|
|
||||||
This does not make the database close unconditional. A slow
|
This does not make the database close unconditional: a wedged
|
||||||
`ArchiveSweeper` or `RetentionReaper` is enough to cut the shutdown
|
`ArchiveSweeper` or `RetentionReaper` still runs first and can
|
||||||
short, not only one that consumes the whole budget: what they spend
|
consume the whole budget on its own.
|
||||||
comes out of the drain first, so after 2 seconds of theirs a request
|
|
||||||
still in flight gets 1 second to finish, and after 3 it gets none.
|
|
||||||
Past 3 seconds they spend the reserve itself, and one that takes the
|
|
||||||
whole budget skips every hook after it, the database close included.
|
|
||||||
|
|
||||||
The value is chosen to sit inside the container stop grace period.
|
The value is chosen to sit inside the container stop grace period.
|
||||||
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
|
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
|
||||||
@@ -3393,9 +3271,8 @@ version is fixed independently of the compiler's:
|
|||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
`golangci-lint run`, both with `--network=none`.
|
||||||
both with `--network=none`.
|
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
stage passing (it copies a file from it), runs `make test` and
|
stage passing (it copies a file from it), runs `make test` and
|
||||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||||
@@ -3427,9 +3304,8 @@ linked, which is what lets it run on the Alpine runtime image.
|
|||||||
inside the image, so a build that succeeds is a repo that is formatted,
|
inside the image, so a build that succeeds is a repo that is formatted,
|
||||||
linted, tested and compiled. `script/lint` also uses Docker
|
linted, tested and compiled. `script/lint` also uses Docker
|
||||||
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
||||||
run the same pinned linter version the gate does; of the steps
|
run the same pinned linter version the gate does; only `script/test`
|
||||||
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
and `script/fmt-check` run on the host.
|
||||||
host.
|
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ duplicate. That is deliberate — the alternative is a silent lost
|
|||||||
delivery — and the README says so under Rationale. It is not a defect
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
to re-file.
|
to re-file.
|
||||||
|
|
||||||
|
One caveat on reading a green check: a docs-only commit deliberately
|
||||||
|
replays from the layer cache
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
||||||
|
such a commit evidences a replay rather than an executed run. A code
|
||||||
|
commit invalidates the `COPY` layer and genuinely executes.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
|
|||||||
+7
-23
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxevent"
|
|
||||||
"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"
|
||||||
@@ -38,19 +37,17 @@ import (
|
|||||||
// hook that used the whole budget would exhaust it at that instant,
|
// hook that used the whole budget would exhaust it at that instant,
|
||||||
// and fx would skip every hook after the server — the delivery
|
// and fx would skip every hook after the server — the delivery
|
||||||
// engine, the healthcheck, the webhook DB manager and the database
|
// engine, the healthcheck, the webhook DB manager and the database
|
||||||
// close. That hook is the HTTP drain plus the Sentry flush that
|
// close. That hook is the 3s HTTP drain plus the Sentry flush that
|
||||||
// follows it in the same hook, and each is clamped to the stop
|
// follows it in the same hook, so the flush is clamped to the stop
|
||||||
// context's remaining time less server.TailHookReserve rather than
|
// context's remaining time less server.TailHookReserve rather than
|
||||||
// running for its own fixed 3s and 2s; the reserve is what the tail
|
// running for its own fixed 2s; the reserve is what the tail hooks
|
||||||
// hooks live on, and they are microsecond-scale in normal operation.
|
// live on, and they are microsecond-scale in normal operation.
|
||||||
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
||||||
// across every drain length and every amount of budget the hooks
|
// across every drain length.
|
||||||
// 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
|
||||||
// ArchiveSweeper 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
|
// and can still consume the whole budget on their own.
|
||||||
// out of the reserve, and they can consume the whole budget.
|
|
||||||
const stopTimeout = 5 * time.Second
|
const stopTimeout = 5 * time.Second
|
||||||
|
|
||||||
// exitUsage is the status for a command line this binary cannot make
|
// exitUsage is the status for a command line this binary cannot make
|
||||||
@@ -171,19 +168,6 @@ func run(stderr io.Writer) int {
|
|||||||
func newApp() *fx.App {
|
func newApp() *fx.App {
|
||||||
return fx.New(
|
return fx.New(
|
||||||
fx.StopTimeout(stopTimeout),
|
fx.StopTimeout(stopTimeout),
|
||||||
// fx's own events go through the service's logger, not fx's
|
|
||||||
// console logger on standard error. The exception is a failure
|
|
||||||
// before this logger is built, such as an invalid configuration
|
|
||||||
// value, which fx's console logger still prints there. fx holds
|
|
||||||
// its events back until this logger is built and then replays
|
|
||||||
// them, so it takes the configuration, which sets the level
|
|
||||||
// DEBUG=true asks for: without it the replay would run at INFO
|
|
||||||
// and drop every record of how the graph was built.
|
|
||||||
fx.WithLogger(
|
|
||||||
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
|
|
||||||
return logger.NewFxLogger(l.Get())
|
|
||||||
},
|
|
||||||
),
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
+9
-129
@@ -2,19 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
@@ -37,7 +30,6 @@ const dockerStopGrace = 10 * time.Second
|
|||||||
// fx.New applies options before it executes invokes, so the timeout
|
// fx.New applies options before it executes invokes, so the timeout
|
||||||
// is set whether or not the graph itself can be constructed here.
|
// is set whether or not the graph itself can be constructed here.
|
||||||
func TestNewApp_StopTimeout(t *testing.T) {
|
func TestNewApp_StopTimeout(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
got := newApp().StopTimeout()
|
got := newApp().StopTimeout()
|
||||||
@@ -46,100 +38,6 @@ func TestNewApp_StopTimeout(t *testing.T) {
|
|||||||
require.Less(t, got, dockerStopGrace)
|
require.Less(t, got, dockerStopGrace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// freePort returns a loopback TCP port that was free a moment ago, by
|
|
||||||
// taking one and releasing it.
|
|
||||||
func freePort(t *testing.T) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var listenCfg net.ListenConfig
|
|
||||||
|
|
||||||
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
addr, ok := l.Addr().(*net.TCPAddr)
|
|
||||||
require.True(t, ok, "listener is not TCP")
|
|
||||||
require.NoError(t, l.Close())
|
|
||||||
|
|
||||||
return addr.Port
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
|
|
||||||
// runs, with DEBUG=true, and reads back what reached the service's
|
|
||||||
// logger. fx's own events must arrive there as structured records:
|
|
||||||
// the start at INFO, and at DEBUG the records of how the graph was
|
|
||||||
// built.
|
|
||||||
//
|
|
||||||
// fx holds its events back until its logger is built and then replays
|
|
||||||
// them all at once, so the earliest of them arriving shows the replay
|
|
||||||
// ran at DEBUG: that globals.New was provided, which fx records before
|
|
||||||
// anything is built, and the run of logger.New, which happens before
|
|
||||||
// the configuration sets the level.
|
|
||||||
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
|
||||||
t.Setenv("PORT", strconv.Itoa(freePort(t)))
|
|
||||||
t.Setenv("DEBUG", "true")
|
|
||||||
|
|
||||||
// internal/logger writes to whatever os.Stdout is when it builds
|
|
||||||
// its handler. A file is not a terminal, so that handler is the
|
|
||||||
// JSON one the service uses in production.
|
|
||||||
out, err := os.CreateTemp(t.TempDir(), "stdout")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
stdout := os.Stdout
|
|
||||||
os.Stdout = out
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Stdout = stdout
|
|
||||||
_ = out.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
app := newApp()
|
|
||||||
require.NoError(t, app.Start(t.Context()))
|
|
||||||
require.NoError(t, app.Stop(t.Context()))
|
|
||||||
|
|
||||||
_, err = out.Seek(0, io.SeekStart)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
written, err := io.ReadAll(out)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
type record struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Constructor string `json:"constructor"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var records []record
|
|
||||||
|
|
||||||
for line := range strings.Lines(string(written)) {
|
|
||||||
var r record
|
|
||||||
|
|
||||||
// The first-boot banner is plain text, not a record.
|
|
||||||
if json.Unmarshal([]byte(line), &r) == nil {
|
|
||||||
records = append(records, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = "sneak.berlin/go/webhooker/internal/"
|
|
||||||
|
|
||||||
info := slog.LevelInfo.String()
|
|
||||||
debug := slog.LevelDebug.String()
|
|
||||||
|
|
||||||
assert.Contains(t, records, record{Level: info, Msg: "started"})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
|
|
||||||
})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "run", Name: pkg + "logger.New()",
|
|
||||||
})
|
|
||||||
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
|
|
||||||
assert.Contains(t, records, record{
|
|
||||||
Level: debug, Msg: "initialized custom fxevent.Logger",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunRefusesLockedDataDir pins what an operator's second start
|
// TestRunRefusesLockedDataDir pins what an operator's second start
|
||||||
// does. The entry point must refuse before it builds the fx graph —
|
// does. The entry point must refuse before it builds the fx graph —
|
||||||
// nothing may open a database in a DATA_DIR another process holds —
|
// nothing may open a database in a DATA_DIR another process holds —
|
||||||
@@ -252,40 +150,22 @@ 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 ArchiveSweeper and
|
// Shrinking either budget, or unbounding the flush again, must fail
|
||||||
// RetentionReaper hooks run before it, and whatever they spent is
|
// here rather than silently recreating a hook that swallows the
|
||||||
// gone. The outer sweep walks every amount they can spend. Once they
|
// whole sequence.
|
||||||
// have eaten into the headroom themselves, the hook must spend
|
|
||||||
// nothing of what is left. A drain that starts on the full budget
|
|
||||||
// must still get all of ShutdownTimeout, so a smaller stopTimeout
|
|
||||||
// cannot silently shorten every drain.
|
|
||||||
//
|
|
||||||
// Shrinking either budget, or unbounding the drain or the flush
|
|
||||||
// again, must fail here rather than silently recreating a hook that
|
|
||||||
// swallows the whole sequence.
|
|
||||||
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
||||||
require.Equal(
|
|
||||||
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
|
|
||||||
"a drain that starts on the full stop budget is cut short",
|
|
||||||
)
|
|
||||||
|
|
||||||
const step = 10 * time.Millisecond
|
const step = 10 * time.Millisecond
|
||||||
|
|
||||||
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
|
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
|
||||||
remaining := stopTimeout - spent
|
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
|
||||||
longest := max(server.DrainBudget(remaining), 0)
|
|
||||||
|
|
||||||
for drain := time.Duration(0); drain <= longest; drain += step {
|
require.LessOrEqual(
|
||||||
hook := drain + server.SentryFlushBudget(remaining-drain)
|
t, hook+tailHeadroom, stopTimeout,
|
||||||
|
"a %s drain leaves the tail hooks short", drain,
|
||||||
require.GreaterOrEqual(
|
)
|
||||||
t, remaining-hook, min(remaining, tailHeadroom),
|
|
||||||
"a %s drain after %s of earlier hooks leaves "+
|
|
||||||
"the tail hooks short", drain, spent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
|
||||||
github.com/chromedp/chromedp v0.16.0
|
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
@@ -20,7 +18,7 @@ require (
|
|||||||
github.com/prometheus/client_model v0.5.0
|
github.com/prometheus/client_model v0.5.0
|
||||||
github.com/slok/go-http-metrics v0.11.0
|
github.com/slok/go-http-metrics v0.11.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.20.1
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
@@ -31,17 +29,13 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||||
@@ -50,12 +44,13 @@ require (
|
|||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/dig v1.17.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
go.uber.org/zap v1.23.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
||||||
|
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||||
|
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
|
||||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
|
||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -24,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -64,20 +55,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||||
@@ -94,24 +82,28 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
|||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
|
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
|
||||||
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
|
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
||||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
|
||||||
|
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
@@ -119,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
|||||||
+24
-26
@@ -80,7 +80,8 @@ const (
|
|||||||
// process over a Docker network or a private LAN connects from.
|
// process over a Docker network or a private LAN connects from.
|
||||||
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number.
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
|
|
||||||
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
||||||
@@ -104,7 +105,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
|
|||||||
var ErrNonPositiveValue = errors.New("value must be positive")
|
var ErrNonPositiveValue = errors.New("value must be positive")
|
||||||
|
|
||||||
// ErrInvalidPort is returned when an environment variable holding a
|
// ErrInvalidPort is returned when an environment variable holding a
|
||||||
// TCP port number is set to a number outside 1 to 65535.
|
// TCP port number is set above the valid port range.
|
||||||
var ErrInvalidPort = errors.New("invalid port")
|
var ErrInvalidPort = errors.New("invalid port")
|
||||||
|
|
||||||
// ErrInvalidCIDR is returned when an environment variable holding a
|
// ErrInvalidCIDR is returned when an environment variable holding a
|
||||||
@@ -148,6 +149,7 @@ type ConfigParams struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
DataDir string
|
DataDir string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
MaintenanceMode bool
|
||||||
Environment string
|
Environment string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
@@ -194,13 +196,12 @@ type Config struct {
|
|||||||
// otherwise refuse. The guard itself is always on: there is no
|
// otherwise refuse. The guard itself is always on: there is no
|
||||||
// setting that disables SSRF protection, and delivery's
|
// setting that disables SSRF protection, and delivery's
|
||||||
// alwaysBlockedNetworks stays blocked no matter what is listed
|
// alwaysBlockedNetworks stays blocked no matter what is listed
|
||||||
// here. That set is link-local, the unspecified addresses
|
// here. That set is link-local plus the cloud metadata
|
||||||
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
// endpoints outside it that disclose credentials or user data
|
||||||
// link-local that disclose credentials or user data at a
|
// at a provider-fixed, non-public address; it is not
|
||||||
// provider-fixed, non-public address; it is not exhaustive of
|
// exhaustive of every cloud's metadata address. See
|
||||||
// every cloud's metadata address. See
|
// alwaysBlockedNetworks for the authoritative list and the
|
||||||
// alwaysBlockedNetworks for the authoritative list and why
|
// criterion it is built from.
|
||||||
// each entry is on it.
|
|
||||||
AllowedEgressCIDRs []netip.Prefix
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
@@ -362,27 +363,17 @@ func envPositiveInt(
|
|||||||
// envPort returns the value of the named environment variable parsed
|
// envPort returns the value of the named environment variable parsed
|
||||||
// as a TCP port number. Returns defaultValue if not set. A set value
|
// as a TCP port number. Returns defaultValue if not set. A set value
|
||||||
// that is unparseable, below 1, or above maxPort is a hard error
|
// that is unparseable, below 1, or above maxPort is a hard error
|
||||||
// naming the key and the bad value; every out-of-range value wraps
|
// naming the key and the bad value.
|
||||||
// ErrInvalidPort, including one too large or too small for an int.
|
|
||||||
func envPort(key string, defaultValue int) (int, error) {
|
func envPort(key string, defaultValue int) (int, error) {
|
||||||
v := os.Getenv(key)
|
port, err := envPositiveInt(key, defaultValue)
|
||||||
if v == "" {
|
if err != nil {
|
||||||
return defaultValue, nil
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// strconv.ErrRange means a number too large or too small for an
|
if port > maxPort {
|
||||||
// int, which is outside the port range as well.
|
|
||||||
port, err := strconv.Atoi(v)
|
|
||||||
if err != nil && !errors.Is(err, strconv.ErrRange) {
|
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"invalid integer for %s: %q: %w", key, v, err,
|
"%w: %s must be at most %d, got %d",
|
||||||
)
|
ErrInvalidPort, key, maxPort, port,
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || port < 1 || port > maxPort {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"%w: %s must be from 1 to %d, got %q",
|
|
||||||
ErrInvalidPort, key, maxPort, v,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +657,11 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
retentionSweepInterval, err := envPositiveDuration(
|
retentionSweepInterval, err := envPositiveDuration(
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
defaultRetentionSweepInterval,
|
defaultRetentionSweepInterval,
|
||||||
@@ -715,6 +711,7 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return &Config{
|
return &Config{
|
||||||
DataDir: DataDir(),
|
DataDir: DataDir(),
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
|
MaintenanceMode: maintenanceMode,
|
||||||
Environment: environment,
|
Environment: environment,
|
||||||
MetricsUsername: metricsUsername,
|
MetricsUsername: metricsUsername,
|
||||||
MetricsPassword: metricsPassword,
|
MetricsPassword: metricsPassword,
|
||||||
@@ -801,6 +798,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
// host can reach the admin UI.
|
// host can reach the admin UI.
|
||||||
"bindAddress", s.BindAddress,
|
"bindAddress", s.BindAddress,
|
||||||
"debug", s.Debug,
|
"debug", s.Debug,
|
||||||
|
"maintenanceMode", s.MaintenanceMode,
|
||||||
"dataDir", s.DataDir,
|
"dataDir", s.DataDir,
|
||||||
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||||
// Logged because a perfectly valid non-positive value here
|
// Logged because a perfectly valid non-positive value here
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,12 +71,14 @@ func TestEnvironmentConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.envValue != "" {
|
if tt.envValue != "" {
|
||||||
t.Setenv(
|
t.Setenv(
|
||||||
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"WEBHOOKER_ENVIRONMENT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range tt.envVars {
|
for k, v := range tt.envVars {
|
||||||
@@ -121,11 +124,6 @@ func testEnvironmentConfigSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned. The same holds for every fxtest.New
|
|
||||||
// below.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -196,11 +194,14 @@ func TestRetentionSweepInterval(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
|
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -271,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -335,11 +335,14 @@ func TestSessionIdleTimeout(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
|
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"SESSION_IDLE_TIMEOUT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -361,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -388,17 +390,20 @@ func TestDefaultDataDir(t *testing.T) {
|
|||||||
t.Run("env="+name, func(t *testing.T) {
|
t.Run("env="+name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if env != "" {
|
if env != "" {
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"WEBHOOKER_ENVIRONMENT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
||||||
|
|
||||||
var cfg *config.Config
|
var cfg *config.Config
|
||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -433,9 +438,9 @@ func TestDataDirHelper(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
if set == "" {
|
||||||
|
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
||||||
if set != "" {
|
} else {
|
||||||
t.Setenv("DATA_DIR", set)
|
t.Setenv("DATA_DIR", set)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,11 +503,14 @@ func TestReceiverRateLimit(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
|
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -526,7 +534,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -614,11 +621,12 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("TRUSTED_PROXIES", tt.value)
|
t.Setenv("TRUSTED_PROXIES", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -642,7 +650,6 @@ func testTrustedProxiesSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -725,11 +732,14 @@ func TestAllowedEgressCIDRs(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -753,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -797,10 +806,13 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
||||||
|
|
||||||
if tt.allowed != "" {
|
if tt.allowed == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,14 +945,20 @@ func TestMetricsAuthConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.username.set {
|
if tt.username.set {
|
||||||
t.Setenv("METRICS_USERNAME", tt.username.value)
|
t.Setenv("METRICS_USERNAME", tt.username.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("METRICS_USERNAME"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.password.set {
|
if tt.password.set {
|
||||||
t.Setenv("METRICS_PASSWORD", tt.password.value)
|
t.Setenv("METRICS_PASSWORD", tt.password.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("METRICS_PASSWORD"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
@@ -988,7 +1006,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(globals.New, logger.New, config.New),
|
fx.Provide(globals.New, logger.New, config.New),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ const malformedDotEnv = "PORT 19615\n" +
|
|||||||
"this is not = valid ! syntax\n" +
|
"this is not = valid ! syntax\n" +
|
||||||
"\"unclosed\n"
|
"\"unclosed\n"
|
||||||
|
|
||||||
|
// unsetDotEnvKey makes dotEnvKey genuinely absent for the duration of
|
||||||
|
// the test and restores it afterwards. t.Setenv registers the restore;
|
||||||
|
// the Unsetenv that follows is what the test actually needs, because a
|
||||||
|
// variable set to the empty string is still present in os.Environ and
|
||||||
|
// godotenv would refuse to overwrite it.
|
||||||
|
func unsetDotEnvKey(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv(dotEnvKey, "placeholder")
|
||||||
|
require.NoError(t, os.Unsetenv(dotEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
// writeDotEnv writes contents to a .env file in a fresh temporary
|
// writeDotEnv writes contents to a .env file in a fresh temporary
|
||||||
// directory and returns its path.
|
// directory and returns its path.
|
||||||
func writeDotEnv(t *testing.T, contents string) string {
|
func writeDotEnv(t *testing.T, contents string) string {
|
||||||
@@ -39,9 +50,9 @@ func writeDotEnv(t *testing.T, contents string) string {
|
|||||||
// normally rather than be refused for a file it was never meant to
|
// normally rather than be refused for a file it was never meant to
|
||||||
// have.
|
// have.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
||||||
@@ -54,9 +65,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
|||||||
// reaches the environment, which is the whole reason the file is read
|
// reaches the environment, which is the whole reason the file is read
|
||||||
// at all.
|
// at all.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
||||||
|
|
||||||
@@ -82,9 +93,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
|
|||||||
// reverts to its default; the process used to start that way with no
|
// reverts to its default; the process used to start that way with no
|
||||||
// log line naming the file at all.
|
// log line naming the file at all.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // unsetDotEnvKey uses t.Setenv.
|
||||||
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
path := writeDotEnv(
|
path := writeDotEnv(
|
||||||
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
||||||
@@ -132,7 +143,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
|
|||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
unsetDotEnvKey(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
|
|||||||
+103
-81
@@ -1,6 +1,7 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -17,9 +18,10 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
|
|||||||
|
|
||||||
// Real configuration variables exercised by the config.New tests.
|
// Real configuration variables exercised by the config.New tests.
|
||||||
const (
|
const (
|
||||||
envKeyPort = "PORT"
|
envKeyPort = "PORT"
|
||||||
envKeyDebug = "DEBUG"
|
envKeyDebug = "DEBUG"
|
||||||
envKeyBindAddress = "BIND_ADDRESS"
|
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||||
|
envKeyBindAddress = "BIND_ADDRESS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sample BIND_ADDRESS values used by the tables below.
|
// Sample BIND_ADDRESS values used by the tables below.
|
||||||
@@ -120,10 +122,10 @@ func TestEnvBool(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvBoolForTest(
|
got, err := config.EnvBoolForTest(
|
||||||
@@ -144,62 +146,17 @@ func TestEnvBool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envIntCase is one row of the envPositiveInt and envPort tables.
|
|
||||||
type envIntCase struct {
|
|
||||||
name string
|
|
||||||
set bool
|
|
||||||
value string
|
|
||||||
expectError bool
|
|
||||||
errIs error
|
|
||||||
expected int
|
|
||||||
}
|
|
||||||
|
|
||||||
// runEnvIntCases runs each row through parse, which is
|
|
||||||
// envPositiveInt or envPort, with testEnvKey set to the row's value
|
|
||||||
// or left unset.
|
|
||||||
func runEnvIntCases(
|
|
||||||
t *testing.T,
|
|
||||||
parse func(key string, defaultValue int) (int, error),
|
|
||||||
defaultValue int,
|
|
||||||
tests []envIntCase,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
|
||||||
// is incompatible with parallel subtests.
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
|
||||||
t.Setenv(testEnvKey, tt.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := parse(testEnvKey, defaultValue)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), testEnvKey)
|
|
||||||
assert.Contains(t, err.Error(), tt.value)
|
|
||||||
|
|
||||||
if tt.errIs != nil {
|
|
||||||
require.ErrorIs(t, err, tt.errIs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
|
||||||
func TestEnvPositiveInt(t *testing.T) {
|
func TestEnvPositiveInt(t *testing.T) {
|
||||||
const defaultValue = 7
|
const defaultValue = 7
|
||||||
|
|
||||||
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
errIs error
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "unset returns the default integer",
|
name: "unset returns the default integer",
|
||||||
expected: defaultValue,
|
expected: defaultValue,
|
||||||
@@ -236,14 +193,51 @@ func TestEnvPositiveInt(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrNonPositiveValue,
|
errIs: config.ErrNonPositiveValue,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPositiveIntForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
|
||||||
func TestEnvPort(t *testing.T) {
|
func TestEnvPort(t *testing.T) {
|
||||||
const defaultValue = 8080
|
const defaultValue = 8080
|
||||||
|
|
||||||
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
errIs error
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "unset returns the default port",
|
name: "unset returns the default port",
|
||||||
expected: defaultValue,
|
expected: defaultValue,
|
||||||
@@ -271,14 +265,7 @@ func TestEnvPort(t *testing.T) {
|
|||||||
set: true,
|
set: true,
|
||||||
value: "0",
|
value: "0",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrInvalidPort,
|
errIs: config.ErrNonPositiveValue,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative is rejected",
|
|
||||||
set: true,
|
|
||||||
value: "-1",
|
|
||||||
expectError: true,
|
|
||||||
errIs: config.ErrInvalidPort,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "above the port range is rejected",
|
name: "above the port range is rejected",
|
||||||
@@ -287,14 +274,37 @@ func TestEnvPort(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
errIs: config.ErrInvalidPort,
|
errIs: config.ErrInvalidPort,
|
||||||
},
|
},
|
||||||
{
|
}
|
||||||
name: "too large for an int is rejected",
|
|
||||||
set: true,
|
for _, tt := range tests {
|
||||||
value: "99999999999999999999",
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
expectError: true,
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
errIs: config.ErrInvalidPort,
|
// is incompatible with parallel subtests.
|
||||||
},
|
if tt.set {
|
||||||
})
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPortForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEnvBindAddress covers BIND_ADDRESS parsing.
|
// TestEnvBindAddress covers BIND_ADDRESS parsing.
|
||||||
@@ -310,10 +320,10 @@ func TestEnvBindAddress(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvBindAddressForTest(
|
got, err := config.EnvBindAddressForTest(
|
||||||
@@ -476,7 +486,6 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
t.Setenv(tt.key, tt.value)
|
t.Setenv(tt.key, tt.value)
|
||||||
|
|
||||||
@@ -595,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
|
|||||||
value: "ture",
|
value: "ture",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||||
|
key: envKeyMaintenanceMode,
|
||||||
|
value: "sometimes",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,15 +653,22 @@ func sentryEnvValueCases() []badEnvValueCase {
|
|||||||
// break the legitimate unset case: absent variables still get their
|
// break the legitimate unset case: absent variables still get their
|
||||||
// documented defaults.
|
// documented defaults.
|
||||||
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||||
|
envKeyBindAddress, envKeySentryDSN,
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.Unsetenv(key))
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := buildConfig(t)
|
cfg, err := buildConfig(t)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, cfg)
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
assert.Equal(t, 8080, cfg.Port)
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
assert.False(t, cfg.Debug)
|
assert.False(t, cfg.Debug)
|
||||||
|
assert.False(t, cfg.MaintenanceMode)
|
||||||
|
|
||||||
// Loopback, not the wildcard: the default must not publish the
|
// Loopback, not the wildcard: the default must not publish the
|
||||||
// cleartext admin UI and the unauthenticated receiver on every
|
// cleartext admin UI and the unauthenticated receiver on every
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -100,10 +101,10 @@ func TestEnvSentryDSN(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(envKeySentryDSN, tt.value)
|
t.Setenv(envKeySentryDSN, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(envKeySentryDSN))
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
|
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClearEnvForTest unsets every variable in the process environment
|
|
||||||
// for the rest of the test, so a test sees only the variables it sets
|
|
||||||
// itself, not whatever the developer's shell exports. When the test
|
|
||||||
// ends it leaves the environment exactly as it found it: each variable
|
|
||||||
// it unset is put back, and any variable added since is removed.
|
|
||||||
func ClearEnvForTest(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
present := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
key, _, _ := strings.Cut(entry, "=")
|
|
||||||
present[key] = true
|
|
||||||
|
|
||||||
// t.Setenv registers the restore; the Unsetenv after it is
|
|
||||||
// what makes the key absent, since a key set to the empty
|
|
||||||
// string is still present, and godotenv will not overwrite a
|
|
||||||
// present key.
|
|
||||||
t.Setenv(key, "")
|
|
||||||
|
|
||||||
err := os.Unsetenv(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unsetting %s: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A variable the test adds other than through t.Setenv, as loading
|
|
||||||
// a .env file does, has no restore of its own.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
for _, entry := range os.Environ() {
|
|
||||||
key, _, _ := strings.Cut(entry, "=")
|
|
||||||
if present[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.Unsetenv(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unsetting %s: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
|
|
||||||
// after the clear other than through t.Setenv, as a test's .env file
|
|
||||||
// sets one, is gone once the test ends, so it cannot reach the tests
|
|
||||||
// that run after it.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
|
||||||
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
|
|
||||||
// The outer clear keeps a value of the key exported in the shell
|
|
||||||
// from making it a variable the inner clear has to put back.
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
t.Run("loads a .env file after the clear", func(t *testing.T) {
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
|
|
||||||
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
|
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
|
||||||
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
|
|
||||||
})
|
|
||||||
|
|
||||||
_, present := os.LookupEnv(dotEnvKey)
|
|
||||||
assert.False(
|
|
||||||
t, present,
|
|
||||||
"a variable set after the clear must not outlive the test",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -199,40 +199,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
"(deleted_at=? AND created_at>?)")
|
"(deleted_at=? AND created_at>?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResubmitCountUsesItsIndex does the same for the event log's count
|
|
||||||
// of the events resubmitted from each of a page's events (resubmitCounts
|
|
||||||
// in the handlers). It passes a full page of 25 ids: with an index on
|
|
||||||
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
|
|
||||||
// the deleted_at index from five.
|
|
||||||
func TestResubmitCountUsesItsIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
|
|
||||||
page := make([]string, 25)
|
|
||||||
for i := range page {
|
|
||||||
page[i] = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var counts []struct{ Total int }
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Select("resubmitted_from_id, count(*) AS total").
|
|
||||||
Where("resubmitted_from_id IN ?", page).
|
|
||||||
Group("resubmitted_from_id").Find(&counts),
|
|
||||||
"idx_events_resubmitted_from_id "+
|
|
||||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type APIKey struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// User.APIKeys leads back here, and the JSON could loop.
|
User User `json:"user,omitzero"`
|
||||||
User User `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
Event Event `json:"event,omitzero"`
|
||||||
// JSON could loop.
|
Target Target `json:"target,omitzero"`
|
||||||
Event Event `json:"-"`
|
|
||||||
Target Target `json:"-"`
|
|
||||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type DeliveryResult struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Delivery.DeliveryResults leads back here, and the JSON could loop.
|
Delivery Delivery `json:"delivery,omitzero"`
|
||||||
Delivery Delivery `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type Entrypoint struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Webhook.Entrypoints leads back here, and the JSON could loop.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ type Event struct {
|
|||||||
// narrows by a < only on the last column it uses. Its final delete
|
// narrows by a < only on the last column it uses. Its final delete
|
||||||
// has no deleted_at condition and uses the index on created_at
|
// has no deleted_at condition and uses the index on created_at
|
||||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||||
// DeletedAt is also the second column of the resubmitted_from_id
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
||||||
// index, for the reason DeliveryResult gives.
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
|
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||||
@@ -44,11 +42,10 @@ type Event struct {
|
|||||||
// existed. It is not a foreign key: the source event can be
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||||
Entrypoint Entrypoint `json:"-"`
|
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
|
|
||||||
// reference to the record it belongs to is left out of the JSON, so a
|
|
||||||
// webhook and its targets cannot marshal each other in a loop, and that
|
|
||||||
// GORM still preloads that reference, since it ignores json tags.
|
|
||||||
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
stored := database.Webhook{
|
|
||||||
UserID: uuid.New().String(),
|
|
||||||
Name: testWebhookName,
|
|
||||||
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
|
||||||
Targets: []database.Target{{
|
|
||||||
Name: "log",
|
|
||||||
Type: database.TargetTypeLog,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
require.NoError(t, db.Create(&stored).Error)
|
|
||||||
|
|
||||||
entrypointID := stored.Entrypoints[0].ID
|
|
||||||
targetID := stored.Targets[0].ID
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
require.NoError(t, db.
|
|
||||||
Preload("Entrypoints.Webhook").
|
|
||||||
Preload("Targets.Webhook").
|
|
||||||
First(&webhook, "id = ?", stored.ID).Error)
|
|
||||||
|
|
||||||
require.Len(t, webhook.Entrypoints, 1)
|
|
||||||
require.Len(t, webhook.Targets, 1)
|
|
||||||
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
|
|
||||||
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
|
|
||||||
|
|
||||||
encoded := marshalModel(t, webhook)
|
|
||||||
|
|
||||||
assert.Contains(t, encoded, entrypointID)
|
|
||||||
assert.Contains(t, encoded, targetID)
|
|
||||||
|
|
||||||
// Each child holds the parent's id as its webhookId, so the parent
|
|
||||||
// is looked for by its own id field.
|
|
||||||
parentIDField := `"id":"` + stored.ID + `"`
|
|
||||||
|
|
||||||
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
|
|
||||||
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
|
|
||||||
|
|
||||||
var target database.Target
|
|
||||||
|
|
||||||
require.NoError(t, db.
|
|
||||||
Preload("Webhook").
|
|
||||||
First(&target, "id = ?", targetID).Error)
|
|
||||||
|
|
||||||
assert.Equal(t, stored.ID, target.Webhook.ID)
|
|
||||||
|
|
||||||
encoded = marshalModel(t, target)
|
|
||||||
|
|
||||||
assert.Contains(t, encoded, stored.ID)
|
|
||||||
assert.NotContains(t, encoded, parentIDField)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestModelsMarshalWithoutTheirParent covers the other references to a
|
|
||||||
// parent: each model is built with its parent set, and the parent's id
|
|
||||||
// must not appear in the JSON.
|
|
||||||
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parent := database.BaseModel{ID: uuid.New().String()}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
model any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Webhook.User",
|
|
||||||
model: database.Webhook{User: database.User{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "APIKey.User",
|
|
||||||
model: database.APIKey{User: database.User{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delivery.Event",
|
|
||||||
model: database.Delivery{Event: database.Event{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delivery.Target",
|
|
||||||
model: database.Delivery{Target: database.Target{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DeliveryResult.Delivery",
|
|
||||||
model: database.DeliveryResult{
|
|
||||||
Delivery: database.Delivery{BaseModel: parent},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Event.Webhook",
|
|
||||||
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Event.Entrypoint",
|
|
||||||
model: database.Event{
|
|
||||||
Entrypoint: database.Entrypoint{BaseModel: parent},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,7 @@ type Target struct {
|
|||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Webhook Webhook `json:"-"`
|
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,8 @@ type Webhook struct {
|
|||||||
// must equal DefaultRetentionDays.
|
// must equal DefaultRetentionDays.
|
||||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations
|
||||||
// User.Webhooks leads back here, and the JSON could loop.
|
User User `json:"user,omitzero"`
|
||||||
User User `json:"-"`
|
|
||||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
wh := webhooks[i]
|
wh := webhooks[i]
|
||||||
|
|
||||||
|
// Skip retain-forever webhooks before building any query.
|
||||||
|
// RetainsForever covers both the RetentionForeverDays
|
||||||
|
// sentinel and the non-positive values that predate it: the
|
||||||
|
// sentinel is a positive number, so without this the reaper
|
||||||
|
// would compute a cutoff a thousand years in the past and
|
||||||
|
// issue a DELETE matching nothing on every single sweep.
|
||||||
|
if wh.RetainsForever() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing to reap if the per-webhook database has never
|
// Nothing to reap if the per-webhook database has never
|
||||||
// been created.
|
// been created.
|
||||||
if !r.dbManager.DBExists(wh.ID) {
|
if !r.dbManager.DBExists(wh.ID) {
|
||||||
@@ -202,13 +212,6 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
webhookID string,
|
webhookID string,
|
||||||
retentionDays int,
|
retentionDays int,
|
||||||
) {
|
) {
|
||||||
// A retain-forever webhook has no cutoff, so its database is not
|
|
||||||
// even opened.
|
|
||||||
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := r.dbManager.GetDB(webhookID)
|
db, err := r.dbManager.GetDB(webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
@@ -220,6 +223,11 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := reapExpired(ctx, db, cutoff)
|
deleted, err := reapExpired(ctx, db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|||||||
t,
|
t,
|
||||||
overflowingRetentionDays,
|
overflowingRetentionDays,
|
||||||
database.RetentionForeverDays,
|
database.RetentionForeverDays,
|
||||||
"the test value must not be treated as retain-forever",
|
"the test value must not be rescued by the forever skip",
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookID := createWebhook(
|
webhookID := createWebhook(
|
||||||
|
|||||||
@@ -102,13 +102,6 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
|||||||
// seedExpiredEvents stores count events created at the given time,
|
// seedExpiredEvents stores count events created at the given time,
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
// each with a delivered delivery to one target and a failed delivery
|
||||||
// to the other, and one attempt for each delivery.
|
// to the other, and one attempt for each delivery.
|
||||||
//
|
|
||||||
// It and seedBareEvents insert 50 rows per statement, not more. The
|
|
||||||
// SQLite driver looks up each parameter's value by scanning the
|
|
||||||
// statement's arguments from the first until it reaches that
|
|
||||||
// parameter's, so the time to bind a statement grows with the square of
|
|
||||||
// its parameter count: at 500 rows, several thousand parameters, the
|
|
||||||
// seeding took most of these tests' time under -race.
|
|
||||||
func seedExpiredEvents(
|
func seedExpiredEvents(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
@@ -145,8 +138,8 @@ func seedExpiredEvents(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
|
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
results := make([]database.DeliveryResult, len(deliveries))
|
||||||
for i := range deliveries {
|
for i := range deliveries {
|
||||||
@@ -155,7 +148,7 @@ func seedExpiredEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 50).Error)
|
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedBareEvents stores count events created at the given time, with
|
// seedBareEvents stores count events created at the given time, with
|
||||||
@@ -179,7 +172,7 @@ func seedBareEvents(
|
|||||||
events[i].CreatedAt = createdAt
|
events[i].CreatedAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 50).Error)
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||||
|
|||||||
@@ -33,19 +33,6 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
"invalid cached database type",
|
"invalid cached database type",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEventDBNotRemoved is in DeleteDB's error when the event
|
|
||||||
// database file itself could not be removed: it is still on disk.
|
|
||||||
var ErrEventDBNotRemoved = errors.New(
|
|
||||||
"event database file not removed",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrSidecarNotRemoved is in DeleteDB's error when the event
|
|
||||||
// database file was removed, so its events are gone, but its -wal
|
|
||||||
// or -shm sidecar could not be.
|
|
||||||
var ErrSidecarNotRemoved = errors.New(
|
|
||||||
"event database file removed, but a -wal or -shm sidecar was not",
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
@@ -164,10 +151,7 @@ func (m *WebhookDBManager) DBExists(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDB closes the connection and deletes the database file
|
// DeleteDB closes the connection and deletes the database file
|
||||||
// for a webhook, with its -wal and -shm sidecars. The files are
|
// for a webhook. The file is permanently removed.
|
||||||
// permanently removed. Each file is tried even when another could
|
|
||||||
// not be removed, and the error wraps ErrEventDBNotRemoved or
|
|
||||||
// ErrSidecarNotRemoved to say which was left, naming each file.
|
|
||||||
func (m *WebhookDBManager) DeleteDB(
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -186,23 +170,16 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the main DB file and WAL/SHM files
|
||||||
path := m.dbPath(webhookID)
|
path := m.dbPath(webhookID)
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
dbErr := removeFile(path)
|
err := os.Remove(path + suffix)
|
||||||
sidecarErr := errors.Join(
|
if err != nil && !os.IsNotExist(err) {
|
||||||
removeFile(path+"-wal"),
|
return fmt.Errorf(
|
||||||
removeFile(path+"-shm"),
|
"deleting webhook database file %s%s: %w",
|
||||||
)
|
path, suffix, err,
|
||||||
|
)
|
||||||
if dbErr != nil {
|
}
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %w",
|
|
||||||
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sidecarErr != nil {
|
|
||||||
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
@@ -213,17 +190,6 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeFile removes path. A file that is already gone counts as
|
|
||||||
// removed; the error from any other failure names the file.
|
|
||||||
func removeFile(path string) error {
|
|
||||||
err := os.Remove(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseAll closes all open per-webhook database connections.
|
// CloseAll closes all open per-webhook database connections.
|
||||||
// Called during application shutdown.
|
// Called during application shutdown.
|
||||||
func (m *WebhookDBManager) CloseAll() error {
|
func (m *WebhookDBManager) CloseAll() error {
|
||||||
|
|||||||
@@ -182,91 +182,17 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
// Under WAL, an open database that has been written to has both
|
|
||||||
// sidecars beside it.
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
|
||||||
require.FileExists(t, dbPath+"-wal")
|
|
||||||
require.FileExists(t, dbPath+"-shm")
|
|
||||||
|
|
||||||
// Delete the DB
|
// Delete the DB
|
||||||
require.NoError(t, mgr.DeleteDB(webhookID))
|
require.NoError(t, mgr.DeleteDB(webhookID))
|
||||||
|
|
||||||
// File should no longer exist
|
// File should no longer exist
|
||||||
assert.False(t, mgr.DBExists(webhookID))
|
assert.False(t, mgr.DBExists(webhookID))
|
||||||
|
|
||||||
// Verify the files are actually gone from disk
|
// Verify the file is actually gone from disk
|
||||||
assert.NoFileExists(t, dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockRemoval puts a non-empty directory at path, which os.Remove
|
|
||||||
// cannot remove whoever runs the test, root included.
|
|
||||||
func blockRemoval(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
|
|
||||||
// event database file cannot be removed, the error says so, and both
|
|
||||||
// sidecars are still removed.
|
|
||||||
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
blockRemoval(t, dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
|
assert.True(t, os.IsNotExist(err))
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
|
||||||
|
|
||||||
err := mgr.DeleteDB(webhookID)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
|
|
||||||
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
|
|
||||||
assert.Contains(t, err.Error(), dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
|
|
||||||
// event database file is removed but a sidecar is not, the error
|
|
||||||
// says the database file is gone, and the other sidecar is still
|
|
||||||
// removed.
|
|
||||||
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
dbPath := mgr.DBPath(webhookID)
|
|
||||||
|
|
||||||
require.NoError(t, mgr.CreateDB(webhookID))
|
|
||||||
// Closing removes the sidecars, so the ones below are the only
|
|
||||||
// ones there.
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
blockRemoval(t, dbPath+"-wal")
|
|
||||||
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
|
||||||
|
|
||||||
err := mgr.DeleteDB(webhookID)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
|
|
||||||
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
|
|
||||||
assert.Contains(t, err.Error(), dbPath+"-wal")
|
|
||||||
assert.NoFileExists(t, dbPath)
|
|
||||||
assert.NoFileExists(t, dbPath+"-shm")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||||
|
|||||||
@@ -45,13 +45,8 @@ type ArchiveSweeper struct {
|
|||||||
eng *Engine
|
eng *Engine
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
cancel context.CancelFunc
|
||||||
// cancel needs no lock: fx calls the stop hook only after the
|
wg sync.WaitGroup
|
||||||
// start hook has returned, so stop never reads it while start
|
|
||||||
// is still setting it.
|
|
||||||
cancel context.CancelFunc
|
|
||||||
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArchiveSweeper creates the archive sweeper and registers
|
// NewArchiveSweeper creates the archive sweeper and registers
|
||||||
@@ -168,18 +163,10 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
|||||||
var targets []database.Target
|
var targets []database.Target
|
||||||
|
|
||||||
err := s.db.DB().
|
err := s.db.DB().
|
||||||
WithContext(ctx).
|
|
||||||
Model(&database.Target{}).
|
Model(&database.Target{}).
|
||||||
Where("type = ?", database.TargetTypeDatabase).
|
Where("type = ?", database.TargetTypeDatabase).
|
||||||
Find(&targets).Error
|
Find(&targets).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The app stopping as a sweep starts cancels the listing.
|
|
||||||
// Stopping is not a failure, so it must not produce an
|
|
||||||
// error line.
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Error(
|
s.log.Error(
|
||||||
"archive sweep: failed to list database targets",
|
"archive sweep: failed to list database targets",
|
||||||
"error", err,
|
"error", err,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -22,7 +20,6 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -71,8 +68,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -170,8 +166,7 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -230,8 +225,7 @@ func countArchivedRows(path string) (int64, error) {
|
|||||||
defer func() { _ = sqlDB.Close() }()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -687,64 +681,6 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
|
|
||||||
// closes the handle it finds open before it reopens the file.
|
|
||||||
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
|
|
||||||
// close, the reopen replaces the handle without closing it, the
|
|
||||||
// sweep then closes only the new one, and one connection leaks per
|
|
||||||
// archive per sweep.
|
|
||||||
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
|
|
||||||
w := delivery.NewExportArchiveWriter(
|
|
||||||
path, archiveTestLogger(), 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, w.Open(time.Hour))
|
|
||||||
|
|
||||||
before, err := w.DB().DB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, w.SweepExpired(time.Hour))
|
|
||||||
|
|
||||||
assert.Error(
|
|
||||||
t, before.PingContext(t.Context()),
|
|
||||||
"the handle open before the sweep must be closed by it",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
|
|
||||||
// context is already cancelled, as when the app stops just as a
|
|
||||||
// sweep starts, returns without an error line: stopping is not a
|
|
||||||
// failure.
|
|
||||||
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupArchiveTest(t)
|
|
||||||
|
|
||||||
var errorLines bytes.Buffer
|
|
||||||
|
|
||||||
sweeper := delivery.NewTestArchiveSweeper(
|
|
||||||
env.mainDB, env.eng,
|
|
||||||
slog.New(slog.NewTextHandler(
|
|
||||||
&errorLines,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelError},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
sweeper.ExportSweep(ctx)
|
|
||||||
|
|
||||||
assert.Empty(
|
|
||||||
t, errorLines.String(),
|
|
||||||
"a cancelled sweep must not log at error level",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
||||||
// no-op for the default retention policy, so archives with no
|
// no-op for the default retention policy, so archives with no
|
||||||
// expiry (or the literal "never") behave exactly as before.
|
// expiry (or the literal "never") behave exactly as before.
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// iSetup holds common integration test dependencies.
|
// iSetup holds common integration test dependencies.
|
||||||
@@ -81,8 +80,7 @@ func iMainDB(t *testing.T) *gorm.DB {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -357,14 +355,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
var receivedBody string
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
receivedBody = string(body)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -404,8 +397,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
|||||||
context.TODO(), &task,
|
context.TODO(), &task,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, event.Body, receivedBody)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID,
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -452,14 +443,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
var receivedBody string
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
receivedBody = string(body)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -496,8 +482,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
|||||||
context.TODO(), &task,
|
context.TODO(), &task,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, largeBody, receivedBody)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID,
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||||
database.DeliveryStatusDelivered,
|
database.DeliveryStatusDelivered,
|
||||||
)
|
)
|
||||||
@@ -1427,32 +1411,6 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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`, 0, 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 ---
|
// --- Notify batching ---
|
||||||
|
|
||||||
func TestNotify_MultipleTasks(t *testing.T) {
|
func TestNotify_MultipleTasks(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -26,7 +25,6 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,8 +49,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -1059,21 +1056,6 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
) {
|
) {
|
||||||
@@ -1259,33 +1241,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A response that ends before the length it announced is an error, not
|
|
||||||
// a short body.
|
|
||||||
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
|
||||||
http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Length", "100")
|
|
||||||
_, _ = w.Write([]byte("cut short"))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
_, body, _, err := e.ExportDoHTTPRequest(
|
|
||||||
context.TODO(),
|
|
||||||
&delivery.HTTPTargetConfig{URL: ts.URL},
|
|
||||||
&database.Event{},
|
|
||||||
)
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
||||||
assert.Empty(t, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
// receiver saved as the event's ContentType, so a delivery could send
|
// receiver saved as the event's ContentType, so a delivery could send
|
||||||
// it twice. It must go out exactly once, with a Content-Type configured
|
// it twice. It must go out exactly once, with a Content-Type configured
|
||||||
@@ -1362,34 +1317,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stored inbound headers that do not decode forward nothing, not the
|
|
||||||
// part of them that happened to decode.
|
|
||||||
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
|
||||||
"https://target.example.com/hook",
|
|
||||||
http.NoBody,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
names := delivery.ExportApplyRequestHeaders(
|
|
||||||
req,
|
|
||||||
&database.Event{
|
|
||||||
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
|
|
||||||
},
|
|
||||||
&delivery.HTTPTargetConfig{},
|
|
||||||
"webhooker/dev",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Empty(t, names)
|
|
||||||
assert.Empty(t, req.Header.Get("X-Custom"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDelivery_RoutesToCorrectHandler(
|
func TestProcessDelivery_RoutesToCorrectHandler(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -512,12 +512,6 @@ func (e *ExportArchiveWriter) Reopen(
|
|||||||
return e.w.reopen(expiry)
|
return e.w.reopen(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.
|
// Reopens reports how many times the file has been opened.
|
||||||
func (e *ExportArchiveWriter) Reopens() int {
|
func (e *ExportArchiveWriter) Reopens() int {
|
||||||
return e.w.reopens
|
return e.w.reopens
|
||||||
|
|||||||
@@ -376,97 +376,3 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
|
|||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
|
|
||||||
// rule for a target with retries: whatever the receiver answered, the
|
|
||||||
// delivery stays pending and no retry is scheduled. The circuit breaker
|
|
||||||
// still learns the answer, because it describes the target's health,
|
|
||||||
// not the database's.
|
|
||||||
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The "send succeeded" case starts with the breaker tripped open,
|
|
||||||
// so the delivery goes out as its probe and only a recorded
|
|
||||||
// success closes it again.
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
answer int
|
|
||||||
tripped bool
|
|
||||||
wantBreaker delivery.CircuitState
|
|
||||||
}{
|
|
||||||
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
|
|
||||||
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(tc.answer)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
event := iSeedEvent(
|
|
||||||
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
d := iSeedDelivery(
|
|
||||||
t, s.WebhookDB, event.ID, targetID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
s.WebhookDB.Exec("drop table delivery_results").Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
// A single failure opens this breaker, and with no
|
|
||||||
// cooldown an open breaker lets the next delivery
|
|
||||||
// through as a probe.
|
|
||||||
cb := delivery.NewTestCircuitBreaker(1, 0)
|
|
||||||
if tc.tripped {
|
|
||||||
cb.RecordFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Engine.ExportSetCircuitBreaker(targetID, cb)
|
|
||||||
|
|
||||||
full := &database.Delivery{
|
|
||||||
EventID: event.ID,
|
|
||||||
TargetID: targetID,
|
|
||||||
Status: database.DeliveryStatusPending,
|
|
||||||
Event: event,
|
|
||||||
Target: database.Target{
|
|
||||||
Name: "unwritable",
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Config: iHTTPConfig(ts.URL),
|
|
||||||
MaxRetries: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
full.ID = d.ID
|
|
||||||
|
|
||||||
sched := &recordingScheduler{}
|
|
||||||
|
|
||||||
s.Engine.ExportDeliverHTTPWithScheduler(
|
|
||||||
context.Background(), s.WebhookDB, full,
|
|
||||||
&delivery.Task{
|
|
||||||
DeliveryID: d.ID,
|
|
||||||
TargetID: targetID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
},
|
|
||||||
sched,
|
|
||||||
)
|
|
||||||
|
|
||||||
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
|
|
||||||
assert.Empty(t, sched.delays, "no retry may be scheduled")
|
|
||||||
assert.Equal(t, tc.wantBreaker, cb.State())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-57
@@ -37,8 +37,8 @@ var (
|
|||||||
"blocked cloud metadata address",
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local, cloud instance metadata or " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
|
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||||
)
|
)
|
||||||
errInvalidScheme = errors.New(
|
errInvalidScheme = errors.New(
|
||||||
"only http and https are allowed",
|
"only http and https are allowed",
|
||||||
@@ -72,17 +72,14 @@ var blockedNetworks []*net.IPNet
|
|||||||
var blockedPublicNetworks []*net.IPNet
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open, so a supplied CIDR that covers one still leaves it
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// blocked. An entry is here for one of two reasons: it is a
|
// endpoints that live outside them. Reaching one is credential
|
||||||
// metadata endpoint (the link-local blocks and the cloud
|
// or user-data theft rather than delivery to an internal
|
||||||
// instance metadata endpoints that live outside them), or it is
|
// service, so a supplied CIDR that covers such an address still
|
||||||
// an unspecified address. Reaching a metadata endpoint is
|
// leaves it blocked.
|
||||||
// credential or user-data theft rather than delivery to an
|
|
||||||
// internal service.
|
|
||||||
//
|
//
|
||||||
// Inclusion criterion for metadata endpoints — one belongs here
|
// Inclusion criterion — an address belongs here only if BOTH
|
||||||
// only if BOTH hold, and every metadata entry below satisfies
|
// hold, and every entry below satisfies both:
|
||||||
// both:
|
|
||||||
//
|
//
|
||||||
// 1. It is a fixed address assigned by the provider, or a
|
// 1. It is a fixed address assigned by the provider, or a
|
||||||
// range reserved by IANA — never one the operator chose.
|
// range reserved by IANA — never one the operator chose.
|
||||||
@@ -93,8 +90,8 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// not cheaply rotated.
|
// not cheaply rotated.
|
||||||
//
|
//
|
||||||
// Both halves are load-bearing, so use them to refuse a
|
// Both halves are load-bearing, so use them to refuse a
|
||||||
// metadata candidate and say why. An endpoint disclosing only
|
// candidate and say why. An endpoint disclosing only the
|
||||||
// the operator's own inventory (instance id, region, disks, NICs)
|
// operator's own inventory (instance id, region, disks, NICs)
|
||||||
// fails (2): letting a delivery target reach the operator's own
|
// fails (2): letting a delivery target reach the operator's own
|
||||||
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
||||||
// provide. But (2) is not "IAM credentials only" either —
|
// provide. But (2) is not "IAM credentials only" either —
|
||||||
@@ -115,15 +112,6 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
//
|
//
|
||||||
// The unspecified addresses 0.0.0.0 and :: are here for a
|
|
||||||
// separate reason: they disclose nothing, but no host can have
|
|
||||||
// either, and on Linux a connection to one reaches this host's
|
|
||||||
// own loopback. Listing them means an allowlist reaches loopback
|
|
||||||
// only through an entry that covers a loopback address
|
|
||||||
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
|
|
||||||
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
|
|
||||||
// else lives at either address, so refusing them costs nothing.
|
|
||||||
//
|
|
||||||
// Every entry is either already in blockedNetworks — this list is
|
// Every entry is either already in blockedNetworks — this list is
|
||||||
// what makes it unconditional — or an alternate encoding of
|
// what makes it unconditional — or an alternate encoding of
|
||||||
// 169.254.169.254 that Contains does not match against
|
// 169.254.169.254 that Contains does not match against
|
||||||
@@ -143,46 +131,23 @@ var alwaysBlockedNetworks []*net.IPNet
|
|||||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
func init() {
|
func init() {
|
||||||
blockedNetworks = mustParseCIDRs([]string{
|
blockedNetworks = mustParseCIDRs([]string{
|
||||||
// IPv4 loopback.
|
|
||||||
"127.0.0.0/8",
|
"127.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"10.0.0.0/8",
|
"10.0.0.0/8",
|
||||||
// RFC 1918 private network.
|
|
||||||
"172.16.0.0/12",
|
"172.16.0.0/12",
|
||||||
// RFC 1918 private network.
|
|
||||||
"192.168.0.0/16",
|
"192.168.0.0/16",
|
||||||
// IPv4 link-local.
|
|
||||||
"169.254.0.0/16",
|
"169.254.0.0/16",
|
||||||
// "This network", holding the IPv4 unspecified address 0.0.0.0.
|
|
||||||
"0.0.0.0/8",
|
"0.0.0.0/8",
|
||||||
// Carrier-grade NAT shared address space.
|
|
||||||
"100.64.0.0/10",
|
"100.64.0.0/10",
|
||||||
// IETF protocol assignments.
|
|
||||||
"192.0.0.0/24",
|
"192.0.0.0/24",
|
||||||
// IPv4 documentation (TEST-NET-1).
|
|
||||||
"192.0.2.0/24",
|
"192.0.2.0/24",
|
||||||
// Benchmarking.
|
|
||||||
"198.18.0.0/15",
|
"198.18.0.0/15",
|
||||||
// IPv4 documentation (TEST-NET-2).
|
|
||||||
"198.51.100.0/24",
|
"198.51.100.0/24",
|
||||||
// IPv4 documentation (TEST-NET-3).
|
|
||||||
"203.0.113.0/24",
|
"203.0.113.0/24",
|
||||||
// IPv4 multicast.
|
|
||||||
"224.0.0.0/4",
|
"224.0.0.0/4",
|
||||||
// Reserved, including the broadcast address.
|
|
||||||
"240.0.0.0/4",
|
"240.0.0.0/4",
|
||||||
// IPv6 loopback.
|
|
||||||
"::1/128",
|
"::1/128",
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
// IPv6 unique local addresses.
|
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
// IPv6 link-local.
|
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// IPv6 multicast.
|
|
||||||
"ff00::/8",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"2001:db8::/32",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
blockedPublicNetworks = mustParseCIDRs([]string{
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||||
@@ -242,14 +207,6 @@ func init() {
|
|||||||
// allowlist from opening it.
|
// allowlist from opening it.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
|
|
||||||
// The unspecified addresses, each of which reaches this
|
|
||||||
// host's loopback on Linux.
|
|
||||||
//
|
|
||||||
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
// IPv6 unspecified address.
|
|
||||||
"::/128",
|
|
||||||
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -386,9 +343,8 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// The order is the policy:
|
// The order is the policy:
|
||||||
//
|
//
|
||||||
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
||||||
// consulted, so no configured CIDR reaches link-local, a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address, or an
|
// cloud metadata endpoint at a non-public address.
|
||||||
// unspecified address.
|
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network, or a listed public address on the default
|
// network, or a listed public address on the default
|
||||||
// blocklist, becomes reachable.
|
// blocklist, becomes reachable.
|
||||||
|
|||||||
@@ -168,13 +168,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
|||||||
|
|
||||||
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
||||||
// case: cloud instance metadata endpoints are credential theft
|
// case: cloud instance metadata endpoints are credential theft
|
||||||
// rather than delivery to an internal service, and the
|
// rather than delivery to an internal service, so no allowlist
|
||||||
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
// reaches one. Every guard below names a CIDR that covers its
|
||||||
// on Linux, so no allowlist reaches any of them. Every guard
|
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
||||||
// below names a CIDR that covers its target — including
|
// CGNAT blocks an operator would really list — and the address
|
||||||
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
// must stay refused anyway, on both the validation and the
|
||||||
// operator would really list — and the address must stay
|
// delivery path.
|
||||||
// refused anyway, on both the validation and the delivery path.
|
|
||||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -220,17 +219,15 @@ type metadataAlwaysRefusedCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||||
// blocked address (link-local, the cloud metadata endpoints and
|
// blocked address together with an allowlist entry that would
|
||||||
// the unspecified addresses) together with an allowlist entry
|
// otherwise reach it. Split by family of address only to stay
|
||||||
// that would otherwise reach it. Split by family of address only
|
// under the function-length limit.
|
||||||
// to stay under the function-length limit.
|
|
||||||
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
cases := linkLocalRefusedCases()
|
cases := linkLocalRefusedCases()
|
||||||
cases = append(cases, ulaMetadataRefusedCases()...)
|
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||||
cases = append(cases, ipv4MetadataRefusedCases()...)
|
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||||
cases = append(cases, encodedMetadataRefusedCases()...)
|
|
||||||
|
|
||||||
return append(cases, unspecifiedRefusedCases()...)
|
return append(cases, encodedMetadataRefusedCases()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkLocalRefusedCases covers the link-local blocks, including
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
@@ -370,23 +367,6 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unspecifiedRefusedCases covers the unspecified addresses, each
|
|
||||||
// of which reaches this host's loopback on Linux.
|
|
||||||
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
|
|
||||||
return []metadataAlwaysRefusedCase{
|
|
||||||
{
|
|
||||||
name: "IPv4 unspecified address under 0.0.0.0/0",
|
|
||||||
allow: allowAllIPv4,
|
|
||||||
target: "http://0.0.0.0:8080/hook",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 unspecified address under ::/0",
|
|
||||||
allow: allowAllIPv6,
|
|
||||||
target: "http://[::]:8080/hook",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
||||||
// not narrow anything: public addresses were reachable before it
|
// not narrow anything: public addresses were reachable before it
|
||||||
// existed and stay reachable, whether or not a list is set.
|
// existed and stay reachable, whether or not a list is set.
|
||||||
@@ -544,10 +524,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
// Oracle Cloud Classic metadata, inside the blocked
|
// Oracle Cloud Classic metadata, inside the blocked
|
||||||
// 192.0.0.0/24.
|
// 192.0.0.0/24.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
// The IPv4 and IPv6 unspecified addresses, each of
|
|
||||||
// which reaches this host's loopback on Linux.
|
|
||||||
"0.0.0.0/32",
|
|
||||||
"::/128",
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -580,8 +556,7 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|||||||
{cidr: "172.16.0.0/12", reopenable: true},
|
{cidr: "172.16.0.0/12", reopenable: true},
|
||||||
{cidr: "192.168.0.0/16", reopenable: true},
|
{cidr: "192.168.0.0/16", reopenable: true},
|
||||||
{cidr: linkLocalIPv4, reopenable: false},
|
{cidr: linkLocalIPv4, reopenable: false},
|
||||||
// Its first address, 0.0.0.0, is in the unconditional set.
|
{cidr: "0.0.0.0/8", reopenable: true},
|
||||||
{cidr: "0.0.0.0/8", reopenable: false},
|
|
||||||
{cidr: "100.64.0.0/10", reopenable: true},
|
{cidr: "100.64.0.0/10", reopenable: true},
|
||||||
{cidr: "192.0.0.0/24", reopenable: true},
|
{cidr: "192.0.0.0/24", reopenable: true},
|
||||||
{cidr: "192.0.2.0/24", reopenable: true},
|
{cidr: "192.0.2.0/24", reopenable: true},
|
||||||
@@ -591,11 +566,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|||||||
{cidr: "224.0.0.0/4", reopenable: true},
|
{cidr: "224.0.0.0/4", reopenable: true},
|
||||||
{cidr: "240.0.0.0/4", reopenable: true},
|
{cidr: "240.0.0.0/4", reopenable: true},
|
||||||
{cidr: "::1/128", reopenable: true},
|
{cidr: "::1/128", reopenable: true},
|
||||||
{cidr: "::/128", reopenable: false},
|
|
||||||
{cidr: "fc00::/7", reopenable: true},
|
{cidr: "fc00::/7", reopenable: true},
|
||||||
{cidr: "fe80::/10", reopenable: false},
|
{cidr: "fe80::/10", reopenable: false},
|
||||||
{cidr: "ff00::/8", reopenable: true},
|
|
||||||
{cidr: "2001:db8::/32", reopenable: true},
|
|
||||||
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,42 +101,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
|
|
||||||
// covers the unspecified addresses and the IPv6 multicast and
|
|
||||||
// documentation ranges: with no allowlist set, each is refused
|
|
||||||
// both when a target is created and when a delivery dials it.
|
|
||||||
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
guard := delivery.NewTestGuard()
|
|
||||||
|
|
||||||
targets := []string{
|
|
||||||
// The unspecified addresses. On Linux a connection to
|
|
||||||
// either reaches this host's loopback.
|
|
||||||
"http://0.0.0.0:8080/hook",
|
|
||||||
"http://[::]:8080/hook",
|
|
||||||
// IPv6 multicast, all nodes.
|
|
||||||
"http://[ff02::1]/hook",
|
|
||||||
// IPv6 documentation.
|
|
||||||
"http://[2001:db8::1]/hook",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range targets {
|
|
||||||
t.Run(target, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
require.Error(t,
|
|
||||||
guard.ValidateTargetURL(context.Background(), target),
|
|
||||||
"%s must be refused at target creation", target,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertDialRefused(t, guard, target)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateTargetURL_Allowed(t *testing.T) {
|
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -276,9 +277,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. The
|
// newWriter builds the writer for a database target's archive. The
|
||||||
// file is the one ArchivePath gives for the webhook and the target as
|
// file lives beside the webhook's event database in the data
|
||||||
// the main database names them now; from then on only rename changes
|
// directory and is named for the webhook and the target as the main
|
||||||
// the name the writer uses. It does not touch the archive file.
|
// database has them now; from then on only rename changes the name
|
||||||
|
// the writer uses. It does not touch the archive file.
|
||||||
func (t *databaseTarget) newWriter(
|
func (t *databaseTarget) newWriter(
|
||||||
targetID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
@@ -297,10 +299,12 @@ func (t *databaseTarget) newWriter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := newArchiveWriter(
|
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
||||||
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
|
name := ArchiveFileName(
|
||||||
t.eng.log,
|
target.Webhook.Name, target.Name, target.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
||||||
w.webhookID = target.WebhookID
|
w.webhookID = target.WebhookID
|
||||||
|
|
||||||
return w, nil
|
return w, nil
|
||||||
|
|||||||
@@ -194,10 +194,6 @@ type archiveWriter struct {
|
|||||||
lastReopen time.Time
|
lastReopen time.Time
|
||||||
reopens int
|
reopens int
|
||||||
|
|
||||||
// now is the clock the reopen debounce is measured on. It is
|
|
||||||
// time.Now outside tests.
|
|
||||||
now func() time.Time
|
|
||||||
|
|
||||||
// evicted marks a writer that has been removed from the
|
// evicted marks a writer that has been removed from the
|
||||||
// registry. Its handle is closed and it must never open the
|
// registry. Its handle is closed and it must never open the
|
||||||
// file again: nothing holds it any more, so a reopen would
|
// file again: nothing holds it any more, so a reopen would
|
||||||
@@ -232,7 +228,6 @@ func newArchiveWriter(
|
|||||||
path: path,
|
path: path,
|
||||||
log: log,
|
log: log,
|
||||||
debounce: archiveReopenDebounce,
|
debounce: archiveReopenDebounce,
|
||||||
now: time.Now,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +263,7 @@ func (w *archiveWriter) write(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.now().Sub(w.lastReopen) >= w.debounce {
|
if time.Since(w.lastReopen) >= w.debounce {
|
||||||
return w.reopen(expiry)
|
return w.reopen(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +323,7 @@ func (w *archiveWriter) openMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.db = gdb
|
w.db = gdb
|
||||||
w.lastReopen = w.now()
|
w.lastReopen = time.Now()
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
if expiry > 0 {
|
if expiry > 0 {
|
||||||
@@ -412,9 +407,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
// creates it under the new name.
|
// creates it under the new name.
|
||||||
//
|
//
|
||||||
// If a file already has the new name, nothing is moved and the
|
// If a file already has the new name, nothing is moved and the
|
||||||
// error is ErrArchiveNameTaken. If one file fails to move, those
|
// error is ErrArchiveNameTaken.
|
||||||
// already moved are moved back before the error is returned, so the
|
|
||||||
// archive is never split across two names.
|
|
||||||
func (w *archiveWriter) rename(name string) error {
|
func (w *archiveWriter) rename(name string) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -442,27 +435,13 @@ func (w *archiveWriter) rename(name string) error {
|
|||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
for i, suffix := range suffixes {
|
for _, suffix := range suffixes {
|
||||||
err := os.Rename(w.path+suffix, path+suffix)
|
err := os.Rename(w.path+suffix, path+suffix)
|
||||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
return fmt.Errorf(
|
||||||
|
"renaming archive %s to %s: %w", w.path, path, err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, moved := range suffixes[:i] {
|
|
||||||
backErr := os.Rename(path+moved, w.path+moved)
|
|
||||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
|
||||||
w.log.Error(
|
|
||||||
"failed to move archive file back",
|
|
||||||
"from", path+moved,
|
|
||||||
"to", w.path+moved,
|
|
||||||
"error", backErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf(
|
|
||||||
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.path = path
|
w.path = path
|
||||||
@@ -515,47 +494,6 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
|
||||||
// file says about it.
|
|
||||||
type ArchiveFileInfo struct {
|
|
||||||
// Size is the bytes on disk of the file and its -wal together.
|
|
||||||
Size int64
|
|
||||||
|
|
||||||
// Written is when the file or its -wal was last modified, whichever
|
|
||||||
// is later: a write lands in the -wal first.
|
|
||||||
Written time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatArchive reads the metadata of the archive file at path and of
|
|
||||||
// its -wal, without opening the archive. With no file at path, which is
|
|
||||||
// so before the first write and after the operator moved it away, the
|
|
||||||
// error wraps fs.ErrNotExist.
|
|
||||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
|
||||||
|
|
||||||
wal, err := os.Stat(path + "-wal")
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Size += wal.Size()
|
|
||||||
|
|
||||||
if wal.ModTime().After(info.Written) {
|
|
||||||
info.Written = wal.ModTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileExists reports whether a path currently exists.
|
// fileExists reports whether a path currently exists.
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// archiveTableQuery counts the archive's table: 0 when the archive
|
|
||||||
// writer has created the file but not yet the table in it.
|
|
||||||
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
|
|
||||||
"WHERE type = 'table' AND name = 'archived_events'"
|
|
||||||
|
|
||||||
// ArchivePath returns where a database target's archive file is: in
|
|
||||||
// the data directory, beside the webhook's event database, under the
|
|
||||||
// name ArchiveFileName gives it.
|
|
||||||
func ArchivePath(
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
target *database.Target,
|
|
||||||
) string {
|
|
||||||
return filepath.Join(
|
|
||||||
filepath.Dir(dbMgr.DBPath(webhook.ID)),
|
|
||||||
ArchiveFileName(webhook.Name, target.Name, target.ID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveExportFileName returns the name a database target's archive
|
|
||||||
// downloads under:
|
|
||||||
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
|
|
||||||
// made safe as in ArchiveFileName and the time in UTC.
|
|
||||||
func ArchiveExportFileName(
|
|
||||||
webhookName, targetName string, at time.Time,
|
|
||||||
) string {
|
|
||||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
|
||||||
archiveNamePart(targetName) + "-" +
|
|
||||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveExport is a database target's archive opened for download.
|
|
||||||
// It reads the file on its own connection, inside one read-only
|
|
||||||
// transaction, so it writes out the archive as it stood when
|
|
||||||
// OpenArchiveExport returned.
|
|
||||||
//
|
|
||||||
// Archives are in WAL mode, where a reader works from a snapshot and
|
|
||||||
// never blocks a writer: archive writes go on while an export is open,
|
|
||||||
// and the export does not see them. SQLite cannot checkpoint the -wal
|
|
||||||
// past an open snapshot, so the -wal grows until the export is closed.
|
|
||||||
type ArchiveExport struct {
|
|
||||||
db *sql.DB
|
|
||||||
tx *gorm.DB
|
|
||||||
|
|
||||||
// empty is true when there is nothing to read: no file, or a file
|
|
||||||
// without the archive's table yet.
|
|
||||||
empty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedName is how an export names its webhook and its target.
|
|
||||||
type exportedName struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenArchiveExport opens the archive file at path for export and
|
|
||||||
// takes the snapshot the export reads. It never creates the file: with
|
|
||||||
// no file at path, the export has no rows.
|
|
||||||
//
|
|
||||||
// Once it has returned, the file is open, so a rename or a move of it
|
|
||||||
// does not affect the export, which reads the same file under its new
|
|
||||||
// name.
|
|
||||||
//
|
|
||||||
// The transaction lasts as long as ctx does, so ctx must last for the
|
|
||||||
// whole export.
|
|
||||||
func OpenArchiveExport(
|
|
||||||
ctx context.Context, path string, log *slog.Logger,
|
|
||||||
) (*ArchiveExport, error) {
|
|
||||||
if !fileExists(path) {
|
|
||||||
return &ArchiveExport{empty: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := database.OpenSQLite(path, archiveModeExisting)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: db}, &gorm.Config{
|
|
||||||
// Never leave this at GORM's default. See
|
|
||||||
// internal/gormlog.
|
|
||||||
Logger: gormlog.New(log),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadOnly makes the driver begin a deferred transaction in place
|
|
||||||
// of the BEGIN IMMEDIATE the connection string asks for, so the
|
|
||||||
// export never takes the archive's write lock.
|
|
||||||
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
|
|
||||||
if tx.Error != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The transaction's first read is what takes the snapshot.
|
|
||||||
var tables int
|
|
||||||
|
|
||||||
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
|
|
||||||
if err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
_ = db.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
|
||||||
// webhook and target, each an id and a name; exported_at; and
|
|
||||||
// archived_events, one object per archived row, keyed by column name.
|
|
||||||
// A body that is not valid UTF-8 cannot be a JSON string, so it is
|
|
||||||
// written in base64, with "body_encoding": "base64" beside it.
|
|
||||||
//
|
|
||||||
// Each row is written out before the next is read, so neither the
|
|
||||||
// archive nor its JSON is ever held in memory whole. After an error
|
|
||||||
// the gzip stream is left unfinished, so what was written does not
|
|
||||||
// decompress as a whole file.
|
|
||||||
func (x *ArchiveExport) WriteGzipJSON(
|
|
||||||
ctx context.Context,
|
|
||||||
w io.Writer,
|
|
||||||
webhook *database.Webhook,
|
|
||||||
target *database.Target,
|
|
||||||
exportedAt time.Time,
|
|
||||||
) error {
|
|
||||||
head, err := json.Marshal(map[string]any{
|
|
||||||
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
|
|
||||||
"target": exportedName{ID: target.ID, Name: target.Name},
|
|
||||||
"exported_at": exportedAt.UTC(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encoding archive export: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zw := gzip.NewWriter(w)
|
|
||||||
|
|
||||||
err = x.writeJSON(ctx, zw, head)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing archive export: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return zw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close ends the export's transaction and closes its connection.
|
|
||||||
func (x *ArchiveExport) Close() error {
|
|
||||||
if x.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = x.tx.Rollback()
|
|
||||||
|
|
||||||
return x.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSON writes head with archived_events added as its last key,
|
|
||||||
// the rows going into it one at a time.
|
|
||||||
func (x *ArchiveExport) writeJSON(
|
|
||||||
ctx context.Context, w io.Writer, head []byte,
|
|
||||||
) error {
|
|
||||||
// head goes out without its closing brace, so that
|
|
||||||
// archived_events can follow it.
|
|
||||||
_, err := w.Write(head[:len(head)-1])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, `,"archived_events":[`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = x.writeRows(ctx, w)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, "\n]}\n")
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRows writes each archived row to w, oldest first, one per line,
|
|
||||||
// separated by commas.
|
|
||||||
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
|
||||||
if x.empty {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := x.tx.WithContext(ctx).
|
|
||||||
Model(&archivedEvent{}).Order("id").Rows()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
for sep := "\n"; rows.Next(); sep = ",\n" {
|
|
||||||
var ev archivedEvent
|
|
||||||
|
|
||||||
err = x.tx.ScanRows(rows, &ev)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.WriteString(w, sep)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeRow(w, &ev)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeRow writes an archived row to w as a JSON object keyed by
|
|
||||||
// column name, its body in base64 when it is not valid UTF-8.
|
|
||||||
func writeRow(w io.Writer, ev *archivedEvent) error {
|
|
||||||
row := map[string]any{
|
|
||||||
"id": ev.ID,
|
|
||||||
"event_id": ev.EventID,
|
|
||||||
"webhook_id": ev.WebhookID,
|
|
||||||
"entrypoint_id": ev.EntrypointID,
|
|
||||||
"method": ev.Method,
|
|
||||||
"headers": ev.Headers,
|
|
||||||
"body": ev.Body,
|
|
||||||
"content_type": ev.ContentType,
|
|
||||||
"archived_at": ev.ArchivedAt.UTC(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !utf8.ValidString(ev.Body) {
|
|
||||||
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
|
|
||||||
row["body_encoding"] = "base64"
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := json.Marshal(row)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(line)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The webhook and the target the export tests' archives belong to.
|
|
||||||
const (
|
|
||||||
exportWebhookID = "wh-export"
|
|
||||||
exportWebhookName = "Orders (EU)"
|
|
||||||
exportTargetID = "tgt-export"
|
|
||||||
exportTargetName = "Long-term archive"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// binaryBody is a body that is not valid UTF-8.
|
|
||||||
binaryBody = "\xff\xfe\x00\x01binary\x80"
|
|
||||||
|
|
||||||
// openedEventID is the event the snapshot tests archive before
|
|
||||||
// they open the export.
|
|
||||||
openedEventID = "opened"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeExportTo writes export to w as the archive of the export tests'
|
|
||||||
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
|
||||||
func writeExportTo(
|
|
||||||
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
|
|
||||||
) error {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return export.WriteGzipJSON(
|
|
||||||
t.Context(), w,
|
|
||||||
&database.Webhook{
|
|
||||||
BaseModel: database.BaseModel{ID: exportWebhookID},
|
|
||||||
Name: exportWebhookName,
|
|
||||||
},
|
|
||||||
&database.Target{
|
|
||||||
BaseModel: database.BaseModel{ID: exportTargetID},
|
|
||||||
Name: exportTargetName,
|
|
||||||
},
|
|
||||||
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportArchive runs a whole export of the archive at path and returns
|
|
||||||
// its JSON, decompressed and parsed.
|
|
||||||
func exportArchive(t *testing.T, path string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
return writeExport(t, export)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeExport writes an opened export and returns its JSON,
|
|
||||||
// decompressed and parsed. Reading to the end makes the gzip reader
|
|
||||||
// check that the stream was finished.
|
|
||||||
func writeExport(
|
|
||||||
t *testing.T, export *delivery.ArchiveExport,
|
|
||||||
) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
require.NoError(t, writeExportTo(t, export, &buf))
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(&buf)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(zr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var got map[string]any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal(raw, &got))
|
|
||||||
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedEvents returns an export's archived_events.
|
|
||||||
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
list, ok := got["archived_events"].([]any)
|
|
||||||
require.True(t, ok, "archived_events must be an array: %v", got)
|
|
||||||
|
|
||||||
events := make([]map[string]any, len(list))
|
|
||||||
|
|
||||||
for i, v := range list {
|
|
||||||
events[i], ok = v.(map[string]any)
|
|
||||||
require.True(t, ok, "an archived event must be an object: %v", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportedEventIDs returns the event_id of each of an export's
|
|
||||||
// archived_events.
|
|
||||||
func exportedEventIDs(t *testing.T, got map[string]any) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := exportedEvents(t, got)
|
|
||||||
ids := make([]string, 0, len(events))
|
|
||||||
|
|
||||||
for _, ev := range events {
|
|
||||||
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_MatchesStoredRows proves an export holds the
|
|
||||||
// webhook, the target, the time, and every column of every stored
|
|
||||||
// row: a body that is valid UTF-8 as a string, and one that is not in
|
|
||||||
// base64, marked as such.
|
|
||||||
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
|
|
||||||
|
|
||||||
for i, body := range bodies {
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
|
||||||
EventID: fmt.Sprintf("ev-%d", i),
|
|
||||||
WebhookID: exportWebhookID,
|
|
||||||
EntrypointID: "ep-1",
|
|
||||||
Method: "POST",
|
|
||||||
Headers: `{"X-Test":["yes"]}`,
|
|
||||||
Body: body,
|
|
||||||
ContentType: testContentType,
|
|
||||||
}, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored []delivery.ExportArchivedEvent
|
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
|
||||||
Order("id").Find(&stored).Error)
|
|
||||||
|
|
||||||
got := exportArchive(t, path)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
|
|
||||||
got["webhook"],
|
|
||||||
)
|
|
||||||
assert.Equal(t,
|
|
||||||
map[string]any{"id": exportTargetID, "name": exportTargetName},
|
|
||||||
got["target"],
|
|
||||||
)
|
|
||||||
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
|
|
||||||
|
|
||||||
events := exportedEvents(t, got)
|
|
||||||
require.Len(t, events, len(bodies))
|
|
||||||
|
|
||||||
for i, row := range stored {
|
|
||||||
assertExportedRow(t, row, events[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertExportedRow checks that ev, from an export, holds every column
|
|
||||||
// of the stored row.
|
|
||||||
func assertExportedRow(
|
|
||||||
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
archivedAt, err := time.Parse(
|
|
||||||
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, archivedAt.Equal(row.ArchivedAt))
|
|
||||||
|
|
||||||
assert.EqualValues(t, row.ID, ev["id"])
|
|
||||||
assert.Equal(t, row.EventID, ev["event_id"])
|
|
||||||
assert.Equal(t, row.WebhookID, ev["webhook_id"])
|
|
||||||
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
|
|
||||||
assert.Equal(t, row.Method, ev["method"])
|
|
||||||
assert.Equal(t, row.Headers, ev["headers"])
|
|
||||||
assert.Equal(t, row.ContentType, ev["content_type"])
|
|
||||||
|
|
||||||
if row.Body != binaryBody {
|
|
||||||
assert.Equal(t, row.Body, ev["body"])
|
|
||||||
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, binaryBody, string(body))
|
|
||||||
assert.Equal(t, "base64", ev["body_encoding"])
|
|
||||||
assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_Empty proves an archive with nothing in it exports
|
|
||||||
// as an empty archived_events: no file, which the export must not
|
|
||||||
// create; a file the archive writer has not yet put its table in; and
|
|
||||||
// a table with no rows.
|
|
||||||
func TestArchiveExport_Empty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
missing := filepath.Join(dir, "missing.db")
|
|
||||||
noTable := filepath.Join(dir, "no-table.db")
|
|
||||||
noRows := filepath.Join(dir, "no-rows.db")
|
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
|
|
||||||
require.NoError(t,
|
|
||||||
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
|
||||||
Open(0),
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, path := range []string{missing, noTable, noRows} {
|
|
||||||
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, suffix := range archiveFileSuffixes() {
|
|
||||||
assert.NoFileExists(t, missing+suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
|
|
||||||
// archive as it was when it was opened, and holds up no archive
|
|
||||||
// write: a row written while the export is open is stored, and is not
|
|
||||||
// in the export. A write held up for the whole busy timeout would
|
|
||||||
// fail.
|
|
||||||
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
|
||||||
)
|
|
||||||
|
|
||||||
var stored int64
|
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
|
||||||
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
|
||||||
assert.Equal(t, int64(2), stored)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_SurvivesRename proves that renaming the archive
|
|
||||||
// while an export of it is open, as renaming its webhook or target
|
|
||||||
// does, leaves the export reading the same file.
|
|
||||||
func TestArchiveExport_SurvivesRename(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive-old.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
require.NoError(t, w.Rename("archive-new.db"))
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
|
|
||||||
require.NoFileExists(t, path)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// heapPeak is an io.Writer that discards what it is given and records
|
|
||||||
// the largest heap it saw at a write. It collects garbage before each
|
|
||||||
// reading, so the heap it reads is what is still held.
|
|
||||||
type heapPeak struct {
|
|
||||||
max uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *heapPeak) Write(b []byte) (int, error) {
|
|
||||||
var m runtime.MemStats
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
runtime.ReadMemStats(&m)
|
|
||||||
p.max = max(p.max, m.HeapAlloc)
|
|
||||||
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportHeapGrowth exports an archive of rows random bodies, each
|
|
||||||
// bodySize bytes of base64, and returns how far the heap rose above
|
|
||||||
// where it stood when the export began, at its highest.
|
|
||||||
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
|
|
||||||
// Base64 makes four characters of every three bytes.
|
|
||||||
random := make([]byte, bodySize/4*3)
|
|
||||||
|
|
||||||
for range rows {
|
|
||||||
_, _ = rand.Read(random)
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
|
||||||
Body: base64.StdEncoding.EncodeToString(random),
|
|
||||||
}, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
|
|
||||||
var start runtime.MemStats
|
|
||||||
|
|
||||||
runtime.ReadMemStats(&start)
|
|
||||||
|
|
||||||
// Through a buffer, the heap is read once per 8 KiB of output
|
|
||||||
// rather than at each of gzip's small writes, which takes far
|
|
||||||
// longer.
|
|
||||||
peak := &heapPeak{max: start.HeapAlloc}
|
|
||||||
buffered := bufio.NewWriterSize(peak, 8<<10)
|
|
||||||
|
|
||||||
require.NoError(t, writeExportTo(t, export, buffered))
|
|
||||||
require.NoError(t, buffered.Flush())
|
|
||||||
|
|
||||||
return peak.max - start.HeapAlloc
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExport_Streams proves an export holds neither the archive
|
|
||||||
// nor its output in memory whole: exporting 384 KiB more of archive
|
|
||||||
// raises the heap's peak by less than half of that. The export's own
|
|
||||||
// memory, mostly gzip's compressor, is the same for both archives, so
|
|
||||||
// it cancels out. The bodies are random bytes in base64, which gzip
|
|
||||||
// shrinks by only a quarter, so an export that read every row before
|
|
||||||
// writing, or built the JSON or the gzipped file before writing it,
|
|
||||||
// would raise the peak by at least three quarters of the difference.
|
|
||||||
//
|
|
||||||
// The smaller archive has two rows so that its export, too, writes
|
|
||||||
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
|
|
||||||
// the heap must be read while the export's own memory is held.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // It measures the heap, which tests share.
|
|
||||||
func TestArchiveExport_Streams(t *testing.T) {
|
|
||||||
const (
|
|
||||||
bodySize = 16 << 10
|
|
||||||
smallRows = 2
|
|
||||||
largeRows = smallRows + 24
|
|
||||||
limit = (largeRows - smallRows) * bodySize / 2
|
|
||||||
)
|
|
||||||
|
|
||||||
small := exportHeapGrowth(t, smallRows, bodySize)
|
|
||||||
large := exportHeapGrowth(t, largeRows, bodySize)
|
|
||||||
|
|
||||||
assert.Less(t, large, small+limit,
|
|
||||||
"the heap rose by %d for %d rows and by %d for %d rows",
|
|
||||||
small, smallRows, large, largeRows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveExportFileName proves the download is named for the
|
|
||||||
// webhook and the target, with the names made safe as for the archive
|
|
||||||
// file, and the export time in UTC.
|
|
||||||
func TestArchiveExportFileName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
|
|
||||||
delivery.ArchiveExportFileName(
|
|
||||||
exportWebhookName, exportTargetName,
|
|
||||||
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,7 +17,6 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func archiveTestLogger() *slog.Logger {
|
func archiveTestLogger() *slog.Logger {
|
||||||
@@ -44,8 +42,7 @@ func openArchiveDBForRead(
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -184,66 +181,16 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
|||||||
assert.Equal(t, "b", got[0].EventID)
|
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, 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) {
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const debounce = 2 * time.Second
|
// A generous debounce keeps the two rapid writes inside
|
||||||
|
// the window even on a heavily loaded test machine.
|
||||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||||
w := delivery.NewExportArchiveWriter(
|
w := delivery.NewExportArchiveWriter(
|
||||||
path, archiveTestLogger(), debounce,
|
path, archiveTestLogger(), 2*time.Second,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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(
|
require.NoError(t, w.Write(
|
||||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||||
))
|
))
|
||||||
@@ -255,7 +202,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
|||||||
// initial open — no extra close/reopen.
|
// initial open — no extra close/reopen.
|
||||||
assert.Equal(t, 1, w.Reopens())
|
assert.Equal(t, 1, w.Reopens())
|
||||||
|
|
||||||
now = now.Add(debounce)
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
require.NoError(t, w.Write(
|
require.NoError(t, w.Write(
|
||||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||||
@@ -688,35 +635,3 @@ func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, newPath, w.Path())
|
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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -179,27 +179,6 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
|
|
||||||
// target URL that no request can be built from.
|
|
||||||
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
|
|
||||||
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
|
|
||||||
context.TODO(),
|
|
||||||
&delivery.HTTPTargetConfig{
|
|
||||||
URL: "https://hooks.example.com" + maskSecretPath + "\n",
|
|
||||||
},
|
|
||||||
&database.Event{},
|
|
||||||
)
|
|
||||||
require.Error(t, reqErr)
|
|
||||||
assert.Zero(t, statusCode)
|
|
||||||
|
|
||||||
assertNoCredential(t, reqErr.Error())
|
|
||||||
assert.Contains(t, reqErr.Error(), "invalid control character")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
||||||
// validator's error does not carry the submitted URL, which
|
// validator's error does not carry the submitted URL, which
|
||||||
// the handler both logs and shows.
|
// the handler both logs and shows.
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ func readFirstBootSecrets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bootAtDebug starts and stops the real application graph against
|
// bootAtDebug starts and stops the real application graph against
|
||||||
// dataDir with DEBUG=true and nothing else set, and returns everything
|
// dataDir with DEBUG=true, and returns everything it wrote to standard
|
||||||
// it wrote to standard output.
|
// output.
|
||||||
//
|
//
|
||||||
// config.New reads DEBUG from the environment exactly as the binary
|
// config.New reads DEBUG from the environment exactly as the binary
|
||||||
// does, internal/logger builds the handler it builds in production,
|
// does, internal/logger builds the handler it builds in production,
|
||||||
@@ -128,7 +128,6 @@ func readFirstBootSecrets(
|
|||||||
func bootAtDebug(t *testing.T, dataDir string) string {
|
func bootAtDebug(t *testing.T, dataDir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
config.ClearEnvForTest(t)
|
|
||||||
t.Setenv("DEBUG", "true")
|
t.Setenv("DEBUG", "true")
|
||||||
t.Setenv("DATA_DIR", dataDir)
|
t.Setenv("DATA_DIR", dataDir)
|
||||||
|
|
||||||
@@ -138,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
|
|||||||
//
|
//
|
||||||
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
||||||
// statement through gorm's own traceRecorder, which does not implement
|
// statement through gorm's own traceRecorder, which does not implement
|
||||||
// this interface. No production code path calls it; only tests do, and
|
// this interface. No production code path calls it; its one caller is
|
||||||
// what a test binds is fixture data. scan_guard_test.go fails if a
|
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
|
||||||
// non-test file calls it.
|
// scan_guard_test.go fails if a non-test file calls it.
|
||||||
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
||||||
// processor and are filtered.
|
// processor and are filtered.
|
||||||
func (l *Logger) ParamsFilter(
|
func (l *Logger) ParamsFilter(
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isRowProducer reports whether name is GORM's Row or database/sql's
|
// minNonTestFiles guards the walk below against passing because it
|
||||||
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
|
// found nothing to look at. The tree held 60 non-test .go files when
|
||||||
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
|
// this was written.
|
||||||
// it also returns an error, so Scan is never called on its result
|
const minNonTestFiles = 40
|
||||||
// directly. It matches the method name only and resolves no types, so
|
|
||||||
// a repo-local method with one of these names that returns *gorm.DB
|
// isRowProducer reports whether name is a method that returns a
|
||||||
// gets past it: Scan on that method's result is not reported.
|
// database/sql row handle. GORM's Row and Rows return *sql.Row and
|
||||||
|
// *sql.Rows, so Scan on the result of one of them is database/sql's
|
||||||
|
// Scan and never (*gorm.DB).Scan.
|
||||||
func isRowProducer(name string) bool {
|
func isRowProducer(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "Row", "QueryRow", "QueryRowContext":
|
case "Row", "Rows", "QueryRow", "QueryRowContext":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -48,14 +50,9 @@ func receiverIsRowHandle(x ast.Expr) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unguardedScans returns the position of every Scan call in file whose
|
// unguardedScans returns the position of every Scan call in file whose
|
||||||
// receiver is not a call to a row producer. It fails closed: any other
|
// receiver is not a row handle. It fails closed: a receiver it cannot
|
||||||
// receiver — a local variable, a struct field, a call to any other
|
// resolve syntactically — a local variable, a struct field — is
|
||||||
// method — is reported rather than assumed safe.
|
// reported rather than assumed safe.
|
||||||
//
|
|
||||||
// It sees only calls written x.Scan(...). A method value, f := db.Scan
|
|
||||||
// followed by f(&v), is out of scope: Scan is never the called
|
|
||||||
// expression there, and nobody writes a query that way by accident,
|
|
||||||
// which is the mistake this check exists to catch.
|
|
||||||
func unguardedScans(
|
func unguardedScans(
|
||||||
fset *token.FileSet, file *ast.File,
|
fset *token.FileSet, file *ast.File,
|
||||||
) []token.Position {
|
) []token.Position {
|
||||||
@@ -114,15 +111,15 @@ func skipDir(name string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkNonTestGo parses every non-test .go file under root. It returns
|
// walkNonTestGo parses every non-test .go file under root and returns
|
||||||
// the directories, relative to root, it parsed a file in, along with
|
// how many it parsed along with every unguarded Scan it found.
|
||||||
// every unguarded Scan it found.
|
func walkNonTestGo(t *testing.T, root string) (int, []string) {
|
||||||
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
walked := map[string]bool{}
|
var (
|
||||||
|
parsed int
|
||||||
var hits []string
|
hits []string
|
||||||
|
)
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
|
|
||||||
@@ -150,12 +147,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
dir, err := filepath.Rel(root, filepath.Dir(path))
|
parsed++
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
walked[dir] = true
|
|
||||||
|
|
||||||
for _, pos := range unguardedScans(fset, file) {
|
for _, pos := range unguardedScans(fset, file) {
|
||||||
hits = append(hits, relPosition(root, pos))
|
hits = append(hits, relPosition(root, pos))
|
||||||
@@ -165,7 +157,7 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
return walked, hits
|
return parsed, hits
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNonTestGo reports whether a file name is Go source this check
|
// isNonTestGo reports whether a file name is Go source this check
|
||||||
@@ -197,39 +189,19 @@ func relPosition(root string, pos token.Position) string {
|
|||||||
// logged with its values interpolated. The package comment states the
|
// logged with its values interpolated. The package comment states the
|
||||||
// limit; this fails when someone adds a call site anyway.
|
// limit; this fails when someone adds a call site anyway.
|
||||||
//
|
//
|
||||||
// Test files are not governed: what a test binds is fixture data.
|
// The current tree has one caller, internal/database/database_test.go,
|
||||||
|
// which this check does not govern: it is test-only and its SELECT 1
|
||||||
|
// binds nothing.
|
||||||
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
root := moduleRoot(t)
|
parsed, offenders := walkNonTestGo(t, moduleRoot(t))
|
||||||
walked, offenders := walkNonTestGo(t, root)
|
|
||||||
|
|
||||||
// The module's packages are static, templates, and every directory
|
|
||||||
// directly under cmd and internal. Each holds non-test code, so one
|
|
||||||
// the walk parsed nothing in was skipped, and a Scan there would
|
|
||||||
// pass unseen.
|
|
||||||
packages := []string{"static", "templates"}
|
|
||||||
|
|
||||||
for _, parent := range []string{"cmd", "internal"} {
|
|
||||||
entries, err := os.ReadDir(filepath.Join(root, parent))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
packages = append(packages, filepath.Join(parent, entry.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dir := range packages {
|
|
||||||
require.True(
|
|
||||||
t, walked[dir],
|
|
||||||
"the walk parsed no non-test .go file in %s", dir,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
require.GreaterOrEqual(
|
||||||
|
t, parsed, minNonTestFiles,
|
||||||
|
"parsed %d non-test .go files, so this check found "+
|
||||||
|
"nothing to look at", parsed,
|
||||||
|
)
|
||||||
require.Empty(
|
require.Empty(
|
||||||
t, offenders,
|
t, offenders,
|
||||||
"Scan called on a receiver this check cannot show is a "+
|
"Scan called on a receiver this check cannot show is a "+
|
||||||
@@ -250,51 +222,18 @@ type scanGuardCase struct {
|
|||||||
want int
|
want int
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanGuardCases covers each receiver form unguardedScans names, plus
|
|
||||||
// each row producer isRowProducer lets through. Each body is valid Go
|
|
||||||
// inside plantedFile.
|
|
||||||
func scanGuardCases() []scanGuardCase {
|
func scanGuardCases() []scanGuardCase {
|
||||||
return []scanGuardCase{
|
return []scanGuardCase{
|
||||||
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
|
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1},
|
||||||
{"struct field", `s.db.Scan(&v)`, 1},
|
{"gorm receiver", `gdb.Scan(&v)`, 1},
|
||||||
{"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
|
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1},
|
||||||
{
|
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1},
|
||||||
"sql rows in a variable",
|
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
||||||
"rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
|
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0},
|
||||||
1,
|
|
||||||
},
|
|
||||||
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
|
||||||
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
|
|
||||||
{
|
|
||||||
"sql QueryRowContext",
|
|
||||||
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
|
|
||||||
0,
|
|
||||||
},
|
|
||||||
{"unrelated call", `gdb.Find(&v)`, 0},
|
{"unrelated call", `gdb.Find(&v)`, 0},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plantedFile wraps one case body in a function that declares every
|
|
||||||
// name the bodies use, so each body is the Go it stands for. The result
|
|
||||||
// is parsed, never compiled.
|
|
||||||
const plantedFile = `package p
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type store struct{ db *gorm.DB }
|
|
||||||
|
|
||||||
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
|
|
||||||
var v int
|
|
||||||
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
|
||||||
// a detector that matched nothing would satisfy the walk above no
|
// a detector that matched nothing would satisfy the walk above no
|
||||||
// matter what the tree contained.
|
// matter what the tree contained.
|
||||||
@@ -306,7 +245,9 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
src := fmt.Sprintf(plantedFile, tc.body)
|
src := fmt.Sprintf(
|
||||||
|
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
|
||||||
|
)
|
||||||
|
|
||||||
file, err := parser.ParseFile(
|
file, err := parser.ParseFile(
|
||||||
fset, tc.name+".go", src, 0,
|
fset, tc.name+".go", src, 0,
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ func (h *Handlers) renderLoginError(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, "login.html", data, status)
|
w.WriteHeader(status)
|
||||||
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticateUser looks up and verifies a user's credentials.
|
// authenticateUser looks up and verifies a user's credentials.
|
||||||
@@ -332,9 +333,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
// Redirect to login page
|
||||||
w, r, withNotice("/pages/login", signedOut),
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -405,60 +404,6 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
|
|
||||||
// form shown again with an error still answers 400 with the whole page.
|
|
||||||
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "Username and password are required",
|
|
||||||
)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "</html>",
|
|
||||||
"the page must render to completion",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
|
|
||||||
// error page whose template fails answers 500 with the error page and
|
|
||||||
// none of the form page, rather than the 400 it meant to send.
|
|
||||||
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// The page prints its error message and then fails.
|
|
||||||
h.AddTemplateForTest("login.html", template.Must(
|
|
||||||
template.New("login").Funcs(template.FuncMap{
|
|
||||||
"fail": func() (string, error) { return "", errMidRender },
|
|
||||||
}).Parse(`{{.Error}}{{fail}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
w := submitLogin(h, sharedProxyPeer, "", "")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.NotContains(
|
|
||||||
t, w.Body.String(), "Username and password are required",
|
|
||||||
"the response must carry no part of the aborted page",
|
|
||||||
)
|
|
||||||
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogin_SuccessCreatesSession is the control for the tests above:
|
// TestLogin_SuccessCreatesSession is the control for the tests above:
|
||||||
// the success path they assert on really does authenticate.
|
// the success path they assert on really does authenticate.
|
||||||
func TestLogin_SuccessCreatesSession(t *testing.T) {
|
func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||||
|
|||||||
@@ -11,37 +11,72 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The outcomes of a replay POST, as the notice codes its redirect
|
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||||
// carries. noticeFor holds the line each one shows.
|
// with and the event log page reads its banner from.
|
||||||
|
const replayOutcomeParam = "replay"
|
||||||
|
|
||||||
|
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||||
|
// carries one of these fixed codes rather than a message, so nothing a
|
||||||
|
// client submits can reach the rendered page through it.
|
||||||
|
type replayOutcomeCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// replayQueued reports that a new delivery was created and handed
|
// replayQueued reports that a new delivery was created and handed
|
||||||
// to the delivery engine.
|
// to the delivery engine.
|
||||||
replayQueued noticeCode = "replay-queued"
|
replayQueued replayOutcomeCode = "queued"
|
||||||
|
|
||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event.
|
// target and this is the ordinary case for an old event.
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
// all, deleted or otherwise.
|
// all, deleted or otherwise.
|
||||||
replayTargetMissing noticeCode = "replay-target-missing"
|
replayTargetMissing replayOutcomeCode = "target-missing"
|
||||||
|
|
||||||
// replayTargetInactive reports a target the operator has
|
// replayTargetInactive reports a target the operator has
|
||||||
// deactivated. A deactivated target receives no new deliveries, so
|
// deactivated. A deactivated target receives no new deliveries, so
|
||||||
// a replay to it would be a delivery they switched off.
|
// a replay to it would be a delivery they switched off.
|
||||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
replayTargetInactive replayOutcomeCode = "target-inactive"
|
||||||
|
|
||||||
// replayNotTerminal reports a delivery the engine has not finished
|
// replayNotTerminal reports a delivery the engine has not finished
|
||||||
// with.
|
// with.
|
||||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||||
|
|
||||||
// replayInFlight reports that an earlier replay of this event to
|
// replayInFlight reports that an earlier replay of this event to
|
||||||
// this target is still running.
|
// this target is still running.
|
||||||
replayInFlight noticeCode = "replay-in-flight"
|
replayInFlight replayOutcomeCode = "in-flight"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// replayOutcome returns the banner the event log page shows for an
|
||||||
|
// outcome code, and whether the replay was queued. An unrecognised
|
||||||
|
// code yields no banner.
|
||||||
|
func replayOutcome(code string) (string, bool) {
|
||||||
|
switch replayOutcomeCode(code) {
|
||||||
|
case replayQueued:
|
||||||
|
return "Replay queued: a new delivery was created against " +
|
||||||
|
"the target's current configuration.", true
|
||||||
|
case replayTargetDeleted:
|
||||||
|
return "Not replayed: the target this delivery was for has " +
|
||||||
|
"been deleted. Recreate the target, then replay.", false
|
||||||
|
case replayTargetMissing:
|
||||||
|
return "Not replayed: the target this delivery was for no " +
|
||||||
|
"longer exists.", false
|
||||||
|
case replayTargetInactive:
|
||||||
|
return "Not replayed: the target this delivery was for is " +
|
||||||
|
"deactivated. Activate it, then replay.", false
|
||||||
|
case replayNotTerminal:
|
||||||
|
return "Not replayed: this delivery has not finished yet.",
|
||||||
|
false
|
||||||
|
case replayInFlight:
|
||||||
|
return "Not replayed: a delivery of this event to this " +
|
||||||
|
"target is already in flight.", false
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||||
// target.
|
// target.
|
||||||
//
|
//
|
||||||
@@ -105,14 +140,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !original.Status.Terminal() {
|
if !original.Status.Terminal() {
|
||||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||||
if target == nil {
|
if target == nil {
|
||||||
redirectToEventLog(w, r, webhook, code)
|
h.finishReplay(w, r, webhook, code)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,7 +200,7 @@ func (h *Handlers) queueReplay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inFlight > 0 {
|
if inFlight > 0 {
|
||||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
h.finishReplay(w, r, webhook, replayInFlight)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,7 +238,7 @@ func (h *Handlers) queueReplay(
|
|||||||
"delivery_id", task.DeliveryID,
|
"delivery_id", task.DeliveryID,
|
||||||
)
|
)
|
||||||
|
|
||||||
redirectToEventLog(w, r, webhook, replayQueued)
|
h.finishReplay(w, r, webhook, replayQueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayTarget loads the delivery's target as it stands now.
|
// replayTarget loads the delivery's target as it stands now.
|
||||||
@@ -216,7 +251,7 @@ func (h *Handlers) queueReplay(
|
|||||||
// with the returned code saying why.
|
// with the returned code saying why.
|
||||||
func (h *Handlers) replayTarget(
|
func (h *Handlers) replayTarget(
|
||||||
webhookID, targetID string,
|
webhookID, targetID string,
|
||||||
) (*database.Target, noticeCode) {
|
) (*database.Target, replayOutcomeCode) {
|
||||||
var target database.Target
|
var target database.Target
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().Where(
|
err := h.db.DB().Unscoped().Where(
|
||||||
@@ -326,16 +361,17 @@ func replayBody(body string) *string {
|
|||||||
return &body
|
return &body
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// finishReplay redirects back to the event log the replay was
|
||||||
// log it was triggered from, carrying the outcome as its notice and
|
// triggered from, carrying the outcome code the page turns into a
|
||||||
// the page number the form submitted.
|
// banner and the page number the form submitted.
|
||||||
func redirectToEventLog(
|
func (h *Handlers) finishReplay(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code replayOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
|
replayOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
// this is a POST, and its query string is what logs and Referer
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
|
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
|
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
|
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.Contains(t, body, ">Replay<")
|
assert.Contains(t, body, ">Replay<")
|
||||||
|
|
||||||
refused := renderSourceLogsPageWithQuery(
|
refused := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
t, h, sess, wh.ID, "?replay=target-deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?notice=made-up",
|
t, h, sess, wh.ID, "?replay=made-up",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NotContains(t, unknown, "alert-error")
|
assert.NotContains(t, unknown, "alert-error")
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ import (
|
|||||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||||
// to blob is what makes the driver hand back the stored bytes
|
// to blob is what makes the driver hand back the stored bytes
|
||||||
// rather than a string conversion, so Content-Length taken from
|
// rather than a string conversion, so Content-Length taken from
|
||||||
// the result matches what goes on the wire. The retention reaper
|
// the result matches what goes on the wire. The soft-delete
|
||||||
// deletes event rows outright, so a reaped event is simply gone
|
// predicate is spelled out because Raw bypasses GORM's default
|
||||||
// and the query finds no row. The deleted_at predicate repeats
|
// scope, and it is what stops a reaped event still being
|
||||||
// the soft-delete scope GORM adds to its own queries, which Raw
|
// downloadable.
|
||||||
// bypasses; nothing soft-deletes an event, so today it excludes
|
|
||||||
// nothing.
|
|
||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
|
|||||||
@@ -405,11 +405,10 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
|||||||
// route. The body is read in one query before any header is
|
// route. The body is read in one query before any header is
|
||||||
// written, so a reaped event cannot produce a partial download:
|
// written, so a reaped event cannot produce a partial download:
|
||||||
// it is a clean 404 with no Content-Length and no
|
// it is a clean 404 with no Content-Length and no
|
||||||
// Content-Disposition. The reaper deletes event rows outright,
|
// Content-Disposition. Both removals the codebase performs are
|
||||||
// which is the "hard deleted" case. The "soft deleted" case
|
// covered — the reaper hard-deletes, and a soft-deleted row is
|
||||||
// covers a row no code produces today: it only pins the query's
|
// excluded by the query's own deleted_at predicate rather than
|
||||||
// own deleted_at predicate, the soft-delete condition Raw would
|
// by GORM's default scope, which Raw bypasses.
|
||||||
// otherwise skip.
|
|
||||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -10,19 +11,43 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The outcomes of a resubmit POST, as the notice codes its redirect
|
// resubmitOutcomeParam is the query parameter the resubmit POST
|
||||||
// carries. noticeFor holds the line each one shows.
|
// redirects with and the event log page reads its banner from.
|
||||||
|
const resubmitOutcomeParam = "resubmit"
|
||||||
|
|
||||||
|
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
||||||
|
// carries one of these fixed codes rather than a message, so nothing a
|
||||||
|
// client submits can reach the rendered page through it.
|
||||||
|
type resubmitOutcomeCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// resubmitQueued reports that a new event was stored and its
|
// resubmitQueued reports that a new event was stored and its
|
||||||
// deliveries handed to the delivery engine.
|
// deliveries handed to the delivery engine.
|
||||||
resubmitQueued noticeCode = "resubmit-queued"
|
resubmitQueued resubmitOutcomeCode = "queued"
|
||||||
|
|
||||||
// resubmitNoTargets reports a source with no active targets. The
|
// resubmitNoTargets reports a source with no active targets. The
|
||||||
// new event is stored either way, exactly as a received event
|
// new event is stored either way, exactly as a received event
|
||||||
// with no targets is.
|
// with no targets is.
|
||||||
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resubmitOutcome returns the banner the event log page shows for an
|
||||||
|
// outcome code, and whether the resubmit was queued. An unrecognised
|
||||||
|
// code yields no banner.
|
||||||
|
func resubmitOutcome(code string) (string, bool) {
|
||||||
|
switch resubmitOutcomeCode(code) {
|
||||||
|
case resubmitQueued:
|
||||||
|
return "Resubmitted: a new event was created from the stored " +
|
||||||
|
"one and queued to every active target.", true
|
||||||
|
case resubmitNoTargets:
|
||||||
|
return "Resubmitted: a new event was created, but this " +
|
||||||
|
"source has no active targets, so nothing was queued.",
|
||||||
|
true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||||
// read as bytes rather than as a string so the copy is byte-identical
|
// read as bytes rather than as a string so the copy is byte-identical
|
||||||
// to what was received, whatever the payload's encoding.
|
// to what was received, whatever the payload's encoding.
|
||||||
@@ -145,9 +170,8 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// per-webhook database files — a sibling webhook's event is not in the
|
// per-webhook database files — a sibling webhook's event is not in the
|
||||||
// database being queried at all — and is there so the scoping survives
|
// database being queried at all — and is there so the scoping survives
|
||||||
// any future change that puts more than one webhook's events in one
|
// any future change that puts more than one webhook's events in one
|
||||||
// file. A reaped event is not found because the retention reaper
|
// file. Going through Model applies GORM's soft-delete scope, which is
|
||||||
// deletes its row outright rather than marking it deleted; see
|
// what stops a reaped event being resubmitted.
|
||||||
// deleteEvents in internal/database/retention.go.
|
|
||||||
func loadResubmitSource(
|
func loadResubmitSource(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
webhookID, eventID string,
|
webhookID, eventID string,
|
||||||
@@ -221,5 +245,29 @@ func (h *Handlers) queueResubmit(
|
|||||||
code = resubmitNoTargets
|
code = resubmitNoTargets
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToEventLog(w, r, webhook, code)
|
h.finishResubmit(w, r, webhook, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishResubmit redirects back to the event log the resubmit was
|
||||||
|
// triggered from, carrying the outcome code the page turns into a
|
||||||
|
// banner and the page number the form submitted.
|
||||||
|
func (h *Handlers) finishResubmit(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
code resubmitOutcomeCode,
|
||||||
|
) {
|
||||||
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
|
resubmitOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
|
// The page is read from the form rather than the query string:
|
||||||
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
// headers record.
|
||||||
|
if page := pageOrFirst(
|
||||||
|
r.PostFormValue("page"),
|
||||||
|
); page > 1 {
|
||||||
|
dest += "&page=" + strconv.Itoa(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,6 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
|
|||||||
// the handlers enforce rather than a number copied beside it.
|
// the handlers enforce rather than a number copied beside it.
|
||||||
const MaxTargetRetriesForTest = maxTargetRetries
|
const MaxTargetRetriesForTest = maxTargetRetries
|
||||||
|
|
||||||
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
|
|
||||||
// messages the webhook delete handler logs when a file of the event
|
|
||||||
// database is left on disk, so a test checking that one is absent
|
|
||||||
// checks for the handler's own wording.
|
|
||||||
const (
|
|
||||||
EventDBLeftMsgForTest = eventDBLeftMsg
|
|
||||||
SidecarLeftMsgForTest = sidecarLeftMsg
|
|
||||||
)
|
|
||||||
|
|
||||||
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func PageOrFirstForTest(s string) int {
|
func PageOrFirstForTest(s string) int {
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ 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/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"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -59,7 +57,6 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -92,15 +89,6 @@ 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 opens the file they give.
|
|
||||||
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.
|
||||||
@@ -109,10 +97,10 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, navbar and notice templates, and with any further
|
// base, htmlheader, and navbar templates, and with any further files
|
||||||
// files the page includes. The page file must be listed first so that
|
// the page includes. The page file must be listed first so that its
|
||||||
// its root action ({{template "base" .}}) becomes the template set's
|
// root action ({{template "base" .}}) becomes the template set's entry
|
||||||
// entry point.
|
// point.
|
||||||
func parsePageTemplate(
|
func parsePageTemplate(
|
||||||
pageFile string, included ...string,
|
pageFile string, included ...string,
|
||||||
) *template.Template {
|
) *template.Template {
|
||||||
@@ -121,7 +109,6 @@ func parsePageTemplate(
|
|||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
"notice.html",
|
|
||||||
}, included...)
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
@@ -152,7 +139,6 @@ func New(
|
|||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"settings.html": parsePageTemplate("settings.html"),
|
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
@@ -223,13 +209,11 @@ func (s *Handlers) renderError(
|
|||||||
// served outside the routes where NoCache runs.
|
// served outside the routes where NoCache runs.
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
// No notice: one would say an action worked above a page saying
|
|
||||||
// the request failed.
|
|
||||||
data := s.pageData(r, map[string]any{
|
data := s.pageData(r, map[string]any{
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"StatusText": http.StatusText(status),
|
"StatusText": http.StatusText(status),
|
||||||
"Message": errorPageText(status),
|
"Message": errorPageText(status),
|
||||||
}, nil)
|
})
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
@@ -283,7 +267,6 @@ type templateDataWrapper struct {
|
|||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Version string
|
Version string
|
||||||
Notice *notice
|
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,26 +293,12 @@ func (s *Handlers) getUserInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a pre-parsed template with common
|
// renderTemplate renders a pre-parsed template with common
|
||||||
// data and answers 200.
|
// data
|
||||||
func (s *Handlers) renderTemplate(
|
func (s *Handlers) renderTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
pageTemplate string,
|
pageTemplate string,
|
||||||
data any,
|
data any,
|
||||||
) {
|
|
||||||
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTemplateStatus is renderTemplate answering with status, for a
|
|
||||||
// form shown again with an error. Call it instead of WriteHeader
|
|
||||||
// followed by renderTemplate: the status is written only once the page
|
|
||||||
// has rendered, so a failed render can still answer 500.
|
|
||||||
func (s *Handlers) renderTemplateStatus(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
pageTemplate string,
|
|
||||||
data any,
|
|
||||||
status int,
|
|
||||||
) {
|
) {
|
||||||
tmpl, ok := s.templates[pageTemplate]
|
tmpl, ok := s.templates[pageTemplate]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -342,17 +311,12 @@ func (s *Handlers) renderTemplateStatus(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(
|
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
||||||
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageData adds the fields the shared layout renders to a page's own
|
// pageData adds the fields the shared layout renders to a page's own
|
||||||
// data. The layout shows the notice, when there is one, above the
|
// data.
|
||||||
// page.
|
func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||||
func (s *Handlers) pageData(
|
|
||||||
r *http.Request, data any, pageNotice *notice,
|
|
||||||
) any {
|
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -366,7 +330,6 @@ func (s *Handlers) pageData(
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
m["Notice"] = pageNotice
|
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -375,25 +338,23 @@ func (s *Handlers) pageData(
|
|||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Notice: pageNotice,
|
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes status
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
// and the page to the response only once rendering has fully
|
// the response only once rendering has fully succeeded. Executing
|
||||||
// succeeded. Executing straight into the ResponseWriter commits a
|
// straight into the ResponseWriter commits a partial body and a 200
|
||||||
// partial body and the status before a mid-render error can be
|
// status before a mid-render error can be reported, leaving no way
|
||||||
// reported, leaving no way to serve a 500. Buffering makes a page's
|
// to serve a 500. Buffering makes a page's rendered size resident
|
||||||
// rendered size resident memory per concurrent viewer, so every page
|
// memory per concurrent viewer, so every page owes it a bound: the
|
||||||
// owes it a bound: the event log caps each stored body at
|
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
||||||
// maxRenderedBodyBytes for exactly this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
status int,
|
|
||||||
) {
|
) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
@@ -408,7 +369,6 @@ func (s *Handlers) executeTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
|
||||||
|
|
||||||
_, err = buf.WriteTo(w)
|
_, err = buf.WriteTo(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -56,16 +56,13 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
|
|||||||
// recordingArchives is a delivery.Archives that records what it
|
// 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 delivery engine. After FailRenames, every
|
// path reached the delivery engine. After FailRenames, every
|
||||||
// rename of that target fails with the given error. After
|
// rename fails with the given error.
|
||||||
// BlockNextRename, the next rename is recorded and then waits.
|
|
||||||
type recordingArchives struct {
|
type recordingArchives struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
evicted []string
|
evicted []string
|
||||||
evictedTargets []string
|
evictedTargets []string
|
||||||
renames []archiveRename
|
renames []archiveRename
|
||||||
renameErrs map[string]error
|
renameErr error
|
||||||
entered chan struct{}
|
|
||||||
release chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// errInjectedRename is the failure a test hands FailRenames.
|
// errInjectedRename is the failure a test hands FailRenames.
|
||||||
@@ -102,50 +99,23 @@ func (r *recordingArchives) Rename(
|
|||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
r.renames = append(r.renames, archiveRename{
|
r.renames = append(r.renames, archiveRename{
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
WebhookName: webhookName,
|
WebhookName: webhookName,
|
||||||
TargetName: targetName,
|
TargetName: targetName,
|
||||||
})
|
})
|
||||||
err := r.renameErrs[targetID]
|
|
||||||
entered, release := r.entered, r.release
|
|
||||||
r.entered, r.release = nil, nil
|
|
||||||
|
|
||||||
r.mu.Unlock()
|
return r.renameErr
|
||||||
|
|
||||||
if entered != nil {
|
|
||||||
close(entered)
|
|
||||||
<-release
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockNextRename makes the next rename, once recorded, wait until
|
// FailRenames makes every later rename fail with err.
|
||||||
// the returned release is called. The returned channel is closed
|
func (r *recordingArchives) FailRenames(err error) {
|
||||||
// when that rename starts waiting.
|
|
||||||
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
|
|
||||||
entered := make(chan struct{})
|
|
||||||
release := make(chan struct{})
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
r.entered, r.release = entered, release
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
return entered, func() { close(release) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailRenames makes every later rename of targetID fail with err.
|
|
||||||
func (r *recordingArchives) FailRenames(targetID string, err error) {
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if r.renameErrs == nil {
|
r.renameErr = err
|
||||||
r.renameErrs = map[string]error{}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.renameErrs[targetID] = err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evicted returns a copy of the recorded webhook ids.
|
// Evicted returns a copy of the recorded webhook ids.
|
||||||
@@ -181,40 +151,22 @@ func (r *recordingArchives) Renames() []archiveRename {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestApp returns an app whose RequireStart fails the test when
|
|
||||||
// starting takes longer than fx's default start timeout of 15s. That
|
|
||||||
// limit catches a start that hangs, not a busy host: measured with make
|
|
||||||
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
|
|
||||||
// package's starts took 0.49s.
|
|
||||||
func newTestApp(
|
func newTestApp(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
targets ...any,
|
targets ...any,
|
||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newTestAppWithConfig(
|
|
||||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
|
||||||
func newTestAppWithConfig(
|
|
||||||
t *testing.T,
|
|
||||||
cfg *config.Config,
|
|
||||||
targets ...any,
|
|
||||||
) *fxtest.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config { return cfg },
|
func() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
},
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// noticeParam is the query parameter an action's redirect carries its
|
|
||||||
// notice code in.
|
|
||||||
const noticeParam = "notice"
|
|
||||||
|
|
||||||
// noticeCode names one of the fixed lines noticeFor knows. An action
|
|
||||||
// redirects with the code rather than the line, so nothing a client
|
|
||||||
// puts in the URL reaches the page: a code noticeFor does not know
|
|
||||||
// shows nothing.
|
|
||||||
type noticeCode string
|
|
||||||
|
|
||||||
// The codes of the actions on the webhook pages and of signing out.
|
|
||||||
// Replay's codes, with the reasons a replay can be refused, and
|
|
||||||
// resubmit's codes are defined beside those actions.
|
|
||||||
const (
|
|
||||||
webhookCreated noticeCode = "webhook-created"
|
|
||||||
webhookSaved noticeCode = "webhook-saved"
|
|
||||||
webhookDeleted noticeCode = "webhook-deleted"
|
|
||||||
entrypointAdded noticeCode = "entrypoint-added"
|
|
||||||
entrypointDeleted noticeCode = "entrypoint-deleted"
|
|
||||||
entrypointActivated noticeCode = "entrypoint-activated"
|
|
||||||
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
|
||||||
targetAdded noticeCode = "target-added"
|
|
||||||
targetSaved noticeCode = "target-saved"
|
|
||||||
targetDeleted noticeCode = "target-deleted"
|
|
||||||
targetActivated noticeCode = "target-activated"
|
|
||||||
targetDeactivated noticeCode = "target-deactivated"
|
|
||||||
signedOut noticeCode = "signed-out"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notice is the line templates/notice.html shows above a page to say
|
|
||||||
// what an action did.
|
|
||||||
type notice struct {
|
|
||||||
Text string
|
|
||||||
|
|
||||||
// Failed shows the line as an error: the action was refused.
|
|
||||||
Failed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// noticeFor returns the notice the request's URL names, or nil when it
|
|
||||||
// names none or an unknown code.
|
|
||||||
func noticeFor(r *http.Request) *notice {
|
|
||||||
n, ok := map[noticeCode]notice{
|
|
||||||
webhookCreated: {Text: "Webhook created."},
|
|
||||||
webhookSaved: {Text: "Webhook saved."},
|
|
||||||
webhookDeleted: {Text: "Webhook deleted."},
|
|
||||||
entrypointAdded: {Text: "Entrypoint added."},
|
|
||||||
entrypointDeleted: {Text: "Entrypoint deleted."},
|
|
||||||
entrypointActivated: {Text: "Entrypoint activated."},
|
|
||||||
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
|
||||||
targetAdded: {Text: "Target added."},
|
|
||||||
targetSaved: {Text: "Target saved."},
|
|
||||||
targetDeleted: {Text: "Target deleted."},
|
|
||||||
targetActivated: {Text: "Target activated."},
|
|
||||||
targetDeactivated: {Text: "Target deactivated."},
|
|
||||||
signedOut: {Text: "Signed out."},
|
|
||||||
|
|
||||||
replayQueued: {
|
|
||||||
Text: "Replay queued: a new delivery was created " +
|
|
||||||
"against the target's current configuration.",
|
|
||||||
},
|
|
||||||
replayTargetDeleted: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"has been deleted. Recreate the target, then replay.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayTargetMissing: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"no longer exists.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayTargetInactive: {
|
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
|
||||||
"is deactivated. Activate it, then replay.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayNotTerminal: {
|
|
||||||
Text: "Not replayed: this delivery has not finished yet.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
replayInFlight: {
|
|
||||||
Text: "Not replayed: a delivery of this event to this " +
|
|
||||||
"target is already in flight.",
|
|
||||||
Failed: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
resubmitQueued: {
|
|
||||||
Text: "Resubmitted: a new event was created from the " +
|
|
||||||
"stored one and queued to every active target.",
|
|
||||||
},
|
|
||||||
resubmitNoTargets: {
|
|
||||||
Text: "Resubmitted: a new event was created, but this " +
|
|
||||||
"source has no active targets, so nothing was queued.",
|
|
||||||
},
|
|
||||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &n
|
|
||||||
}
|
|
||||||
|
|
||||||
// withNotice returns path with code added as its notice.
|
|
||||||
func withNotice(path string, code noticeCode) string {
|
|
||||||
return path + "?" + noticeParam + "=" + string(code)
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notSet is what the Settings page shows for a value that is empty.
|
|
||||||
const notSet = "not set"
|
|
||||||
|
|
||||||
// settingRow is one line of the Settings page: an environment
|
|
||||||
// variable, what it controls, and the value the server loaded for it.
|
|
||||||
type settingRow struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSettings returns a handler for the read-only Settings page,
|
|
||||||
// which lists the configuration the server started with.
|
|
||||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
|
||||||
"Settings": settingRows(h.params.Config),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingRows lists every field of cfg under the environment variable
|
|
||||||
// it is read from, with the description the README's configuration
|
|
||||||
// table gives it (less its pointers to other README sections), in the
|
|
||||||
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
|
|
||||||
// their values never reach the page: only whether they are set.
|
|
||||||
func settingRows(cfg *config.Config) []settingRow {
|
|
||||||
metricsUsername := cfg.MetricsUsername
|
|
||||||
if metricsUsername == "" {
|
|
||||||
metricsUsername = notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return []settingRow{
|
|
||||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
|
||||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
|
||||||
{
|
|
||||||
"BIND_ADDRESS",
|
|
||||||
"IP address the HTTP listener binds. Loopback by default, " +
|
|
||||||
"so the cleartext listener is not published on every " +
|
|
||||||
"interface. The Docker image ships 0.0.0.0 instead",
|
|
||||||
cfg.BindAddress,
|
|
||||||
},
|
|
||||||
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
|
|
||||||
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
|
|
||||||
{
|
|
||||||
"METRICS_USERNAME",
|
|
||||||
"Basic auth username for /metrics. Must be set together " +
|
|
||||||
"with METRICS_PASSWORD; one without the other fails " +
|
|
||||||
"startup",
|
|
||||||
metricsUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_PASSWORD",
|
|
||||||
"Basic auth password for /metrics. Must be set together " +
|
|
||||||
"with METRICS_USERNAME; one without the other fails " +
|
|
||||||
"startup",
|
|
||||||
setOrNotSet(cfg.MetricsPassword),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SENTRY_DSN",
|
|
||||||
"Sentry error reporting DSN. Unset leaves error reporting " +
|
|
||||||
"off; a value the Sentry SDK cannot parse fails startup " +
|
|
||||||
"rather than serving with reporting silently off",
|
|
||||||
setOrNotSet(cfg.SentryDSN),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
|
||||||
"How often the retention reaper and archive sweeper run " +
|
|
||||||
"(Go duration, must be positive). A value that does " +
|
|
||||||
"not parse, or is zero or negative, fails startup",
|
|
||||||
cfg.RetentionSweepInterval.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SESSION_IDLE_TIMEOUT",
|
|
||||||
"Idle session timeout (Go duration)",
|
|
||||||
cfg.SessionIdleTimeout.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RECEIVER_RATE_LIMIT",
|
|
||||||
"Receiver requests/minute per IP per entrypoint " +
|
|
||||||
"(10x that per IP across the route)",
|
|
||||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"TRUSTED_PROXIES",
|
|
||||||
"CIDRs whose forwarded headers are trusted. A set value " +
|
|
||||||
"replaces the default. If any client can reach webhooker, " +
|
|
||||||
"or the proxy in front of it, from an RFC 1918 source " +
|
|
||||||
"address, set it to the proxy's address alone",
|
|
||||||
cidrList(cfg.TrustedProxies),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ALLOWED_EGRESS_CIDRS",
|
|
||||||
"CIDRs that delivery targets may reach despite the " +
|
|
||||||
"SSRF blocklist",
|
|
||||||
cidrList(cfg.AllowedEgressCIDRs),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
|
||||||
// has a value, never the value itself.
|
|
||||||
func setOrNotSet(value string) string {
|
|
||||||
if value == "" {
|
|
||||||
return notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return "set"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cidrList renders a CIDR list setting for the Settings page.
|
|
||||||
func cidrList(prefixes []netip.Prefix) string {
|
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
|
||||||
// and returns the value it shows for each variable name, plus the
|
|
||||||
// whole page.
|
|
||||||
func settingsShown(
|
|
||||||
t *testing.T, cfg *config.Config,
|
|
||||||
) (map[string]string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/settings", nil,
|
|
||||||
)
|
|
||||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleSettings().ServeHTTP(w, req)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
row := regexp.MustCompile(
|
|
||||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
shown := map[string]string{}
|
|
||||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
|
||||||
shown[match[1]] = html.UnescapeString(match[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
return shown, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
|
|
||||||
// only one of the three set in one of the content tests, so each
|
|
||||||
// row is checked against its own field.
|
|
||||||
cfg := &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Debug: true,
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
MetricsUsername: "scraper",
|
|
||||||
MetricsPassword: "",
|
|
||||||
Port: 9123,
|
|
||||||
SentryDSN: "",
|
|
||||||
BindAddress: "192.0.2.10",
|
|
||||||
RetentionSweepInterval: 17 * time.Minute,
|
|
||||||
SessionIdleTimeout: 3 * time.Hour,
|
|
||||||
ReceiverRateLimit: 77,
|
|
||||||
TrustedProxies: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.1.0.0/16"),
|
|
||||||
},
|
|
||||||
AllowedEgressCIDRs: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("192.168.5.0/24"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, cfg)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
|
||||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
|
||||||
"PORT": "9123",
|
|
||||||
"BIND_ADDRESS": "192.0.2.10",
|
|
||||||
"DATA_DIR": cfg.DataDir,
|
|
||||||
"DEBUG": "true",
|
|
||||||
"METRICS_USERNAME": "scraper",
|
|
||||||
"METRICS_PASSWORD": "not set",
|
|
||||||
"SENTRY_DSN": "not set",
|
|
||||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
|
||||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
|
||||||
"RECEIVER_RATE_LIMIT": "77",
|
|
||||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
|
||||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
|
||||||
}, shown)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/settings"`,
|
|
||||||
"the navigation bar links to the page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const metricsPassword = "metrics-password-1f9a"
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
MetricsPassword: metricsPassword,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, metricsPassword)
|
|
||||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
|
||||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
sentryKey = "dsnkey7c2e"
|
|
||||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
|
||||||
)
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
SentryDSN: sentryDSN,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, sentryKey)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -442,9 +440,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
@@ -468,121 +464,6 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
|
|
||||||
// database file is removed but a sidecar beside it is not, the
|
|
||||||
// operator is told the events are gone, never that the event
|
|
||||||
// database file is still there.
|
|
||||||
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
mgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &mgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
logs := new(bytes.Buffer)
|
|
||||||
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
require.NoError(t, mgr.CreateDB(wh.ID))
|
|
||||||
// Closing removes the sidecars, so the -wal below is the only
|
|
||||||
// one there.
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
// A non-empty directory in the -wal file's place, which
|
|
||||||
// os.Remove cannot remove whoever runs the test.
|
|
||||||
eventDBPath := mgr.DBPath(wh.ID)
|
|
||||||
require.NoError(t, os.MkdirAll(
|
|
||||||
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
|
|
||||||
))
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := postRequest(
|
|
||||||
"/hook/"+wh.ID+"/delete",
|
|
||||||
cookies,
|
|
||||||
map[string]string{paramSourceID: wh.ID},
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.NoFileExists(t, eventDBPath)
|
|
||||||
assert.Contains(t, logs.String(), "its events are gone")
|
|
||||||
assert.Contains(t, logs.String(), eventDBPath+"-wal")
|
|
||||||
assert.NotContains(
|
|
||||||
t, logs.String(), handlers.EventDBLeftMsgForTest,
|
|
||||||
"the events are gone, so the operator must not be told "+
|
|
||||||
"the event database file survived",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
|
|
||||||
// event database file itself cannot be removed, the operator is told
|
|
||||||
// it is still on disk, never that its events are gone.
|
|
||||||
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
mgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &mgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
logs := new(bytes.Buffer)
|
|
||||||
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
// A non-empty directory in the database file's place, which
|
|
||||||
// os.Remove cannot remove whoever runs the test.
|
|
||||||
eventDBPath := mgr.DBPath(wh.ID)
|
|
||||||
require.NoError(t, os.MkdirAll(
|
|
||||||
filepath.Join(eventDBPath, "keep"), 0o700,
|
|
||||||
))
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
req := postRequest(
|
|
||||||
"/hook/"+wh.ID+"/delete",
|
|
||||||
cookies,
|
|
||||||
map[string]string{paramSourceID: wh.ID},
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, logs.String(), "event database file is still on disk",
|
|
||||||
)
|
|
||||||
assert.Contains(t, logs.String(), eventDBPath)
|
|
||||||
assert.NotContains(
|
|
||||||
t, logs.String(), handlers.SidecarLeftMsgForTest,
|
|
||||||
"the database file is still on disk, so the operator must "+
|
|
||||||
"not be told its events are gone",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
||||||
// database target releases that target's archive writer and no
|
// database target releases that target's archive writer and no
|
||||||
// other: the webhook's other database target keeps its own.
|
// other: the webhook's other database target keeps its own.
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// failedHighlight is how the list marks a number of failed deliveries
|
|
||||||
// that is not zero.
|
|
||||||
const failedHighlight = `class="font-medium text-red-600"`
|
|
||||||
|
|
||||||
// listWebhook adds a webhook with the given name, owned by the test
|
|
||||||
// user.
|
|
||||||
func listWebhook(
|
|
||||||
t *testing.T, db *database.Database, name string,
|
|
||||||
) *database.Webhook {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
return wh
|
|
||||||
}
|
|
||||||
|
|
||||||
// addEntrypoints adds the given number of entrypoints, all active or
|
|
||||||
// all inactive, to a webhook and returns their paths.
|
|
||||||
func addEntrypoints(
|
|
||||||
t *testing.T, db *database.Database, webhookID string,
|
|
||||||
count int, active bool,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
paths := make([]string, count)
|
|
||||||
for i := range paths {
|
|
||||||
paths[i] = statsEntrypoint(t, db, webhookID, active)
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
// addTargets adds the given number of targets, all active or all
|
|
||||||
// inactive, to a webhook and returns them.
|
|
||||||
func addTargets(
|
|
||||||
t *testing.T, db *database.Database, webhookID string,
|
|
||||||
count int, active bool,
|
|
||||||
) []*database.Target {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
targets := make([]*database.Target, count)
|
|
||||||
for i := range targets {
|
|
||||||
targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog)
|
|
||||||
require.NoError(t, db.DB().Model(targets[i]).
|
|
||||||
Update("active", active).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return targets
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderWebhookList runs the real webhook list handler as the test user
|
|
||||||
// and returns the rendered page.
|
|
||||||
func renderWebhookList(
|
|
||||||
t *testing.T, h *handlers.Handlers, sess *session.Session,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cookies := authenticatedCookies(
|
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleSourceList().ServeHTTP(
|
|
||||||
w, getRequest(t, "/hooks", cookies, nil),
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// listCard returns one webhook's entry in a rendered webhook list, its
|
|
||||||
// markup as rendered and its text with the markup taken out and each
|
|
||||||
// run of space made one space.
|
|
||||||
func listCard(t *testing.T, page, webhookID string) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
|
|
||||||
require.True(t, found, "the list has no entry for %s", webhookID)
|
|
||||||
|
|
||||||
card, _, _ = strings.Cut(card, "</a>")
|
|
||||||
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
|
|
||||||
|
|
||||||
return card, strings.Join(strings.Fields(text), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// receiveEvents posts the given number of events to an entrypoint
|
|
||||||
// through the real receiver, and returns the webhook's event database
|
|
||||||
// and its events, oldest first.
|
|
||||||
func receiveEvents(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhookID, path string,
|
|
||||||
count int,
|
|
||||||
) (*gorm.DB, []database.Event) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
router := receiverRouter(h)
|
|
||||||
|
|
||||||
for range count {
|
|
||||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
events := listEvents(t, webhookDB)
|
|
||||||
require.Len(t, events, count)
|
|
||||||
|
|
||||||
return webhookDB, events
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedFailingWebhook adds a webhook with six entrypoints, two of them
|
|
||||||
// inactive, and seven targets, five of them inactive. Four events reach
|
|
||||||
// its two active targets, arriving 31, 5, 4 and 3 hours ago, and its
|
|
||||||
// event totals row records the last one. Three deliveries failed in the
|
|
||||||
// last 24 hours, two to the first target and one to the second, one
|
|
||||||
// failed 30 hours ago, two were delivered, and two are still pending.
|
|
||||||
// It returns the webhook and when its last event arrived.
|
|
||||||
func seedFailingWebhook(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
) (*database.Webhook, time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := listWebhook(t, db, "failing")
|
|
||||||
paths := addEntrypoints(t, db, wh.ID, 4, true)
|
|
||||||
addEntrypoints(t, db, wh.ID, 2, false)
|
|
||||||
|
|
||||||
active := addTargets(t, db, wh.ID, 2, true)
|
|
||||||
first, second := active[0], active[1]
|
|
||||||
|
|
||||||
addTargets(t, db, wh.ID, 5, false)
|
|
||||||
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4)
|
|
||||||
now := time.Now()
|
|
||||||
lastEventAt := now.Add(-3 * time.Hour)
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour))
|
|
||||||
statsAge(t, webhookDB, events[1].ID, now.Add(-5*time.Hour))
|
|
||||||
statsAge(t, webhookDB, events[2].ID, now.Add(-4*time.Hour))
|
|
||||||
statsAge(t, webhookDB, events[3].ID, lastEventAt)
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
|
||||||
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[0].ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[0].ID, second.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-30*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[1].ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[2].ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[2].ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[3].ID, second.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-time.Minute))
|
|
||||||
|
|
||||||
return wh, lastEventAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedHealthyWebhook adds a webhook with four entrypoints and two
|
|
||||||
// targets, all active, and three events, arriving 8, 7 and 6 hours ago
|
|
||||||
// and each delivered to both targets. Its event totals row records the
|
|
||||||
// last event. It returns the webhook and when its last event arrived.
|
|
||||||
func seedHealthyWebhook(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
) (*database.Webhook, time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := listWebhook(t, db, "healthy")
|
|
||||||
paths := addEntrypoints(t, db, wh.ID, 4, true)
|
|
||||||
targets := addTargets(t, db, wh.ID, 2, true)
|
|
||||||
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
|
|
||||||
now := time.Now()
|
|
||||||
lastEventAt := now.Add(-6 * time.Hour)
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, now.Add(-8*time.Hour))
|
|
||||||
statsAge(t, webhookDB, events[1].ID, now.Add(-7*time.Hour))
|
|
||||||
statsAge(t, webhookDB, events[2].ID, lastEventAt)
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
|
||||||
|
|
||||||
for _, ev := range events {
|
|
||||||
for _, target := range targets {
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, ev.ID, target.ID),
|
|
||||||
database.DeliveryStatusDelivered, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return wh, lastEventAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastEventText is how the list shows when the last event arrived.
|
|
||||||
func lastEventText(at time.Time) string {
|
|
||||||
return at.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
|
|
||||||
// shows for a webhook with recent failures, a healthy one, a new one
|
|
||||||
// that has received no event, and one without an event database.
|
|
||||||
func TestSourceList_ShowsActivityOfEachWebhook(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)
|
|
||||||
|
|
||||||
failing, failingLastEvent := seedFailingWebhook(t, h, db, dbMgr)
|
|
||||||
healthy, healthyLastEvent := seedHealthyWebhook(t, h, db, dbMgr)
|
|
||||||
|
|
||||||
// Creating a webhook creates its event database.
|
|
||||||
fresh := listWebhook(t, db, "fresh")
|
|
||||||
require.NoError(t, dbMgr.CreateDB(fresh.ID))
|
|
||||||
addEntrypoints(t, db, fresh.ID, 2, true)
|
|
||||||
addTargets(t, db, fresh.ID, 3, true)
|
|
||||||
|
|
||||||
quiet := listWebhook(t, db, "quiet")
|
|
||||||
addEntrypoints(t, db, quiet.ID, 2, true)
|
|
||||||
addTargets(t, db, quiet.ID, 3, true)
|
|
||||||
|
|
||||||
page := renderWebhookList(t, h, sess)
|
|
||||||
|
|
||||||
card, text := listCard(t, page, failing.ID)
|
|
||||||
assert.Contains(t, text, "6 entrypoints, 2 inactive")
|
|
||||||
assert.Contains(t, text, "7 targets, 5 inactive")
|
|
||||||
assert.Contains(t, text, "4 events within retention")
|
|
||||||
assert.Contains(t, text, "Last event "+lastEventText(failingLastEvent))
|
|
||||||
assert.Contains(t, card,
|
|
||||||
failedHighlight+">3 failed deliveries in the last 24 hours<")
|
|
||||||
|
|
||||||
card, text = listCard(t, page, healthy.ID)
|
|
||||||
assert.Contains(t, text, "4 entrypoints")
|
|
||||||
assert.Contains(t, text, "2 targets")
|
|
||||||
assert.Contains(t, text, "3 events within retention")
|
|
||||||
assert.Contains(t, text, "Last event "+lastEventText(healthyLastEvent))
|
|
||||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
|
||||||
assert.NotContains(t, text, "inactive")
|
|
||||||
assert.NotContains(t, card, failedHighlight)
|
|
||||||
|
|
||||||
card, text = listCard(t, page, fresh.ID)
|
|
||||||
assert.Contains(t, text, "2 entrypoints")
|
|
||||||
assert.Contains(t, text, "3 targets")
|
|
||||||
assert.Contains(t, text, "0 events within retention")
|
|
||||||
assert.Contains(t, text, "No events yet")
|
|
||||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
|
||||||
assert.NotContains(t, card, failedHighlight)
|
|
||||||
|
|
||||||
card, text = listCard(t, page, quiet.ID)
|
|
||||||
assert.Contains(t, text, "2 entrypoints")
|
|
||||||
assert.Contains(t, text, "3 targets")
|
|
||||||
assert.Contains(t, text, "0 events within retention")
|
|
||||||
assert.Contains(t, text, "No events yet")
|
|
||||||
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
|
|
||||||
assert.NotContains(t, card, failedHighlight)
|
|
||||||
assert.False(t, dbMgr.DBExists(quiet.ID),
|
|
||||||
"showing the list must not create an event database")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
|
|
||||||
// retention has removed one of a webhook's three events, the list
|
|
||||||
// counts the two still stored.
|
|
||||||
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
paths := addEntrypoints(t, db, wh.ID, 3, true)
|
|
||||||
addTargets(t, db, wh.ID, 4, true)
|
|
||||||
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
|
|
||||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
||||||
require.Len(t, listEvents(t, webhookDB), 2)
|
|
||||||
|
|
||||||
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
|
||||||
assert.Contains(t, text, "3 entrypoints")
|
|
||||||
assert.Contains(t, text, "4 targets")
|
|
||||||
assert.Contains(t, text, "2 events within retention")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
|
|
||||||
// retention has removed every event of a webhook, the list still shows
|
|
||||||
// when the last one arrived rather than "No events yet".
|
|
||||||
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
paths := addEntrypoints(t, db, wh.ID, 2, true)
|
|
||||||
addTargets(t, db, wh.ID, 3, true)
|
|
||||||
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
|
|
||||||
lastEventAt := time.Now().Add(-50 * time.Hour)
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, lastEventAt)
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
|
||||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
||||||
require.Empty(t, listEvents(t, webhookDB))
|
|
||||||
|
|
||||||
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
|
||||||
assert.Contains(t, text, "0 events within retention")
|
|
||||||
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
|
|
||||||
assert.NotContains(t, text, "No events yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceList_CountsOfOneInSingular checks that a webhook with one
|
|
||||||
// entrypoint, one target, one event within retention and one failed
|
|
||||||
// delivery in the last 24 hours has each written in the singular.
|
|
||||||
func TestSourceList_CountsOfOneInSingular(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 := listWebhook(t, db, "single")
|
|
||||||
paths := addEntrypoints(t, db, wh.ID, 1, true)
|
|
||||||
targets := addTargets(t, db, wh.ID, 1, true)
|
|
||||||
|
|
||||||
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
|
|
||||||
now := time.Now()
|
|
||||||
lastEventAt := now.Add(-9 * time.Hour)
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, lastEventAt)
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{LastEventAt: &lastEventAt}))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, events[0].ID, targets[0].ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Hour))
|
|
||||||
|
|
||||||
card, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
|
||||||
assert.Contains(t, card, ">1 entrypoint<")
|
|
||||||
assert.Contains(t, card, ">1 target<")
|
|
||||||
assert.Contains(t, card, ">1 event within retention<")
|
|
||||||
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
|
|
||||||
assert.Contains(t, card,
|
|
||||||
failedHighlight+">1 failed delivery in the last 24 hours<")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
|
|
||||||
// event database cannot be read says so in its entry instead of
|
|
||||||
// showing zeros, and that the rest of the list is still shown.
|
|
||||||
func TestSourceList_UnreadableEventDatabase(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)
|
|
||||||
|
|
||||||
broken := listWebhook(t, db, "broken")
|
|
||||||
addEntrypoints(t, db, broken.ID, 2, true)
|
|
||||||
addTargets(t, db, broken.ID, 3, true)
|
|
||||||
|
|
||||||
brokenDB, err := dbMgr.GetDB(broken.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t,
|
|
||||||
brokenDB.Migrator().DropTable(&database.EventTotals{}))
|
|
||||||
|
|
||||||
quiet := listWebhook(t, db, "quiet")
|
|
||||||
addEntrypoints(t, db, quiet.ID, 2, true)
|
|
||||||
addTargets(t, db, quiet.ID, 3, true)
|
|
||||||
|
|
||||||
page := renderWebhookList(t, h, sess)
|
|
||||||
|
|
||||||
_, text := listCard(t, page, broken.ID)
|
|
||||||
assert.Contains(t, text, "2 entrypoints")
|
|
||||||
assert.Contains(t, text, "3 targets")
|
|
||||||
assert.Contains(t, text, "The event figures could not be read.")
|
|
||||||
assert.NotContains(t, text, "events")
|
|
||||||
assert.NotContains(t, text, "failed")
|
|
||||||
|
|
||||||
_, text = listCard(t, page, quiet.ID)
|
|
||||||
assert.Contains(t, text, "No events yet")
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,10 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -22,70 +20,79 @@ import (
|
|||||||
type WebhookListItem struct {
|
type WebhookListItem struct {
|
||||||
database.Webhook
|
database.Webhook
|
||||||
|
|
||||||
EntrypointCount int
|
EntrypointCount int64
|
||||||
InactiveEntrypointCount int
|
TargetCount int64
|
||||||
TargetCount int
|
EventCount int64
|
||||||
InactiveTargetCount int
|
|
||||||
|
|
||||||
// EventCount is how many events the webhook holds, LastEventAt
|
|
||||||
// when the newest arrived (nil before the first), and
|
|
||||||
// FailedLast24Hours how many of its deliveries failed in the last
|
|
||||||
// 24 hours. When the webhook's event database could not be read,
|
|
||||||
// EventsUnreadable is set and these three are not known.
|
|
||||||
EventCount int64
|
|
||||||
LastEventAt *time.Time
|
|
||||||
FailedLast24Hours int64
|
|
||||||
EventsUnreadable bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// errMissingURL signals that a required URL was not provided.
|
// errMissingURL signals that a required URL was not provided.
|
||||||
var errMissingURL = errors.New("missing URL")
|
var errMissingURL = errors.New("missing URL")
|
||||||
|
|
||||||
// parseRetentionDays interprets a retention_days form value. It
|
// errInvalidRetention signals a retention_days form value that is not
|
||||||
// returns the number of days, or, for a value it refuses, the message
|
// a non-negative whole number.
|
||||||
// the create and edit forms show; the message is empty when the value
|
var errInvalidRetention = errors.New("invalid retention days")
|
||||||
// is accepted.
|
|
||||||
|
// errRetentionTooLarge signals a retention_days form value that is a
|
||||||
|
// whole number but larger than the reaper's cutoff arithmetic can
|
||||||
|
// represent. It is distinguished from errInvalidRetention so the form
|
||||||
|
// can tell the user the actual ceiling instead of implying their input
|
||||||
|
// was not a number.
|
||||||
|
var errRetentionTooLarge = errors.New("retention days out of range")
|
||||||
|
|
||||||
|
// retentionErrorMessage returns the message the create and edit forms
|
||||||
|
// show the user for a rejected retention_days value. Any error other
|
||||||
|
// than errRetentionTooLarge falls back to the generic wording, so an
|
||||||
|
// unrecognised parse failure still produces a sensible 400 rather than
|
||||||
|
// an empty alert.
|
||||||
|
func retentionErrorMessage(err error) string {
|
||||||
|
if errors.Is(err, errRetentionTooLarge) {
|
||||||
|
return "Retention must be at most " +
|
||||||
|
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
||||||
|
" days, or 0 to retain events forever."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Retention must be a whole number of days, or 0 to " +
|
||||||
|
"retain events forever."
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRetentionDays interprets a retention_days form value.
|
||||||
//
|
//
|
||||||
// An empty value yields fallback, which lets the create path apply the
|
// An empty value yields fallback, which lets the create path apply the
|
||||||
// default and the edit path leave the stored value unchanged. A value
|
// default and the edit path leave the stored value unchanged. A value
|
||||||
// of 0 is returned as 0 and is rewritten to the retain-forever
|
// of 0 is returned as 0 and is rewritten to the retain-forever
|
||||||
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
||||||
// or negative is refused rather than silently given a default.
|
// or negative is an error rather than a silently substituted default.
|
||||||
//
|
//
|
||||||
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
||||||
// time.Duration, an int64 nanosecond count, so a day count above
|
// time.Duration, an int64 nanosecond count, so a day count above
|
||||||
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
||||||
// future, and deletes every event the webhook has. A finite value
|
// future, and deletes every event the webhook has. A finite value
|
||||||
// above that ceiling is therefore refused, and the message names the
|
// above that ceiling is therefore a 400.
|
||||||
// ceiling rather than implying the input was not a number.
|
|
||||||
//
|
//
|
||||||
// A value at or above the retain-forever sentinel is not out of range:
|
// A value at or above the retain-forever sentinel is not out of range:
|
||||||
// it is what the edit form pre-fills for a retain-forever webhook, so
|
// it is what the edit form pre-fills for a retain-forever webhook, so
|
||||||
// submitting the form back unchanged has to keep meaning "forever"
|
// submitting the form back unchanged has to keep meaning "forever"
|
||||||
// rather than being rejected.
|
// rather than being rejected.
|
||||||
func parseRetentionDays(raw string, fallback int) (int, string) {
|
func parseRetentionDays(raw string, fallback int) (int, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return fallback, ""
|
return fallback, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := strconv.Atoi(raw)
|
v, err := strconv.Atoi(raw)
|
||||||
if err != nil || v < 0 {
|
if err != nil || v < 0 {
|
||||||
return 0, "Retention must be a whole number of days, or 0 to " +
|
return 0, errInvalidRetention
|
||||||
"retain events forever."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if v >= database.RetentionForeverDays {
|
if v >= database.RetentionForeverDays {
|
||||||
return database.RetentionForeverDays, ""
|
return database.RetentionForeverDays, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if v > database.MaxFiniteRetentionDays {
|
if v > database.MaxFiniteRetentionDays {
|
||||||
return 0, "Retention must be at most " +
|
return 0, errRetentionTooLarge
|
||||||
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
|
||||||
" days, or 0 to retain events forever."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return v, ""
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryView is the display-safe projection of a delivery
|
// DeliveryView is the display-safe projection of a delivery
|
||||||
@@ -147,12 +154,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := h.buildWebhookListItems(webhooks)
|
items := h.buildWebhookListItems(webhooks)
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to list webhooks", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhooks": items,
|
"Webhooks": items,
|
||||||
@@ -162,115 +164,36 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWebhookListItems builds the list's entry for each webhook. It
|
// buildWebhookListItems builds list items with counts.
|
||||||
// fails when the main database cannot be read. A webhook whose event
|
|
||||||
// database cannot be read is marked on its own entry, and the error is
|
|
||||||
// logged.
|
|
||||||
func (h *Handlers) buildWebhookListItems(
|
func (h *Handlers) buildWebhookListItems(
|
||||||
webhooks []database.Webhook,
|
webhooks []database.Webhook,
|
||||||
) ([]WebhookListItem, error) {
|
) []WebhookListItem {
|
||||||
items := make([]WebhookListItem, len(webhooks))
|
items := make([]WebhookListItem, len(webhooks))
|
||||||
since := time.Now().Add(-longWindow)
|
|
||||||
|
|
||||||
for i := range webhooks {
|
for i := range webhooks {
|
||||||
item := &items[i]
|
items[i].Webhook = webhooks[i]
|
||||||
item.Webhook = webhooks[i]
|
|
||||||
|
|
||||||
var err error
|
h.db.DB().Model(&database.Entrypoint{}).Where(
|
||||||
|
"webhook_id = ?", webhooks[i].ID,
|
||||||
|
).Count(&items[i].EntrypointCount)
|
||||||
|
|
||||||
item.EntrypointCount, item.InactiveEntrypointCount, err =
|
h.db.DB().Model(&database.Target{}).Where(
|
||||||
h.countWithInactive(&database.Entrypoint{}, item.ID)
|
"webhook_id = ?", webhooks[i].ID,
|
||||||
if err != nil {
|
).Count(&items[i].TargetCount)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.TargetCount, item.InactiveTargetCount, err =
|
if h.dbMgr.DBExists(webhooks[i].ID) {
|
||||||
h.countWithInactive(&database.Target{}, item.ID)
|
webhookDB, err := h.dbMgr.GetDB(
|
||||||
if err != nil {
|
webhooks[i].ID,
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opening an event database that does not exist would create
|
|
||||||
// it, and it would hold nothing to count.
|
|
||||||
if !h.dbMgr.DBExists(item.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.readListEventFigures(item, since)
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read webhook list figures",
|
|
||||||
"webhook_id", item.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
)
|
||||||
|
if err == nil {
|
||||||
item.EventsUnreadable = true
|
webhookDB.Model(
|
||||||
|
&database.Event{},
|
||||||
|
).Count(&items[i].EventCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, nil
|
return items
|
||||||
}
|
|
||||||
|
|
||||||
// countWithInactive returns how many entrypoints or targets, as model
|
|
||||||
// says, a webhook has, and how many of them are inactive.
|
|
||||||
func (h *Handlers) countWithInactive(
|
|
||||||
model any, webhookID string,
|
|
||||||
) (int, int, error) {
|
|
||||||
var active []bool
|
|
||||||
|
|
||||||
err := h.db.DB().Model(model).
|
|
||||||
Where("webhook_id = ?", webhookID).
|
|
||||||
Pluck("active", &active).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf(
|
|
||||||
"reading active flags of webhook %s: %w", webhookID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
inactive := 0
|
|
||||||
|
|
||||||
for _, a := range active {
|
|
||||||
if !a {
|
|
||||||
inactive++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(active), inactive, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readListEventFigures fills in the figures the list shows from the
|
|
||||||
// webhook's event database, with the statistics pane's own queries:
|
|
||||||
// the event count and last arrival from the event totals row, and the
|
|
||||||
// deliveries that failed since the given time from the deliveries'
|
|
||||||
// status index.
|
|
||||||
func (h *Handlers) readListEventFigures(
|
|
||||||
item *WebhookListItem, since time.Time,
|
|
||||||
) error {
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(item.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var totals database.EventTotals
|
|
||||||
|
|
||||||
err = webhookDB.Take(&totals).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item.EventCount = totals.Events - totals.EventsRemoved
|
|
||||||
item.LastEventAt = totals.LastEventAt
|
|
||||||
|
|
||||||
byTarget, err := finishedByTarget(webhookDB, since)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range byTarget {
|
|
||||||
item.FailedLast24Hours += f.Failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceCreate shows the form to create a new webhook.
|
// HandleSourceCreate shows the form to create a new webhook.
|
||||||
@@ -330,25 +253,28 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
retentionStr := r.PostFormValue("retention_days")
|
retentionStr := r.PostFormValue("retention_days")
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
h.renderTemplateStatus(
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData(
|
newSourceFormData(
|
||||||
"Name is required", name, description,
|
"Name is required", name, description,
|
||||||
),
|
),
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
retentionDays, errMsg := parseRetentionDays(
|
retentionDays, retErr := parseRetentionDays(
|
||||||
retentionStr, database.DefaultRetentionDays,
|
retentionStr, database.DefaultRetentionDays,
|
||||||
)
|
)
|
||||||
if errMsg != "" {
|
if retErr != nil {
|
||||||
h.renderTemplateStatus(
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(
|
||||||
w, r, "sources_new.html",
|
w, r, "sources_new.html",
|
||||||
newSourceFormData(errMsg, name, description),
|
newSourceFormData(
|
||||||
http.StatusBadRequest,
|
retentionErrorMessage(retErr),
|
||||||
|
name, description,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -396,8 +322,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +446,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
// target's stored config blob holds a credential, and it
|
// target's stored config blob holds a credential, and it
|
||||||
// must never reach a template.
|
// must never reach a template.
|
||||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
"Entrypoints": NewEntrypointViews(entrypoints),
|
||||||
"Targets": h.targetRows(&webhook, targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
@@ -579,9 +504,6 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
|
|
||||||
sourceID := chi.URLParam(r, "sourceID")
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
h.renameMu.Lock()
|
|
||||||
defer h.renameMu.Unlock()
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
var webhook database.Webhook
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -621,7 +543,8 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
tmplKeyError: "Name is required",
|
tmplKeyError: "Name is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -632,16 +555,17 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
// An empty field falls back to the stored value, so submitting the
|
// An empty field falls back to the stored value, so submitting the
|
||||||
// form without touching retention leaves the policy alone.
|
// form without touching retention leaves the policy alone.
|
||||||
retentionDays, errMsg := parseRetentionDays(
|
retentionDays, retErr := parseRetentionDays(
|
||||||
r.PostFormValue("retention_days"), webhook.RetentionDays,
|
r.PostFormValue("retention_days"), webhook.RetentionDays,
|
||||||
)
|
)
|
||||||
if errMsg != "" {
|
if retErr != nil {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: webhook,
|
tmplKeyWebhook: webhook,
|
||||||
tmplKeyError: errMsg,
|
tmplKeyError: retentionErrorMessage(retErr),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -649,18 +573,17 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
webhook.RetentionDays = retentionDays
|
webhook.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
|
||||||
// delivery.Engine.Rename). If either step fails, the same targets'
|
// delivery.Engine.Rename). If either step fails, they go back to
|
||||||
// archives go back to the name that is still stored, without
|
// the name that is still stored.
|
||||||
// reading the main database again.
|
err := h.renameWebhookArchives(webhook.ID, oldName, webhook.Name)
|
||||||
targets, err := h.renameWebhookArchives(
|
|
||||||
webhook.ID, oldName, webhook.Name,
|
|
||||||
)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = h.db.DB().Save(webhook).Error
|
err = h.db.DB().Save(webhook).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
restoreErr := h.renameArchives(targets, oldName)
|
restoreErr := h.renameWebhookArchives(
|
||||||
|
webhook.ID, webhook.Name, oldName,
|
||||||
|
)
|
||||||
if restoreErr != nil {
|
if restoreErr != nil {
|
||||||
h.log.Error(
|
h.log.Error(
|
||||||
"failed to rename archives back",
|
"failed to rename archives back",
|
||||||
@@ -673,12 +596,12 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: webhook,
|
tmplKeyWebhook: webhook,
|
||||||
tmplKeyError: "Not saved: " + err.Error() +
|
tmplKeyError: "Not saved: " + err.Error() +
|
||||||
". Move that archive out of the data directory, " +
|
". Move that file out of the data directory, " +
|
||||||
"its .db together with any -wal and -shm beside " +
|
"then save again.",
|
||||||
"it, then save again.",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -689,8 +612,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,17 +645,6 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The messages deleteWebhookResources logs when a file of the event
|
|
||||||
// database cannot be removed: the database file itself, or only a
|
|
||||||
// sidecar once the database file is gone.
|
|
||||||
const (
|
|
||||||
eventDBLeftMsg = "webhook deleted, but its event database file is " +
|
|
||||||
"still on disk; remove it by hand"
|
|
||||||
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
|
|
||||||
"-wal or -shm sidecar of its event database is " +
|
|
||||||
"still on disk; remove it by hand"
|
|
||||||
)
|
|
||||||
|
|
||||||
// deleteWebhookResources soft-deletes config and hard-deletes
|
// deleteWebhookResources soft-deletes config and hard-deletes
|
||||||
// the per-webhook event database.
|
// the per-webhook event database.
|
||||||
func (h *Handlers) deleteWebhookResources(
|
func (h *Handlers) deleteWebhookResources(
|
||||||
@@ -773,25 +684,18 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
err = h.dbMgr.DeleteDB(webhook.ID)
|
err = h.dbMgr.DeleteDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The configuration is committed, so the webhook is gone,
|
// The configuration is committed, so the webhook is gone,
|
||||||
// but a file of its event database is still on disk with
|
// but its event database file is still on disk with
|
||||||
// nothing referencing it. Report the failure rather than
|
// nothing referencing it. Report the failure rather than
|
||||||
// redirecting as though everything succeeded: the file
|
// redirecting as though everything succeeded: the file
|
||||||
// needs removing by hand, and the logged error names it.
|
// needs removing by hand, and the logged error names it.
|
||||||
// When only a sidecar is left, the events are already
|
h.serverError(
|
||||||
// gone, and the message must not suggest they survive.
|
w, r, "failed to delete webhook event database", err,
|
||||||
msg := eventDBLeftMsg
|
)
|
||||||
if errors.Is(err, database.ErrSidecarNotRemoved) {
|
|
||||||
msg = sidecarLeftMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
h.serverError(w, r, msg, err)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
@@ -870,13 +774,12 @@ func (h *Handlers) evictTargetArchiveWriter(targetID string) {
|
|||||||
// renameWebhookArchives renames the archive file of every database
|
// renameWebhookArchives renames the archive file of every database
|
||||||
// target of a webhook from the webhook name oldName to newName,
|
// target of a webhook from the webhook name oldName to newName,
|
||||||
// keeping each target's own name. It does nothing when the name is
|
// keeping each target's own name. It does nothing when the name is
|
||||||
// unchanged. It returns the targets it read, so that a failed edit can
|
// unchanged, and stops at the first failure.
|
||||||
// move those same archives back with renameArchives.
|
|
||||||
func (h *Handlers) renameWebhookArchives(
|
func (h *Handlers) renameWebhookArchives(
|
||||||
webhookID, oldName, newName string,
|
webhookID, oldName, newName string,
|
||||||
) ([]database.Target, error) {
|
) error {
|
||||||
if h.archives == nil || oldName == newName {
|
if h.archives == nil || oldName == newName {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var targets []database.Target
|
var targets []database.Target
|
||||||
@@ -888,32 +791,19 @@ func (h *Handlers) renameWebhookArchives(
|
|||||||
).
|
).
|
||||||
Find(&targets).Error
|
Find(&targets).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return targets, h.renameArchives(targets, newName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renameArchives renames the archive file of each of the given
|
|
||||||
// database targets to the webhook name webhookName, keeping each
|
|
||||||
// target's own name. It tries every target even after one fails, so
|
|
||||||
// that moving the archives back after a failed edit leaves none under
|
|
||||||
// the new name, and returns every failure joined.
|
|
||||||
func (h *Handlers) renameArchives(
|
|
||||||
targets []database.Target, webhookName string,
|
|
||||||
) error {
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
for i := range targets {
|
for i := range targets {
|
||||||
err := h.archives.Rename(
|
err = h.archives.Rename(
|
||||||
targets[i].ID, webhookName, targets[i].Name,
|
targets[i].ID, newName, targets[i].Name,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Join(errs...)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownedWebhook resolves the request's sourceID parameter to a
|
// ownedWebhook resolves the request's sourceID parameter to a
|
||||||
@@ -992,16 +882,31 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
totalPages++
|
totalPages++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The banner a replay or resubmit POST redirected back
|
||||||
|
// with. The message comes from a fixed set keyed by the
|
||||||
|
// outcome code, never from the query string itself.
|
||||||
|
replayMsg, replayOK := replayOutcome(
|
||||||
|
r.URL.Query().Get(replayOutcomeParam),
|
||||||
|
)
|
||||||
|
|
||||||
|
resubmitMsg, resubmitOK := resubmitOutcome(
|
||||||
|
r.URL.Query().Get(resubmitOutcomeParam),
|
||||||
|
)
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
"Page": page,
|
"ReplayMessage": replayMsg,
|
||||||
"TotalPages": totalPages,
|
"ReplayQueued": replayOK,
|
||||||
"TotalEvents": total,
|
"ResubmitMessage": resubmitMsg,
|
||||||
"HasPrev": page > 1,
|
"ResubmitQueued": resubmitOK,
|
||||||
"HasNext": page < totalPages,
|
"Page": page,
|
||||||
"PrevPage": page - 1,
|
"TotalPages": totalPages,
|
||||||
"NextPage": page + 1,
|
"TotalEvents": total,
|
||||||
|
"HasPrev": page > 1,
|
||||||
|
"HasNext": page < totalPages,
|
||||||
|
"PrevPage": page - 1,
|
||||||
|
"NextPage": page + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_logs.html", data)
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
@@ -1390,8 +1295,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1410,9 +1314,6 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
sourceID := chi.URLParam(r, "sourceID")
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
h.renameMu.Lock()
|
|
||||||
defer h.renameMu.Unlock()
|
|
||||||
|
|
||||||
var webhook database.Webhook
|
var webhook database.Webhook
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1505,8 +1406,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1710,11 +1610,10 @@ func (h *Handlers) validateTargetURL(
|
|||||||
msg := "Invalid target URL: " + err.Error()
|
msg := "Invalid target URL: " + err.Error()
|
||||||
|
|
||||||
// Only a private or reserved address's refusal says how
|
// Only a private or reserved address's refusal says how
|
||||||
// to allow it. Other refusals never do: link-local, the
|
// to allow it. Metadata refusals never do: link-local and
|
||||||
// unspecified addresses and the unconditional metadata
|
// the other unconditional metadata addresses cannot be
|
||||||
// addresses cannot be opened, and the default
|
// opened, and the default blocklist's public addresses,
|
||||||
// blocklist's public addresses, which listing does open,
|
// which listing does open, hand out credentials.
|
||||||
// hand out credentials.
|
|
||||||
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
msg += ". Private and reserved addresses are refused " +
|
msg += ". Private and reserved addresses are refused " +
|
||||||
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
@@ -1785,7 +1684,6 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|||||||
"entrypointID", &database.Entrypoint{},
|
"entrypointID", &database.Entrypoint{},
|
||||||
"failed to delete entrypoint",
|
"failed to delete entrypoint",
|
||||||
nil,
|
nil,
|
||||||
entrypointDeleted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1797,21 +1695,18 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
|||||||
"targetID", &database.Target{},
|
"targetID", &database.Target{},
|
||||||
"failed to delete target",
|
"failed to delete target",
|
||||||
h.evictTargetArchiveWriter,
|
h.evictTargetArchiveWriter,
|
||||||
targetDeleted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteChildResource returns a handler that deletes a child
|
// deleteChildResource returns a handler that deletes a child
|
||||||
// resource (entrypoint or target) belonging to a webhook. The
|
// resource (entrypoint or target) belonging to a webhook. The
|
||||||
// optional afterDelete hook runs with the child's id once the
|
// optional afterDelete hook runs with the child's id once the
|
||||||
// delete has removed it, before the redirect, which carries done as
|
// delete has removed it, before the redirect.
|
||||||
// its notice.
|
|
||||||
func (h *Handlers) deleteChildResource(
|
func (h *Handlers) deleteChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
model any,
|
model any,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
afterDelete func(childID string),
|
afterDelete func(childID string),
|
||||||
done noticeCode,
|
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1855,7 +1750,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
withNotice("/hook/"+webhook.ID, done),
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1866,7 +1761,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"entrypointID",
|
"entrypointID",
|
||||||
func(webhookID, childID string) (bool, error) {
|
func(webhookID, childID string) error {
|
||||||
var ep database.Entrypoint
|
var ep database.Entrypoint
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1874,15 +1769,14 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&ep).Error
|
).First(&ep).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ep.Active = !ep.Active
|
ep.Active = !ep.Active
|
||||||
|
|
||||||
return ep.Active, h.db.DB().Save(&ep).Error
|
return h.db.DB().Save(&ep).Error
|
||||||
},
|
},
|
||||||
"failed to toggle entrypoint",
|
"failed to toggle entrypoint",
|
||||||
entrypointActivated, entrypointDeactivated,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,7 +1784,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"targetID",
|
"targetID",
|
||||||
func(webhookID, childID string) (bool, error) {
|
func(webhookID, childID string) error {
|
||||||
var tgt database.Target
|
var tgt database.Target
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1898,31 +1792,23 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&tgt).Error
|
).First(&tgt).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the active column: saving the whole row would
|
tgt.Active = !tgt.Active
|
||||||
// write back the name and settings read above over an
|
|
||||||
// edit saved since.
|
|
||||||
active := !tgt.Active
|
|
||||||
|
|
||||||
return active, h.db.DB().Model(&tgt).
|
return h.db.DB().Save(&tgt).Error
|
||||||
Update("active", active).Error
|
|
||||||
},
|
},
|
||||||
"failed to toggle target",
|
"failed to toggle target",
|
||||||
targetActivated, targetDeactivated,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleChildResource returns a handler that toggles the active
|
// toggleChildResource returns a handler that toggles the active
|
||||||
// state of a child resource belonging to a webhook. toggleFn returns
|
// state of a child resource belonging to a webhook.
|
||||||
// the new state, and the redirect carries activated or deactivated as
|
|
||||||
// its notice to match.
|
|
||||||
func (h *Handlers) toggleChildResource(
|
func (h *Handlers) toggleChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
toggleFn func(webhookID, childID string) (bool, error),
|
toggleFn func(webhookID, childID string) error,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
activated, deactivated noticeCode,
|
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1948,21 +1834,16 @@ func (h *Handlers) toggleChildResource(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
active, err := toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, errMsg, err)
|
h.serverError(w, r, errMsg, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
done := deactivated
|
|
||||||
if active {
|
|
||||||
done = activated
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
withNotice("/hook/"+webhook.ID, done),
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,16 @@ package handlers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"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"
|
|
||||||
"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/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
@@ -191,7 +187,6 @@ func storedRetentionDays(
|
|||||||
type sourceTestEnv struct {
|
type sourceTestEnv struct {
|
||||||
handlers *handlers.Handlers
|
handlers *handlers.Handlers
|
||||||
db *database.Database
|
db *database.Database
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
archives *recordingArchives
|
archives *recordingArchives
|
||||||
cookies []*http.Cookie
|
cookies []*http.Cookie
|
||||||
}
|
}
|
||||||
@@ -205,11 +200,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
|||||||
|
|
||||||
var db *database.Database
|
var db *database.Database
|
||||||
|
|
||||||
var dbMgr *database.WebhookDBManager
|
|
||||||
|
|
||||||
var archives *recordingArchives
|
var archives *recordingArchives
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &archives)
|
app := newTestApp(t, &h, &sess, &db, &archives)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -217,7 +210,6 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
|
|||||||
return &sourceTestEnv{
|
return &sourceTestEnv{
|
||||||
handlers: h,
|
handlers: h,
|
||||||
db: db,
|
db: db,
|
||||||
dbMgr: dbMgr,
|
|
||||||
archives: archives,
|
archives: archives,
|
||||||
cookies: authenticatedCookies(
|
cookies: authenticatedCookies(
|
||||||
t, sess, sourceTestUserID, "sourceuser",
|
t, sess, sourceTestUserID, "sourceuser",
|
||||||
@@ -372,42 +364,31 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
|
|||||||
// boundary between "too large to represent" and "retain forever": the
|
// boundary between "too large to represent" and "retain forever": the
|
||||||
// sentinel is above MaxFiniteRetentionDays, but it is the value the
|
// sentinel is above MaxFiniteRetentionDays, but it is the value the
|
||||||
// edit form pre-fills, so it must be accepted rather than rejected as
|
// edit form pre-fills, so it must be accepted rather than rejected as
|
||||||
// out of range. A value above the sentinel is stored as the sentinel.
|
// out of range.
|
||||||
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
|
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, days := range []int{
|
env := setupSourceTest(t)
|
||||||
|
sentinel := strconv.Itoa(database.RetentionForeverDays)
|
||||||
|
|
||||||
|
w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
wh := onlyWebhook(t, env.db)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
database.RetentionForeverDays,
|
database.RetentionForeverDays,
|
||||||
database.RetentionForeverDays + 1,
|
storedRetentionDays(t, env.db, wh.ID),
|
||||||
} {
|
)
|
||||||
raw := strconv.Itoa(days)
|
|
||||||
|
|
||||||
t.Run(raw, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
|
|
||||||
w := submitCreate(t, env.handlers, env.cookies, "forever", &raw)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
|
|
||||||
wh := onlyWebhook(t, env.db)
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
database.RetentionForeverDays,
|
|
||||||
storedRetentionDays(t, env.db, wh.ID),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
|
||||||
// validation failure hands the user's typing back, matching what the
|
// validation failure hands the user's typing back, matching what the
|
||||||
// edit form already does. Losing a long description to a mistyped
|
// edit form already does. Losing a long description to a mistyped
|
||||||
// retention value is the kind of thing that makes people give up on a
|
// retention value is the kind of thing that makes people give up on a
|
||||||
// form. Both values carry HTML-special characters, which must come
|
// form.
|
||||||
// back escaped rather than as markup.
|
|
||||||
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -416,8 +397,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
|||||||
env := setupSourceTest(t)
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
name = `kept"><b>name`
|
name = "kept-name"
|
||||||
description = `a </textarea> worth not losing`
|
description = "a description worth not losing"
|
||||||
)
|
)
|
||||||
|
|
||||||
form := url.Values{}
|
form := url.Values{}
|
||||||
@@ -434,10 +415,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
|||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
|
|
||||||
assert.Contains(t, body, `value="kept"><b>name"`)
|
assert.Contains(t, body, `value="`+name+`"`)
|
||||||
assert.Contains(t, body, `a </textarea> worth not losing`)
|
assert.Contains(t, body, description)
|
||||||
assert.NotContains(t, body, name)
|
|
||||||
assert.NotContains(t, body, description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitEdit posts the webhook edit form for the given webhook.
|
// submitEdit posts the webhook edit form for the given webhook.
|
||||||
@@ -571,7 +550,7 @@ func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
|
|||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
env.archives.FailRenames(tgt.ID, errInjectedRename)
|
env.archives.FailRenames(errInjectedRename)
|
||||||
|
|
||||||
oldName := wh.Name
|
oldName := wh.Name
|
||||||
wh.Name = renamedWebhookName
|
wh.Name = renamedWebhookName
|
||||||
@@ -632,165 +611,6 @@ func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// errInjectedRead is the failure a test makes reads of the main
|
|
||||||
// database report.
|
|
||||||
var errInjectedRead = errors.New("injected read failure")
|
|
||||||
|
|
||||||
// TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading
|
|
||||||
// proves that when the save fails and every later read of the main
|
|
||||||
// database fails too, each archive the rename moved is still renamed
|
|
||||||
// back: the move back needs no second read of the webhook's targets.
|
|
||||||
func TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
var saveFailed atomic.Bool
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Callback().Update().
|
|
||||||
Before("gorm:update").
|
|
||||||
Register("test:fail_save", func(tx *gorm.DB) {
|
|
||||||
saveFailed.Store(true)
|
|
||||||
|
|
||||||
_ = tx.AddError(errInjectedSave)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
require.NoError(t, env.db.DB().Callback().Query().
|
|
||||||
Before("gorm:query").
|
|
||||||
Register("test:fail_reads_after_save", func(tx *gorm.DB) {
|
|
||||||
if saveFailed.Load() {
|
|
||||||
_ = tx.AddError(errInjectedRead)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
oldName := wh.Name
|
|
||||||
wh.Name = renamedWebhookName
|
|
||||||
|
|
||||||
w := submitEdit(t, env, wh, "")
|
|
||||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
[]archiveRename{
|
|
||||||
{first.ID, renamedWebhookName, first.Name},
|
|
||||||
{second.ID, renamedWebhookName, second.Name},
|
|
||||||
{first.ID, oldName, first.Name},
|
|
||||||
{second.ID, oldName, second.Name},
|
|
||||||
},
|
|
||||||
env.archives.Renames(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceEditSubmit_EditsDoNotInterleave proves that a second
|
|
||||||
// webhook edit submitted while the first is inside its archive rename
|
|
||||||
// does not run until the first is saved, so afterwards the stored
|
|
||||||
// names are the ones the archive was last renamed to. The stand-in's
|
|
||||||
// last rename is the name the file has on disk.
|
|
||||||
func TestHandleSourceEditSubmit_EditsDoNotInterleave(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
entered, release := env.archives.BlockNextRename()
|
|
||||||
|
|
||||||
firstEdit, secondEdit := wh, wh
|
|
||||||
firstEdit.Name = "First"
|
|
||||||
secondEdit.Name = "Second"
|
|
||||||
|
|
||||||
firstCode := make(chan int, 1)
|
|
||||||
|
|
||||||
go func() { firstCode <- submitEdit(t, env, firstEdit, "").Code }()
|
|
||||||
|
|
||||||
<-entered
|
|
||||||
|
|
||||||
secondCode := make(chan int, 1)
|
|
||||||
|
|
||||||
go func() { secondCode <- submitEdit(t, env, secondEdit, "").Code }()
|
|
||||||
|
|
||||||
// Were the edits not ordered, the second would run to its end in
|
|
||||||
// this time, while the first is still inside its rename.
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
release()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, <-firstCode)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, <-secondCode)
|
|
||||||
|
|
||||||
var (
|
|
||||||
storedWebhook database.Webhook
|
|
||||||
storedTarget database.Target
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.db.DB().First(&storedWebhook, "id = ?", wh.ID).Error,
|
|
||||||
)
|
|
||||||
require.NoError(
|
|
||||||
t, env.db.DB().First(&storedTarget, "id = ?", tgt.ID).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
renames := env.archives.Renames()
|
|
||||||
require.NotEmpty(t, renames)
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
archiveRename{tgt.ID, storedWebhook.Name, storedTarget.Name},
|
|
||||||
renames[len(renames)-1],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves
|
|
||||||
// that when a webhook has three database targets and only the middle
|
|
||||||
// one's archive cannot be renamed, the stored name stays and both
|
|
||||||
// others are renamed back, the last one included: the move back does
|
|
||||||
// not stop at the target it cannot rename. The handler reaches the
|
|
||||||
// targets in the order they were created, which the exact sequence
|
|
||||||
// below pins, so the refused target always comes before the last.
|
|
||||||
func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
middle := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
last := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
env.archives.FailRenames(middle.ID, errNameTaken)
|
|
||||||
|
|
||||||
oldName := wh.Name
|
|
||||||
wh.Name = renamedWebhookName
|
|
||||||
|
|
||||||
w := submitEdit(t, env, wh, "")
|
|
||||||
require.Equal(t, http.StatusConflict, w.Code)
|
|
||||||
|
|
||||||
var stored database.Webhook
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
|
|
||||||
)
|
|
||||||
assert.Equal(t, oldName, stored.Name)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
[]archiveRename{
|
|
||||||
{first.ID, renamedWebhookName, first.Name},
|
|
||||||
{middle.ID, renamedWebhookName, middle.Name},
|
|
||||||
{last.ID, renamedWebhookName, last.Name},
|
|
||||||
{first.ID, oldName, first.Name},
|
|
||||||
{middle.ID, oldName, middle.Name},
|
|
||||||
{last.ID, oldName, last.Name},
|
|
||||||
},
|
|
||||||
env.archives.Renames(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
|
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
|
||||||
// already has an archive's new name, the edit is refused with an
|
// already has an archive's new name, the edit is refused with an
|
||||||
// error naming that file, and the webhook keeps its stored name.
|
// error naming that file, and the webhook keeps its stored name.
|
||||||
@@ -799,9 +619,9 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
|
|||||||
|
|
||||||
env := setupSourceTest(t)
|
env := setupSourceTest(t)
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
env.archives.FailRenames(tgt.ID, errNameTaken)
|
env.archives.FailRenames(errNameTaken)
|
||||||
|
|
||||||
oldName := wh.Name
|
oldName := wh.Name
|
||||||
wh.Name = renamedWebhookName
|
wh.Name = renamedWebhookName
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// downloadWriteTimeout is how long one write of a download may wait
|
|
||||||
// for a client that has stopped reading.
|
|
||||||
const downloadWriteTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// HandleTargetDownload serves a database target's archive as one
|
|
||||||
// gzipped JSON file, named for the webhook, the target and the time;
|
|
||||||
// see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other
|
|
||||||
// target types have no archive and are a 404.
|
|
||||||
//
|
|
||||||
// A download runs for as long as the client keeps reading: it reads
|
|
||||||
// under a context the request limit does not cancel, and gives each
|
|
||||||
// write its own deadline in place of the server's write timeout. It
|
|
||||||
// stops when a write fails.
|
|
||||||
func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := context.WithoutCancel(r.Context())
|
|
||||||
|
|
||||||
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = export.Close() }()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
|
||||||
w.Header().Set(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="`+delivery.ArchiveExportFileName(
|
|
||||||
webhook.Name, target.Name, now,
|
|
||||||
)+`"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := export.WriteGzipJSON(
|
|
||||||
ctx,
|
|
||||||
downloadWriter{w: w, rc: http.NewResponseController(w)},
|
|
||||||
&webhook, target, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to export archive",
|
|
||||||
"target_id", target.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The 200 has gone out. Aborting the connection is what
|
|
||||||
// tells the client the file is incomplete.
|
|
||||||
panic(http.ErrAbortHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadWriter writes a download to the client, giving each write
|
|
||||||
// downloadWriteTimeout to finish.
|
|
||||||
type downloadWriter struct {
|
|
||||||
w http.ResponseWriter
|
|
||||||
rc *http.ResponseController
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d downloadWriter) Write(b []byte) (int, error) {
|
|
||||||
// A writer that has no write deadline, such as a test's recorder,
|
|
||||||
// answers http.ErrNotSupported and needs none extended.
|
|
||||||
err := d.rc.SetWriteDeadline(time.Now().Add(downloadWriteTimeout))
|
|
||||||
if err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.w.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openTargetArchive opens the archive of the request's database target
|
|
||||||
// for export, with its reads under ctx. It reports false once it has
|
|
||||||
// written the response.
|
|
||||||
//
|
|
||||||
// It holds renameMu, which every archive rename runs under, while it
|
|
||||||
// reads the stored names and opens the file, so the file it opens is
|
|
||||||
// the one those names give. It lets go before the export is streamed:
|
|
||||||
// once the file is open, a rename does not affect the export.
|
|
||||||
func (h *Handlers) openTargetArchive(
|
|
||||||
ctx context.Context,
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
|
||||||
h.renameMu.Lock()
|
|
||||||
defer h.renameMu.Unlock()
|
|
||||||
|
|
||||||
webhook, target, ok := h.ownedTarget(w, r)
|
|
||||||
if !ok {
|
|
||||||
return database.Webhook{}, nil, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.Type != database.TargetTypeDatabase {
|
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
|
||||||
|
|
||||||
return database.Webhook{}, nil, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
|
||||||
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, r, "failed to open archive for export", err)
|
|
||||||
|
|
||||||
return database.Webhook{}, nil, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return webhook, target, export, true
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errClientGone is the write failure of a client that has gone away.
|
|
||||||
var errClientGone = errors.New("client gone")
|
|
||||||
|
|
||||||
// downloadPath is the archive download route of a target.
|
|
||||||
func downloadPath(webhookID, targetID string) string {
|
|
||||||
return "/hook/" + webhookID + "/targets/" + targetID + "/download"
|
|
||||||
}
|
|
||||||
|
|
||||||
// renameTarget submits the edit form renaming a target to Renamed.
|
|
||||||
func renameTarget(
|
|
||||||
env *sourceTestEnv, webhookID, targetID string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("name", "Renamed")
|
|
||||||
|
|
||||||
return submitTargetEdit(env, webhookID, targetID, form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload proves a database target's archive
|
|
||||||
// downloads as a gzipped JSON attachment named for the webhook, the
|
|
||||||
// target and the time, here with no archive file yet, so with no
|
|
||||||
// rows; and that a target of another type has no download.
|
|
||||||
func TestHandleTargetDownload(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
logTarget := seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
w := serveTarget(
|
|
||||||
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
||||||
assert.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
|
||||||
assert.Regexp(t,
|
|
||||||
`^attachment; filename="archive-seeded-t-database-`+
|
|
||||||
`\d{8}T\d{6}Z\.json\.gz"$`,
|
|
||||||
w.Header().Get("Content-Disposition"),
|
|
||||||
)
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(w.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var got map[string]json.RawMessage
|
|
||||||
|
|
||||||
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
|
||||||
assert.JSONEq(t,
|
|
||||||
`{"id":"`+archive.ID+`","name":"t-database"}`,
|
|
||||||
string(got["target"]),
|
|
||||||
)
|
|
||||||
assert.JSONEq(t, `[]`, string(got["archived_events"]))
|
|
||||||
|
|
||||||
w = serveTarget(
|
|
||||||
env, http.MethodGet, downloadPath(wh.ID, logTarget.ID), nil,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
|
||||||
// target's names and opens its archive under the lock a rename holds:
|
|
||||||
// started while an edit is renaming the archive, it waits, and is
|
|
||||||
// named for the target's new name.
|
|
||||||
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
renaming, release := env.archives.BlockNextRename()
|
|
||||||
edited := make(chan *httptest.ResponseRecorder, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
edited <- renameTarget(env, wh.ID, archive.ID)
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-renaming
|
|
||||||
|
|
||||||
downloaded := make(chan *httptest.ResponseRecorder, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
downloaded <- serveTarget(
|
|
||||||
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-downloaded:
|
|
||||||
release()
|
|
||||||
t.Fatal("the download did not wait for the rename")
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
require.Equal(t, http.StatusSeeOther, (<-edited).Code)
|
|
||||||
|
|
||||||
w := <-downloaded
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t,
|
|
||||||
w.Header().Get("Content-Disposition"), "archive-seeded-renamed-",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stalledWriter is a response writer whose first write waits until
|
|
||||||
// resume is closed, closing writing when it starts to wait.
|
|
||||||
type stalledWriter struct {
|
|
||||||
*httptest.ResponseRecorder
|
|
||||||
|
|
||||||
once sync.Once
|
|
||||||
writing chan struct{}
|
|
||||||
resume chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stalledWriter) Write(b []byte) (int, error) {
|
|
||||||
s.once.Do(func() {
|
|
||||||
close(s.writing)
|
|
||||||
<-s.resume
|
|
||||||
})
|
|
||||||
|
|
||||||
return s.ResponseRecorder.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
|
||||||
// lets go of the rename lock once its archive is open: while the
|
|
||||||
// download is stalled writing, an edit can still rename the target.
|
|
||||||
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
||||||
)
|
|
||||||
for _, c := range env.cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
sw := &stalledWriter{
|
|
||||||
ResponseRecorder: httptest.NewRecorder(),
|
|
||||||
writing: make(chan struct{}),
|
|
||||||
resume: make(chan struct{}),
|
|
||||||
}
|
|
||||||
downloaded := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
targetRouter(env).ServeHTTP(sw, req)
|
|
||||||
close(downloaded)
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-sw.writing
|
|
||||||
|
|
||||||
edited := make(chan *httptest.ResponseRecorder, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
edited <- renameTarget(env, wh.ID, archive.ID)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case w := <-edited:
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Error("the rename waited for the download")
|
|
||||||
}
|
|
||||||
|
|
||||||
close(sw.resume)
|
|
||||||
<-downloaded
|
|
||||||
assert.Equal(t, http.StatusOK, sw.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedArchive writes rows to the archive file at path, each with a
|
|
||||||
// body of bodySize random bytes, which do not compress. Its table has
|
|
||||||
// only the columns the test fills; an export writes the others empty.
|
|
||||||
func seedArchive(t *testing.T, path string, rows, bodySize int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, db.Close()) }()
|
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(),
|
|
||||||
"CREATE TABLE archived_events (id INTEGER PRIMARY KEY, body TEXT)",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
body := make([]byte, bodySize)
|
|
||||||
|
|
||||||
for range rows {
|
|
||||||
_, _ = rand.Read(body)
|
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(),
|
|
||||||
"INSERT INTO archived_events (body) VALUES (?)", string(body),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// limitedServer serves the target routes as the server does, behind the
|
|
||||||
// access log, whose lines it returns, and the request limit, here
|
|
||||||
// limit, which is also its write timeout. Each connection's send buffer
|
|
||||||
// is a few KiB, so a larger response is still being written while its
|
|
||||||
// client is not reading.
|
|
||||||
func limitedServer(
|
|
||||||
t *testing.T, env *sourceTestEnv, limit time.Duration,
|
|
||||||
) (*httptest.Server, *bytes.Buffer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const sendBuffer = 4 << 10
|
|
||||||
|
|
||||||
logBuf := new(bytes.Buffer)
|
|
||||||
mw := middleware.NewForTest(
|
|
||||||
slog.New(slog.NewJSONHandler(logBuf, nil)),
|
|
||||||
&config.Config{Environment: config.EnvironmentDev},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
srv := httptest.NewUnstartedServer(
|
|
||||||
mw.Logging()(mw.Timeout(limit)(targetRouter(env))),
|
|
||||||
)
|
|
||||||
srv.Config.WriteTimeout = limit
|
|
||||||
srv.Config.ConnContext = func(
|
|
||||||
ctx context.Context, c net.Conn,
|
|
||||||
) context.Context {
|
|
||||||
tcp, ok := c.(*net.TCPConn)
|
|
||||||
if assert.True(t, ok) {
|
|
||||||
assert.NoError(t, tcp.SetWriteBuffer(sendBuffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
srv.Start()
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
return srv, logBuf
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload_OutlastsTheRequestLimit proves a download
|
|
||||||
// runs for as long as the client keeps reading, and is logged as the
|
|
||||||
// 200 it was. Behind a request limit and a server write timeout of a
|
|
||||||
// tenth of a second, the client stops reading once the response has
|
|
||||||
// started, waits three times as long, and still gets the whole file.
|
|
||||||
// The archive is larger than the connection holds, so the download is
|
|
||||||
// still being written while the client waits.
|
|
||||||
func TestHandleTargetDownload_OutlastsTheRequestLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
limit = 100 * time.Millisecond
|
|
||||||
rows = 8
|
|
||||||
bodySize = 64 << 10
|
|
||||||
)
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
seedArchive(
|
|
||||||
t, delivery.ArchivePath(env.dbMgr, &wh, archive), rows, bodySize,
|
|
||||||
)
|
|
||||||
|
|
||||||
srv, accessLog := limitedServer(t, env, limit)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet,
|
|
||||||
srv.URL+downloadPath(wh.ID, archive.ID), nil,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, c := range env.cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := srv.Client().Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
time.Sleep(3 * limit)
|
|
||||||
|
|
||||||
zr, err := gzip.NewReader(resp.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var (
|
|
||||||
got map[string]json.RawMessage
|
|
||||||
events []json.RawMessage
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
|
||||||
require.NoError(t, json.Unmarshal(got["archived_events"], &events))
|
|
||||||
assert.Len(t, events, rows)
|
|
||||||
|
|
||||||
// Reading to the end makes the gzip reader check that the file was
|
|
||||||
// finished.
|
|
||||||
_, err = io.ReadAll(zr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Close waits for the handler, so the access log line is written.
|
|
||||||
srv.Close()
|
|
||||||
|
|
||||||
var access map[string]any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal(accessLog.Bytes(), &access))
|
|
||||||
assert.EqualValues(t, http.StatusOK, access["status"])
|
|
||||||
assert.GreaterOrEqual(t,
|
|
||||||
access["latency_ms"], float64(limit.Milliseconds()),
|
|
||||||
"the download must outlast the request limit",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// brokenWriter is a response writer whose writes fail once the
|
|
||||||
// response has started, as they do when the client goes away.
|
|
||||||
type brokenWriter struct {
|
|
||||||
*httptest.ResponseRecorder
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b brokenWriter) Write(p []byte) (int, error) {
|
|
||||||
if b.Body.Len() > 0 {
|
|
||||||
return 0, errClientGone
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.ResponseRecorder.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTargetDownload_AbortsWhenItFails proves a download that
|
|
||||||
// fails after its response has started aborts the connection, so the
|
|
||||||
// client sees a failed download rather than a file that looks
|
|
||||||
// complete and does not decompress.
|
|
||||||
func TestHandleTargetDownload_AbortsWhenItFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
||||||
)
|
|
||||||
for _, c := range env.cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := brokenWriter{ResponseRecorder: httptest.NewRecorder()}
|
|
||||||
|
|
||||||
assert.PanicsWithValue(t, http.ErrAbortHandler, func() {
|
|
||||||
targetRouter(env).ServeHTTP(w, req)
|
|
||||||
})
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
}
|
|
||||||
@@ -80,9 +80,6 @@ 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.renameMu.Lock()
|
|
||||||
defer h.renameMu.Unlock()
|
|
||||||
|
|
||||||
webhook, target, ok := h.ownedTarget(w, r)
|
webhook, target, ok := h.ownedTarget(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -182,9 +179,8 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
"Not saved: "+err.Error()+
|
"Not saved: "+err.Error()+
|
||||||
". Move that archive out of the data directory, "+
|
". Move that file out of the data directory, "+
|
||||||
"its .db together with any -wal and -shm beside "+
|
"then save again.",
|
||||||
"it, then save again.",
|
|
||||||
http.StatusConflict,
|
http.StatusConflict,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,8 +193,7 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
http.StatusSeeOther,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,18 +37,14 @@ const (
|
|||||||
editAuthHeader = "Authorization: Bearer " + editBearerSecret
|
editAuthHeader = "Authorization: Bearer " + editBearerSecret
|
||||||
)
|
)
|
||||||
|
|
||||||
// targetRouter mounts the target create, edit and download routes on
|
// targetRouter mounts the target create and edit routes on a chi
|
||||||
// a chi router so the handlers see the URL parameters they read.
|
// router so the handlers see the URL parameters they read.
|
||||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post(
|
router.Post(
|
||||||
"/hook/{sourceID}/targets",
|
"/hook/{sourceID}/targets",
|
||||||
env.handlers.HandleTargetCreate(),
|
env.handlers.HandleTargetCreate(),
|
||||||
)
|
)
|
||||||
router.Get(
|
|
||||||
"/hook/{sourceID}/targets/{targetID}/download",
|
|
||||||
env.handlers.HandleTargetDownload(),
|
|
||||||
)
|
|
||||||
router.Get(
|
router.Get(
|
||||||
"/hook/{sourceID}/targets/{targetID}/edit",
|
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||||
env.handlers.HandleTargetEdit(),
|
env.handlers.HandleTargetEdit(),
|
||||||
@@ -686,7 +682,7 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
|||||||
|
|
||||||
again := url.Values{"name": {"Again"}}
|
again := url.Values{"name": {"Again"}}
|
||||||
|
|
||||||
env.archives.FailRenames(archive.ID, errInjectedRename)
|
env.archives.FailRenames(errInjectedRename)
|
||||||
|
|
||||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
@@ -695,7 +691,7 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
|
|||||||
"a target whose archive was not renamed keeps its name",
|
"a target whose archive was not renamed keeps its name",
|
||||||
)
|
)
|
||||||
|
|
||||||
env.archives.FailRenames(archive.ID, errNameTaken)
|
env.archives.FailRenames(errNameTaken)
|
||||||
|
|
||||||
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
w = submitTargetEdit(env, wh.ID, archive.ID, again)
|
||||||
require.Equal(t, http.StatusConflict, w.Code)
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TargetRowView is one row of the target list on a webhook's page.
|
|
||||||
type TargetRowView struct {
|
|
||||||
delivery.TargetView
|
|
||||||
|
|
||||||
// Archive is a database target's archive file, and nil for a target
|
|
||||||
// of any other type.
|
|
||||||
Archive *ArchiveFileView
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveFileView is what a database target's row shows about its
|
|
||||||
// archive file.
|
|
||||||
type ArchiveFileView struct {
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Note stands in for the size and the last write when there are
|
|
||||||
// none to show, and is empty when there are.
|
|
||||||
Note string
|
|
||||||
|
|
||||||
// Size is the size on disk. Written is how long ago the file was
|
|
||||||
// last written, and WrittenUTC the full time the page shows on
|
|
||||||
// hover.
|
|
||||||
Size string
|
|
||||||
Written string
|
|
||||||
WrittenUTC string
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetRows projects a webhook's targets for the target list on its
|
|
||||||
// page.
|
|
||||||
func (h *Handlers) targetRows(
|
|
||||||
webhook *database.Webhook, targets []database.Target,
|
|
||||||
) []TargetRowView {
|
|
||||||
views := delivery.NewTargetViews(targets)
|
|
||||||
rows := make([]TargetRowView, len(views))
|
|
||||||
|
|
||||||
// NewTargetViews returns one view per target, in order.
|
|
||||||
for i := range views {
|
|
||||||
rows[i].TargetView = views[i]
|
|
||||||
|
|
||||||
if targets[i].Type == database.TargetTypeDatabase {
|
|
||||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveFileView describes a database target's archive file from the
|
|
||||||
// file's metadata alone; the archive is never opened. The file is found
|
|
||||||
// by the name the archive writer uses, so it follows a rename of the
|
|
||||||
// webhook or the target.
|
|
||||||
func (h *Handlers) archiveFileView(
|
|
||||||
webhook *database.Webhook, target *database.Target,
|
|
||||||
) *ArchiveFileView {
|
|
||||||
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
|
||||||
view := &ArchiveFileView{Name: filepath.Base(path)}
|
|
||||||
|
|
||||||
file, err := delivery.StatArchive(path)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
view.Note = "not created yet"
|
|
||||||
case err != nil:
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read archive file metadata",
|
|
||||||
"target_id", target.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
view.Note = "could not be read"
|
|
||||||
default:
|
|
||||||
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
|
||||||
view.Written = humanize.Time(file.Written)
|
|
||||||
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ShowsArchiveFile proves a database target's
|
|
||||||
// row names its archive file and says "not created yet" before the
|
|
||||||
// first write, adds the file's size and last write once it has one row,
|
|
||||||
// and says "not created yet" again once the file has been moved away.
|
|
||||||
// A target of another type shows no archive file.
|
|
||||||
func TestHandleSourceDetail_ShowsArchiveFile(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)
|
|
||||||
archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
path := delivery.ArchivePath(dbMgr, wh, archive)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.Contains(t, body, "not created yet")
|
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
|
||||||
|
|
||||||
seedArchive(t, path, 1, 100)
|
|
||||||
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.NotContains(t, body, "not created yet")
|
|
||||||
assert.Regexp(t,
|
|
||||||
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
|
||||||
)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(path, filepath.Join(t.TempDir(), "moved.db")))
|
|
||||||
|
|
||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.Contains(t, body, "not created yet")
|
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
|
|
||||||
// loaded the target before an edit of it was saved does not write the
|
|
||||||
// old name and settings back over the edit. The edit is submitted from
|
|
||||||
// a callback on the toggle's own read of the target, so it is saved
|
|
||||||
// after that read and before the toggle writes.
|
|
||||||
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupSourceTest(t)
|
|
||||||
wh, tgt := seedHTTPTarget(t, env, "", "")
|
|
||||||
require.True(t, tgt.Active)
|
|
||||||
|
|
||||||
var (
|
|
||||||
edited bool
|
|
||||||
editCode int
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, env.db.DB().Callback().Query().
|
|
||||||
After("gorm:query").
|
|
||||||
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
|
|
||||||
// The edit reads the target too; only the toggle's read,
|
|
||||||
// the first, submits it.
|
|
||||||
if tx.Statement.Table != "targets" || edited {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
edited = true
|
|
||||||
editCode = submitTargetEdit(
|
|
||||||
env, wh.ID, tgt.ID,
|
|
||||||
editForm(editReplacedURL, "", ""),
|
|
||||||
).Code
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
req := postRequest(
|
|
||||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
|
|
||||||
env.cookies,
|
|
||||||
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
require.Equal(t, http.StatusSeeOther, editCode)
|
|
||||||
|
|
||||||
stored := storedTarget(t, env, tgt.ID)
|
|
||||||
assert.False(t, stored.Active)
|
|
||||||
assert.Equal(t, "edited-name", stored.Name)
|
|
||||||
assert.Equal(t, 5, stored.MaxRetries)
|
|
||||||
assert.Equal(
|
|
||||||
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -82,9 +82,9 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert.Contains(t, body, "Retention: 14 days")
|
assert.Contains(t, body, "Retention: 14 days")
|
||||||
assert.Contains(t, body, `class="btn-secondary">Webhooks</a>`)
|
assert.Contains(t, body, `class="btn-text">Webhooks</a>`)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body, `class="btn-secondary w-full">Webhooks</a>`,
|
t, body, `class="btn-text w-full text-left">Webhooks</a>`,
|
||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
@@ -163,7 +163,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, detailBody,
|
t, detailBody,
|
||||||
`<a href="/hook/wh-1/events" class="btn-small">Full Event Log</a>`,
|
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
|
||||||
"the link under recent events",
|
"the link under recent events",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -331,9 +331,8 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
|
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`<button type="button" hidden data-copy-target="entrypoint-url-ep-1"`,
|
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||||
"the copy control must be a button, start hidden and be "+
|
"the button must start hidden and be revealed by script",
|
||||||
"revealed by script",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderTemplate streams to the ResponseWriter, so an abort
|
// renderTemplate streams to the ResponseWriter, so an abort
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -58,8 +57,7 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
h.log.Info("webhook request received",
|
h.log.Info("webhook request received",
|
||||||
"entrypoint_uuid", entrypointUUID,
|
"entrypoint_uuid", entrypointUUID,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"remoteIP", middleware.RemoteIP(r),
|
"remote_addr", r.RemoteAddr,
|
||||||
"clientIP", middleware.ClientIP(r),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if !entrypoint.Active {
|
if !entrypoint.Active {
|
||||||
@@ -152,9 +150,7 @@ func (h *Handlers) lookupEntrypoint(
|
|||||||
return entrypoint, true
|
return entrypoint, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// readWebhookBody reads and validates the request body size. This is
|
// readWebhookBody reads and validates the request body size.
|
||||||
// the receiver's only body cap: /h/{uuid} has no MaxBodySize
|
|
||||||
// middleware (see Server.setupWebhookRoutes).
|
|
||||||
func (h *Handlers) readWebhookBody(
|
func (h *Handlers) readWebhookBody(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -272,12 +268,10 @@ func requestEventSource(
|
|||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target,
|
// createAndFanOut writes the event and one pending delivery per target,
|
||||||
// and adds them to the webhook's running totals, in a single
|
// and adds them to the webhook's running totals, in a single
|
||||||
// transaction, then hands the tasks to the delivery engine. Every
|
// transaction, then hands the tasks to the delivery engine. It is the
|
||||||
// event is created here, received or resubmitted, so a resubmitted
|
// only path by which an event and its deliveries are created, so a
|
||||||
// event is retried, SSRF-guarded and circuit-broken exactly as a
|
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||||
// received one is. Per-delivery replay is the one other path that
|
// exactly as a received one is.
|
||||||
// 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
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
|
||||||
// receiver's "webhook request received" line carries both addresses:
|
|
||||||
// remoteIP, the connecting peer, and clientIP, the client the access
|
|
||||||
// log attributes the request to.
|
|
||||||
func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// untrustedPeer is outside the trusted 10.0.0.0/8, so its
|
|
||||||
// X-Forwarded-For is ignored and it is the client.
|
|
||||||
const untrustedPeer = "192.0.2.10"
|
|
||||||
|
|
||||||
cases := map[string]struct {
|
|
||||||
peer string
|
|
||||||
wantRemote string
|
|
||||||
wantClient string
|
|
||||||
}{
|
|
||||||
"trusted proxy with a forwarded chain": {
|
|
||||||
peer: "10.0.0.1:44444",
|
|
||||||
wantRemote: "10.0.0.1",
|
|
||||||
wantClient: "198.51.100.7",
|
|
||||||
},
|
|
||||||
"untrusted peer": {
|
|
||||||
peer: untrustedPeer + ":5555",
|
|
||||||
wantRemote: untrustedPeer,
|
|
||||||
wantClient: untrustedPeer,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, tc := range cases {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
mw *middleware.Middleware
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
TrustedProxies: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
},
|
|
||||||
}, &h, &mw, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
h.SetLogForTest(slog.New(slog.NewJSONHandler(buf, nil)))
|
|
||||||
|
|
||||||
webhook := seedWebhook(t, db)
|
|
||||||
seedEntrypoint(t, db, webhook.ID)
|
|
||||||
|
|
||||||
// Logging is what works the client address out, so the
|
|
||||||
// request goes through it as it does in production.
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Use(mw.Logging())
|
|
||||||
router.Post("/h/{uuid}", h.HandleWebhook())
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost,
|
|
||||||
"/h/ep-"+webhook.ID, strings.NewReader("{}"),
|
|
||||||
)
|
|
||||||
req.RemoteAddr = tc.peer
|
|
||||||
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.2")
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
line := receivedLine(t, buf)
|
|
||||||
assert.Equal(t, tc.wantRemote, line["remoteIP"])
|
|
||||||
assert.Equal(t, tc.wantClient, line["clientIP"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// receivedLine returns the one "webhook request received" line in the
|
|
||||||
// captured JSON log.
|
|
||||||
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var found []map[string]any
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(
|
|
||||||
strings.TrimSpace(buf.String()), "\n",
|
|
||||||
) {
|
|
||||||
var entry map[string]any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal([]byte(line), &entry))
|
|
||||||
|
|
||||||
if entry["msg"] == "webhook request received" {
|
|
||||||
found = append(found, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Len(t, found, 1)
|
|
||||||
|
|
||||||
return found[0]
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"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/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
@@ -17,6 +18,7 @@ type HealthcheckParams struct {
|
|||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
|||||||
UptimeHuman: s.uptime().String(),
|
UptimeHuman: s.uptime().String(),
|
||||||
Appname: s.params.Globals.Appname,
|
Appname: s.params.Globals.Appname,
|
||||||
Version: s.params.Globals.Version,
|
Version: s.params.Globals.Version,
|
||||||
|
Maintenance: s.params.Config.MaintenanceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
@@ -75,6 +78,7 @@ type Response struct {
|
|||||||
UptimeHuman string `json:"uptimeHuman"`
|
UptimeHuman string `json:"uptimeHuman"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Healthcheck) uptime() time.Duration {
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -19,11 +18,15 @@ import (
|
|||||||
// width.
|
// width.
|
||||||
const budget = 64
|
const budget = 64
|
||||||
|
|
||||||
// batchRunes is how many consecutive code points the charge test logs
|
// sampleRunes is how many runes wide the values in the charge test
|
||||||
// in one value from U+1000 up. Logging each of those on its own line
|
// are. The handlers add a constant per field — a pair of quotes when
|
||||||
// is too slow for the suite under the race detector; 4,096 at a time
|
// the value needs quoting — so the per-rune charge is only visible
|
||||||
// is 271 batches, each logged on two lines, so 542 lines per handler.
|
// once it is amortised over a run of them.
|
||||||
const batchRunes = 4096
|
const sampleRunes = 64
|
||||||
|
|
||||||
|
// quotingSlack is that constant: the pair of quotes a handler adds to
|
||||||
|
// a value that needs them and omits from one that does not.
|
||||||
|
const quotingSlack = 2
|
||||||
|
|
||||||
// newHandlers are the two handlers internal/logger can install. Time
|
// newHandlers are the two handlers internal/logger can install. Time
|
||||||
// is dropped so a line's width is a function of its value alone —
|
// is dropped so a line's width is a function of its value alone —
|
||||||
@@ -63,48 +66,46 @@ func renderedWidth(
|
|||||||
return buf.Len()
|
return buf.Len()
|
||||||
}
|
}
|
||||||
|
|
||||||
// emittedBytes is what a handler writes for the runes of s alone, in a
|
// chargeTestRunes is the set of code points the charge test measures:
|
||||||
// value that starts with prefix: the width of a line carrying prefix
|
// every rune in the first two planes' worth of the BMP that the
|
||||||
// and then s twice, less that of a line carrying prefix and s once.
|
// handlers are most likely to treat specially, the separators that
|
||||||
// Both values start the same way and hold the same runes, so the text
|
// only slog's JSON handler escapes, and a stratified sample across
|
||||||
// handler quotes both or neither, and the quotes cancel along with the
|
// the rest of Unicode so the astral charge is exercised on more than
|
||||||
// prefix and everything else on the line.
|
// one hand-picked rune.
|
||||||
func emittedBytes(
|
func chargeTestRunes() []rune {
|
||||||
newHandler func(io.Writer) slog.Handler,
|
const (
|
||||||
prefix, s string,
|
denseCeiling = 0x800
|
||||||
) int {
|
stride = 1021
|
||||||
return renderedWidth(newHandler, prefix+s+s) -
|
surrogateLo = 0xD800
|
||||||
renderedWidth(newHandler, prefix+s)
|
surrogateHi = 0xDFFF
|
||||||
}
|
)
|
||||||
|
|
||||||
// firstUndercharged returns the first rune in s that the handler
|
var runes []rune
|
||||||
// writes in more bytes than EncodedBytes charges for it, and how many
|
|
||||||
// runes in s are undercharged that way. It measures one rune per line,
|
|
||||||
// in a value of that rune alone and again after a space, which makes
|
|
||||||
// the text handler quote the value. The charge test calls it on the
|
|
||||||
// code points below U+1000, and from there up only on a batch that has
|
|
||||||
// already failed, to name the code points rather than just their range.
|
|
||||||
func firstUndercharged(
|
|
||||||
newHandler func(io.Writer) slog.Handler,
|
|
||||||
s string,
|
|
||||||
) (rune, int) {
|
|
||||||
first, count := rune(-1), 0
|
|
||||||
|
|
||||||
for _, r := range s {
|
keep := func(r rune) {
|
||||||
charge := logfield.EncodedBytes(r)
|
if r >= surrogateLo && r <= surrogateHi {
|
||||||
if emittedBytes(newHandler, "", string(r)) <= charge &&
|
return
|
||||||
emittedBytes(newHandler, " ", string(r)) <= charge {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if count == 0 {
|
runes = append(runes, r)
|
||||||
first = r
|
|
||||||
}
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return first, count
|
for r := range rune(denseCeiling) {
|
||||||
|
keep(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range []rune{
|
||||||
|
0x2028, 0x2029, 0x200B, 0x4E00, 0xE000, 0xFFFD,
|
||||||
|
0x1000C, 0x1F600, 0xE0001, 0x10FFFF,
|
||||||
|
} {
|
||||||
|
keep(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
for r := rune(denseCeiling); r <= utf8.MaxRune; r += stride {
|
||||||
|
keep(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
|
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
|
||||||
@@ -113,93 +114,33 @@ func firstUndercharged(
|
|||||||
// how a stated ceiling becomes false without any test noticing, so
|
// how a stated ceiling becomes false without any test noticing, so
|
||||||
// the charge is measured against what the handlers actually write
|
// the charge is measured against what the handlers actually write
|
||||||
// rather than against the escaping rules as read.
|
// rather than against the escaping rules as read.
|
||||||
//
|
|
||||||
// Every code point below U+1000 is checked on its own, for both
|
|
||||||
// handlers. That range holds the quote, the backslash and the control
|
|
||||||
// characters the handlers escape, next to code points each handler
|
|
||||||
// writes in fewer bytes than their charge, which in a sum would cover
|
|
||||||
// a neighbour charged too little. Each is measured in a value of it
|
|
||||||
// alone and again in one the text handler quotes, because that handler
|
|
||||||
// writes U+007F as one raw byte in a value it leaves bare but as \x7f,
|
|
||||||
// four bytes, in one it quotes.
|
|
||||||
//
|
|
||||||
// From U+1000 up the text handler writes every code point in exactly
|
|
||||||
// its charge, so the rest of Unicode is checked batchRunes at a time:
|
|
||||||
// each batch's summed charge must cover what the handler writes for
|
|
||||||
// the whole batch. The sums there can miss the JSON handler alone
|
|
||||||
// writing one code point in more bytes than its charge, when it writes
|
|
||||||
// others in the same batch in fewer.
|
|
||||||
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
|
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var below strings.Builder
|
|
||||||
for r := range rune(0x1000) {
|
|
||||||
below.WriteRune(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
var batches []string
|
|
||||||
|
|
||||||
for lo := rune(0x1000); lo <= utf8.MaxRune; lo += batchRunes {
|
|
||||||
var batch strings.Builder
|
|
||||||
|
|
||||||
for r := lo; r < lo+batchRunes; r++ {
|
|
||||||
// Surrogate halves are not runes a string can carry.
|
|
||||||
if utf8.ValidRune(r) {
|
|
||||||
batch.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
batches = append(batches, batch.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// What EncodedBytes charges for each batch. Under -race -cover this
|
|
||||||
// takes longer than logging the batches, so it is worked out once,
|
|
||||||
// by whichever handler finishes logging first, while the other is
|
|
||||||
// still logging.
|
|
||||||
charged := sync.OnceValue(func() []int {
|
|
||||||
costs := make([]int, len(batches))
|
|
||||||
|
|
||||||
for i, batch := range batches {
|
|
||||||
for _, r := range batch {
|
|
||||||
costs[i] += logfield.EncodedBytes(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return costs
|
|
||||||
})
|
|
||||||
|
|
||||||
for name, newHandler := range newHandlers() {
|
for name, newHandler := range newHandlers() {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if first, count := firstUndercharged(newHandler, below.String()); count > 0 {
|
// 'a' is a printable ASCII rune, charged exactly one
|
||||||
t.Errorf(
|
// byte, so it is the zero point the other runes are
|
||||||
"%d code points below U+1000 cost more than "+
|
// measured against.
|
||||||
"EncodedBytes charges, the first U+%04X",
|
base := renderedWidth(
|
||||||
count, first,
|
newHandler, strings.Repeat("a", sampleRunes),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, r := range chargeTestRunes() {
|
||||||
|
got := renderedWidth(
|
||||||
|
newHandler,
|
||||||
|
strings.Repeat(string(r), sampleRunes),
|
||||||
)
|
)
|
||||||
}
|
charged := sampleRunes *
|
||||||
|
(logfield.EncodedBytes(r) - 1)
|
||||||
|
|
||||||
emitted := make([]int, len(batches))
|
require.LessOrEqual(
|
||||||
for i, batch := range batches {
|
t, got-base, charged+quotingSlack,
|
||||||
emitted[i] = emittedBytes(newHandler, "", batch)
|
"U+%04X costs more on the line than "+
|
||||||
}
|
"EncodedBytes charges for it",
|
||||||
|
r,
|
||||||
for i, cost := range charged() {
|
|
||||||
if emitted[i] <= cost {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lo := rune(0x1000 + i*batchRunes)
|
|
||||||
first, count := firstUndercharged(
|
|
||||||
newHandler, batches[i],
|
|
||||||
)
|
|
||||||
t.Errorf(
|
|
||||||
"U+%04X to U+%04X emit %d bytes but are "+
|
|
||||||
"charged %d; %d of them cost more than "+
|
|
||||||
"EncodedBytes charges, the first U+%04X",
|
|
||||||
lo, lo+batchRunes-1, emitted[i], cost,
|
|
||||||
count, first,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxevent"
|
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,40 +106,3 @@ func (l *Logger) Identify() {
|
|||||||
func (l *Logger) Writer() io.Writer {
|
func (l *Logger) Writer() io.Writer {
|
||||||
return os.Stdout
|
return os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
// FxLogger writes fx's own events through a slog logger: how the
|
|
||||||
// dependency graph was built at DEBUG, since it repeats on every
|
|
||||||
// start; the start and stop hooks, the start itself and the signal
|
|
||||||
// that stops the service at INFO; every failure at ERROR.
|
|
||||||
//
|
|
||||||
// The formatting is fx's own fxevent.SlogLogger. That logger takes a
|
|
||||||
// single level for every event that is not a failure, so FxLogger
|
|
||||||
// holds one at each level and picks between them.
|
|
||||||
type FxLogger struct {
|
|
||||||
graph *fxevent.SlogLogger
|
|
||||||
lifecycle *fxevent.SlogLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFxLogger returns an FxLogger that writes through log.
|
|
||||||
func NewFxLogger(log *slog.Logger) *FxLogger {
|
|
||||||
graph := &fxevent.SlogLogger{Logger: log}
|
|
||||||
graph.UseLogLevel(slog.LevelDebug)
|
|
||||||
|
|
||||||
lifecycle := &fxevent.SlogLogger{Logger: log}
|
|
||||||
lifecycle.UseLogLevel(slog.LevelInfo)
|
|
||||||
|
|
||||||
return &FxLogger{graph: graph, lifecycle: lifecycle}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogEvent implements fxevent.Logger.
|
|
||||||
func (f *FxLogger) LogEvent(event fxevent.Event) {
|
|
||||||
switch event.(type) {
|
|
||||||
case *fxevent.Supplied, *fxevent.Provided, *fxevent.Replaced,
|
|
||||||
*fxevent.Decorated, *fxevent.BeforeRun, *fxevent.Run,
|
|
||||||
*fxevent.Invoking, *fxevent.Invoked,
|
|
||||||
*fxevent.LoggerInitialized:
|
|
||||||
f.graph.LogEvent(event)
|
|
||||||
default:
|
|
||||||
f.lifecycle.LogEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
package logger_test
|
package logger_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"go.uber.org/fx/fxevent"
|
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errStopHook = errors.New("stop hook failed on purpose")
|
|
||||||
|
|
||||||
func testGlobals() *globals.Globals {
|
func testGlobals() *globals.Globals {
|
||||||
return &globals.Globals{
|
return &globals.Globals{
|
||||||
Appname: "test-app",
|
Appname: "test-app",
|
||||||
@@ -67,47 +57,3 @@ func TestEnableDebugLogging(t *testing.T) {
|
|||||||
// Test debug logging
|
// Test debug logging
|
||||||
l.Get().Debug("debug message", "test", true)
|
l.Get().Debug("debug message", "test", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFxLogger_Levels starts and stops an fx app that reports its own
|
|
||||||
// events through NewFxLogger, as cmd/webhooker does, and reads back
|
|
||||||
// what reached the handler: the graph at DEBUG, the start at INFO and
|
|
||||||
// a failed stop hook at ERROR, each as a structured record.
|
|
||||||
func TestFxLogger_Levels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
log := slog.New(slog.NewJSONHandler(
|
|
||||||
&out, &slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
))
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.WithLogger(func() fxevent.Logger {
|
|
||||||
return logger.NewFxLogger(log)
|
|
||||||
}),
|
|
||||||
fx.Invoke(func(lc fx.Lifecycle) {
|
|
||||||
lc.Append(fx.StopHook(func() error { return errStopHook }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, app.Start(t.Context()))
|
|
||||||
require.ErrorIs(t, app.Stop(t.Context()), errStopHook)
|
|
||||||
|
|
||||||
levels := map[string]string{}
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(&out)
|
|
||||||
for decoder.More() {
|
|
||||||
var record struct {
|
|
||||||
Level string `json:"level"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, decoder.Decode(&record))
|
|
||||||
|
|
||||||
levels[record.Msg] = record.Level
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "DEBUG", levels["provided"])
|
|
||||||
assert.Equal(t, "INFO", levels["started"])
|
|
||||||
assert.Equal(t, "ERROR", levels["OnStop hook failed"])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -384,7 +384,6 @@ func (s *Set) initSeries() {
|
|||||||
s.deliveriesFailed.WithLabelValues(label)
|
s.deliveriesFailed.WithLabelValues(label)
|
||||||
s.deliveryRetries.WithLabelValues(label)
|
s.deliveryRetries.WithLabelValues(label)
|
||||||
s.deliveryReplays.WithLabelValues(label)
|
s.deliveryReplays.WithLabelValues(label)
|
||||||
s.deliveryDuration.WithLabelValues(label)
|
|
||||||
s.deliveriesPending.WithLabelValues(label)
|
s.deliveriesPending.WithLabelValues(label)
|
||||||
s.deliveriesRetrying.WithLabelValues(label)
|
s.deliveriesRetrying.WithLabelValues(label)
|
||||||
s.circuitBreakersOpen.WithLabelValues(label)
|
s.circuitBreakersOpen.WithLabelValues(label)
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ func TestKnownSeriesExistBeforeAnyDelivery(t *testing.T) {
|
|||||||
"webhooker_deliveries_succeeded_total",
|
"webhooker_deliveries_succeeded_total",
|
||||||
"webhooker_deliveries_failed_total",
|
"webhooker_deliveries_failed_total",
|
||||||
"webhooker_delivery_retries_total",
|
"webhooker_delivery_retries_total",
|
||||||
"webhooker_delivery_duration_seconds",
|
|
||||||
"webhooker_circuit_breakers_open",
|
"webhooker_circuit_breakers_open",
|
||||||
} {
|
} {
|
||||||
assert.ElementsMatch(t,
|
assert.ElementsMatch(t,
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ const (
|
|||||||
// capturingMiddleware returns a Middleware whose logger writes JSON
|
// capturingMiddleware returns a Middleware whose logger writes JSON
|
||||||
// lines into the returned buffer, so the access log can be asserted
|
// lines into the returned buffer, so the access log can be asserted
|
||||||
// on directly.
|
// on directly.
|
||||||
//
|
|
||||||
// It trusts 192.0.2.1, the peer address httptest.NewRequestWithContext
|
|
||||||
// gives a request, as a proxy, the way a deployment trusts its reverse
|
|
||||||
// proxy: a request built that way and carrying X-Forwarded-For is
|
|
||||||
// logged with the client that header names as clientIP, and one
|
|
||||||
// without it with the peer.
|
|
||||||
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -78,10 +72,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
|||||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||||
))
|
))
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return middleware.NewForTest(log, cfg, nil), buf
|
return middleware.NewForTest(log, cfg, nil), buf
|
||||||
}
|
}
|
||||||
@@ -90,7 +81,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
|||||||
// internal/logger can select: slog's text handler, which
|
// internal/logger can select: slog's text handler, which
|
||||||
// internal/logger/logger.go installs when stderr is a tty. It escapes
|
// internal/logger/logger.go installs when stderr is a tty. It escapes
|
||||||
// differently from the JSON one, so the line bound has to be asserted
|
// differently from the JSON one, so the line bound has to be asserted
|
||||||
// against both. It trusts the same peer.
|
// against both.
|
||||||
func capturingTextMiddleware(
|
func capturingTextMiddleware(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) (*middleware.Middleware, *bytes.Buffer) {
|
) (*middleware.Middleware, *bytes.Buffer) {
|
||||||
@@ -102,10 +93,7 @@ func capturingTextMiddleware(
|
|||||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||||
))
|
))
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return middleware.NewForTest(log, cfg, nil), buf
|
return middleware.NewForTest(log, cfg, nil), buf
|
||||||
}
|
}
|
||||||
@@ -346,12 +334,11 @@ func oversizedHeaders(value string) map[string]string {
|
|||||||
// sizeCase is one way of pointing 8 KB of client-chosen text at the
|
// sizeCase is one way of pointing 8 KB of client-chosen text at the
|
||||||
// access log.
|
// access log.
|
||||||
type sizeCase struct {
|
type sizeCase struct {
|
||||||
target string
|
target string
|
||||||
headers map[string]string
|
headers map[string]string
|
||||||
wantStatus int
|
wantStatus int
|
||||||
wantURL string
|
wantURL string
|
||||||
wantClientIP string
|
bound int
|
||||||
bound int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lineSizeCases enumerates every part of a request that reaches the
|
// lineSizeCases enumerates every part of a request that reaches the
|
||||||
@@ -388,7 +375,8 @@ func lineSizeCases() map[string]sizeCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The url field on a 5xx keeps the concrete path, so it reaches its
|
// The url field on a 5xx keeps the concrete path, so it reaches its
|
||||||
// own budget on the same line as the three header fields.
|
// own budget on the same line as the three header fields. That is
|
||||||
|
// the widest access log line the service can be made to write.
|
||||||
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
|
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
|
||||||
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
|
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
|
||||||
|
|
||||||
@@ -432,29 +420,6 @@ func lineSizeCases() map[string]sizeCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// From a trusted proxy, clientIP is read out of X-Forwarded-For,
|
|
||||||
// which the client writes. What bounds the field is that only one
|
|
||||||
// address from the header is written, and it is written parsed, with
|
|
||||||
// no zone. An IPv6 address with all eight groups at four digits is
|
|
||||||
// the longest such address; here it carries an 8 KB zone, which must
|
|
||||||
// not reach the line. It goes on the 5xx line with all three header
|
|
||||||
// fields at their budget.
|
|
||||||
const longestIPv6 = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
|
||||||
|
|
||||||
forwarded := oversizedHeaders(oversizedValue("h"))
|
|
||||||
forwarded[headerXFF] = oversizedValue("h") + ", " +
|
|
||||||
longestIPv6 + "%" + oversizedValue("h")
|
|
||||||
|
|
||||||
cases["oversized X-Forwarded-For from a trusted proxy "+
|
|
||||||
"with a 5xx concrete url"] = sizeCase{
|
|
||||||
target: longPath,
|
|
||||||
headers: forwarded,
|
|
||||||
wantStatus: http.StatusInternalServerError,
|
|
||||||
wantURL: wantLongURL,
|
|
||||||
wantClientIP: longestIPv6,
|
|
||||||
bound: maxCappedLineBytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
return cases
|
return cases
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,14 +460,6 @@ func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
|
|||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, tc.wantURL, entries[0]["url"])
|
assert.Equal(t, tc.wantURL, entries[0]["url"])
|
||||||
|
|
||||||
// Set only by the X-Forwarded-For case, where it proves
|
|
||||||
// the header was read rather than ignored.
|
|
||||||
if tc.wantClientIP != "" {
|
|
||||||
assert.Equal(
|
|
||||||
t, tc.wantClientIP, entries[0]["clientIP"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The markers sit at the far end of the client-chosen
|
// The markers sit at the far end of the client-chosen
|
||||||
// text, so their absence is what proves the redaction and
|
// text, so their absence is what proves the redaction and
|
||||||
// the truncation actually ran.
|
// the truncation actually ran.
|
||||||
@@ -691,8 +648,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
|||||||
|
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
"request_start", "method", "url", "useragent", "request_id",
|
"request_start", "method", "url", "useragent", "request_id",
|
||||||
"referer", "proto", "remoteIP", "clientIP", "status",
|
"referer", "proto", "remoteIP", "status", "latency_ms",
|
||||||
"latency_ms",
|
|
||||||
} {
|
} {
|
||||||
assert.Contains(t, entries[0], key)
|
assert.Contains(t, entries[0], key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// forwardedChain is the X-Forwarded-For a request arrives with:
|
|
||||||
// the client, then a second proxy inside trustedProxyCIDR that the
|
|
||||||
// request passed through before reaching trustedPeer.
|
|
||||||
forwardedChain = clientIPv4 + ", 10.0.0.2"
|
|
||||||
|
|
||||||
// untrustedPeer is a peer outside trustedProxyCIDR, so its
|
|
||||||
// X-Forwarded-For is ignored and the peer is the client.
|
|
||||||
untrustedPeer = "192.0.2.10:5555"
|
|
||||||
|
|
||||||
// oneRequestPerMinute is the receiver limit these tests install:
|
|
||||||
// the second request on a path is rejected, and the aggregate
|
|
||||||
// limit is ReceiverAggregateMultiplierConst.
|
|
||||||
oneRequestPerMinute = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// clientLogSite is one log line that names the client. build wraps the
|
|
||||||
// middleware that writes it around a handler, and requests is how many
|
|
||||||
// identical requests it takes before the line is written.
|
|
||||||
type clientLogSite struct {
|
|
||||||
build func(m *middleware.Middleware) http.Handler
|
|
||||||
requests int
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientLogSites maps the message of each line that names the client
|
|
||||||
// to the way to make it be written.
|
|
||||||
func clientLogSites() map[string]clientLogSite {
|
|
||||||
served := func(*middleware.Middleware) http.Handler {
|
|
||||||
return okHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
receiver := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return m.ReceiverRateLimit()(okHandler())
|
|
||||||
}
|
|
||||||
|
|
||||||
login := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
m.RecordLoginFailure(r, "someone")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
csrf := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return m.CSRF(http.HandlerFunc(forbidden))(okHandler())
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordChange := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return m.PasswordChangeRateLimit()(okHandler())
|
|
||||||
}
|
|
||||||
|
|
||||||
replay := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return m.ReplayRateLimit()(okHandler())
|
|
||||||
}
|
|
||||||
|
|
||||||
resubmit := func(m *middleware.Middleware) http.Handler {
|
|
||||||
return m.ResubmitRateLimit()(okHandler())
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]clientLogSite{
|
|
||||||
"http request": {
|
|
||||||
build: served,
|
|
||||||
requests: 1,
|
|
||||||
},
|
|
||||||
"webhook receiver rate limit exceeded": {
|
|
||||||
build: receiver,
|
|
||||||
requests: oneRequestPerMinute + 1,
|
|
||||||
},
|
|
||||||
// The aggregate limit sits in front of the per-entrypoint
|
|
||||||
// one, so the requests that one rejects count towards it.
|
|
||||||
"webhook receiver aggregate rate limit exceeded": {
|
|
||||||
build: receiver,
|
|
||||||
requests: middleware.ReceiverAggregateMultiplierConst*
|
|
||||||
oneRequestPerMinute + 1,
|
|
||||||
},
|
|
||||||
"login failure limit exceeded": {
|
|
||||||
build: login,
|
|
||||||
requests: middleware.LoginRateLimitConst + 1,
|
|
||||||
},
|
|
||||||
"csrf: token validation failed": {
|
|
||||||
build: csrf,
|
|
||||||
requests: 1,
|
|
||||||
},
|
|
||||||
"password change rate limit exceeded": {
|
|
||||||
build: passwordChange,
|
|
||||||
requests: middleware.PasswordChangeRateLimitConst + 1,
|
|
||||||
},
|
|
||||||
"delivery replay rate limit exceeded": {
|
|
||||||
build: replay,
|
|
||||||
requests: middleware.ReplayRateLimitConst + 1,
|
|
||||||
},
|
|
||||||
"event resubmit rate limit exceeded": {
|
|
||||||
build: resubmit,
|
|
||||||
requests: middleware.ResubmitRateLimitConst + 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientLogLines sends the site's requests from peer, each carrying
|
|
||||||
// forwardedChain, through Logging and then the site, as production
|
|
||||||
// does, and returns the logged lines whose message is msg.
|
|
||||||
func clientLogLines(
|
|
||||||
t *testing.T, site clientLogSite, msg, peer string,
|
|
||||||
) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
log := slog.New(slog.NewJSONHandler(
|
|
||||||
buf,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
))
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
ReceiverRateLimit: oneRequestPerMinute,
|
|
||||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
|
||||||
}
|
|
||||||
|
|
||||||
m := middleware.NewForTest(
|
|
||||||
log, cfg, newTestSessionManager(cfg, log, nil),
|
|
||||||
)
|
|
||||||
handler := m.Logging()(site.build(m))
|
|
||||||
|
|
||||||
for range site.requests {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost, "/h/x", nil,
|
|
||||||
)
|
|
||||||
req.RemoteAddr = peer
|
|
||||||
req.Header.Set(headerXFF, forwardedChain)
|
|
||||||
|
|
||||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []map[string]any
|
|
||||||
|
|
||||||
for _, entry := range accessLogEntries(t, buf) {
|
|
||||||
if entry["msg"] == msg {
|
|
||||||
lines = append(lines, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClientIP_LoggedNextToThePeer checks that every line that names
|
|
||||||
// the client carries both addresses: remoteIP, the connecting peer,
|
|
||||||
// and clientIP, the client the rate limiters key on.
|
|
||||||
func TestClientIP_LoggedNextToThePeer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := map[string]struct {
|
|
||||||
peer string
|
|
||||||
wantRemote string
|
|
||||||
wantClient string
|
|
||||||
}{
|
|
||||||
"trusted proxy with a forwarded chain": {
|
|
||||||
peer: trustedPeer,
|
|
||||||
wantRemote: "10.0.0.1",
|
|
||||||
wantClient: clientIPv4,
|
|
||||||
},
|
|
||||||
"untrusted peer": {
|
|
||||||
peer: untrustedPeer,
|
|
||||||
wantRemote: "192.0.2.10",
|
|
||||||
wantClient: "192.0.2.10",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for msg, site := range clientLogSites() {
|
|
||||||
for name, tc := range cases {
|
|
||||||
t.Run(msg+"/"+name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lines := clientLogLines(t, site, msg, tc.peer)
|
|
||||||
require.NotEmpty(t, lines, "%q was never logged", msg)
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
assert.Equal(t, tc.wantRemote, line["remoteIP"])
|
|
||||||
assert.Equal(t, tc.wantClient, line["clientIP"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,10 +45,10 @@ func (m *Middleware) CSRF(
|
|||||||
// unauthenticated client: a POST with no token to
|
// unauthenticated client: a POST with no token to
|
||||||
// /hook/<any length of any text>/edit lands here. The
|
// /hook/<any length of any text>/edit lands here. The
|
||||||
// method and path are capped against the same budgets as
|
// method and path are capped against the same budgets as
|
||||||
// the access log. remoteIP and clientIP are the same
|
// the access log. remote_addr is set by net/http from the
|
||||||
// addresses the access log carries, and
|
// accepted connection rather than by the client, and
|
||||||
// csrf.FailureReason returns one of gorilla/csrf's own
|
// csrf.FailureReason returns one of gorilla/csrf's own
|
||||||
// fixed error values, so none of them is client-sized.
|
// fixed error values, so neither is client-sized.
|
||||||
m.log.Warn("csrf: token validation failed",
|
m.log.Warn("csrf: token validation failed",
|
||||||
"method", logfield.Truncate(
|
"method", logfield.Truncate(
|
||||||
r.Method, maxLogMethodBytes,
|
r.Method, maxLogMethodBytes,
|
||||||
@@ -56,8 +56,7 @@ func (m *Middleware) CSRF(
|
|||||||
"path", logfield.Truncate(
|
"path", logfield.Truncate(
|
||||||
r.URL.Path, logfield.MaxBytes,
|
r.URL.Path, logfield.MaxBytes,
|
||||||
),
|
),
|
||||||
"remoteIP", RemoteIP(r),
|
"remote_addr", r.RemoteAddr,
|
||||||
"clientIP", ClientIP(r),
|
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
forbidden.ServeHTTP(w, r)
|
forbidden.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -132,12 +132,6 @@ func (g *LoginGuard) TrackedKeysForTest() (int, int) {
|
|||||||
// passwordChangeRateLimit constant.
|
// passwordChangeRateLimit constant.
|
||||||
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
||||||
|
|
||||||
// ReplayRateLimitConst exposes the replayRateLimit constant.
|
|
||||||
const ReplayRateLimitConst = replayRateLimit
|
|
||||||
|
|
||||||
// ResubmitRateLimitConst exposes the resubmitRateLimit constant.
|
|
||||||
const ResubmitRateLimitConst = resubmitRateLimit
|
|
||||||
|
|
||||||
// ReceiverAggregateMultiplierConst exposes the
|
// ReceiverAggregateMultiplierConst exposes the
|
||||||
// receiverAggregateMultiplier constant.
|
// receiverAggregateMultiplier constant.
|
||||||
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
|
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
|
||||||
|
|||||||
@@ -385,8 +385,6 @@ func (m *Middleware) RecordLoginFailure(
|
|||||||
"path", logfield.Truncate(
|
"path", logfield.Truncate(
|
||||||
r.URL.Path, logfield.MaxBytes,
|
r.URL.Path, logfield.MaxBytes,
|
||||||
),
|
),
|
||||||
"remoteIP", RemoteIP(r),
|
|
||||||
"clientIP", ClientIP(r),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
)
|
)
|
||||||
|
|
||||||
// inflightHandler is the fixed `handler` label on
|
// inflightHandler is the fixed `handler` label on
|
||||||
@@ -168,72 +169,13 @@ func metricsMiddleware(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// The handler id is unmatchedRoute rather than "" so that
|
||||||
mw := &metricsResponseWriter{
|
// the client-chosen URL path never enters the metrics
|
||||||
ResponseWriter: w,
|
// pipeline at all: an empty id is the library's signal to
|
||||||
request: r,
|
// substitute it. boundedLabelRecorder overwrites this value
|
||||||
statusCode: http.StatusOK,
|
// on every observation, so it is reachable only if that
|
||||||
}
|
// decorator is removed — in which case the metrics collapse
|
||||||
|
// to one series instead of leaking again.
|
||||||
// The handler id is unmatchedRoute rather than "" so
|
return std.Handler(unmatchedRoute, mdlw, next)
|
||||||
// that the client-chosen URL path never enters the
|
|
||||||
// metrics pipeline at all: an empty id is the library's
|
|
||||||
// signal to substitute it. boundedLabelRecorder
|
|
||||||
// overwrites this value on every observation, so it is
|
|
||||||
// reachable only if that decorator is removed — in which
|
|
||||||
// case the metrics collapse to one series instead of
|
|
||||||
// leaking again.
|
|
||||||
mdlw.Measure(unmatchedRoute, mw, func() {
|
|
||||||
next.ServeHTTP(mw, r)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// metricsResponseWriter records the status code and body size of a
|
|
||||||
// response, and hands them with the request to go-http-metrics'
|
|
||||||
// Measure as its Reporter.
|
|
||||||
//
|
|
||||||
// It stands in for the library's std.Handler, whose writer has no
|
|
||||||
// Unwrap: behind it, http.ResponseController cannot reach net/http's
|
|
||||||
// own writer, so a handler's write deadline fails with metrics on.
|
|
||||||
type metricsResponseWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
|
|
||||||
request *http.Request
|
|
||||||
statusCode int
|
|
||||||
bytesWritten int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) WriteHeader(code int) {
|
|
||||||
w.statusCode = code
|
|
||||||
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) Write(b []byte) (int, error) {
|
|
||||||
w.bytesWritten += int64(len(b))
|
|
||||||
|
|
||||||
//nolint:wrapcheck // Pass the writer's own error through unchanged.
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
|
||||||
// a handler can still flush or set a write deadline with metrics on.
|
|
||||||
func (w *metricsResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return w.ResponseWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) Method() string { return w.request.Method }
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) Context() context.Context {
|
|
||||||
return w.request.Context()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) URLPath() string { return w.request.URL.Path }
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) StatusCode() int { return w.statusCode }
|
|
||||||
|
|
||||||
func (w *metricsResponseWriter) BytesWritten() int64 { return w.bytesWritten }
|
|
||||||
|
|
||||||
var _ ghmm.Reporter = (*metricsResponseWriter)(nil)
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -70,19 +69,18 @@ const (
|
|||||||
// url, useragent, referer 3*(512+11) = 1569
|
// url, useragent, referer 3*(512+11) = 1569
|
||||||
// request_id 128+11 = 139
|
// request_id 128+11 = 139
|
||||||
// method 32+11 = 43
|
// method 32+11 = 43
|
||||||
// fixed portion = 405
|
// fixed portion = 336
|
||||||
// ----
|
// ----
|
||||||
// 2156
|
// 2087
|
||||||
//
|
//
|
||||||
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
|
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
|
||||||
// charged on top of each budget rather than inside it.
|
// charged on top of each budget rather than inside it.
|
||||||
//
|
//
|
||||||
// The fixed portion is the JSON punctuation, the field names, the
|
// The fixed portion is the JSON punctuation, the field names, the
|
||||||
// level and the message, both timestamps at their longest, remoteIP
|
// level and the message, both timestamps at their longest, an IPv6
|
||||||
// and clientIP each charged as an IPv6 address with a zone, a
|
// remoteIP with a zone, a three-digit status and a full-width int64
|
||||||
// three-digit status and a full-width int64 latency. Stated at 2560
|
// latency. Stated at 2560 so the figure carries headroom rather
|
||||||
// so the figure carries headroom rather than sitting on the
|
// than sitting on the arithmetic.
|
||||||
// arithmetic.
|
|
||||||
//
|
//
|
||||||
// The tty text handler in internal/logger is covered by the same
|
// The tty text handler in internal/logger is covered by the same
|
||||||
// figure. logfield.EncodedBytes charges every rune at least what
|
// figure. logfield.EncodedBytes charges every rune at least what
|
||||||
@@ -90,8 +88,8 @@ const (
|
|||||||
// bytes strconv.Quote spends on a non-printable rune at or above
|
// bytes strconv.Quote spends on a non-printable rune at or above
|
||||||
// U+10000, which is four more than the JSON handler ever spends —
|
// U+10000, which is four more than the JSON handler ever spends —
|
||||||
// so each budget bounds the encoded field under either handler.
|
// so each budget bounds the encoded field under either handler.
|
||||||
// The text handler's fixed portion is 351, the smaller of the two,
|
// The text handler's fixed portion is 286, the smaller of the two,
|
||||||
// which puts its worst case at 2102.
|
// which puts its worst case at 2037.
|
||||||
//
|
//
|
||||||
// It is also the ceiling on every OTHER line this service writes
|
// It is also the ceiling on every OTHER line this service writes
|
||||||
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
||||||
@@ -217,28 +215,6 @@ func ipFromHostPort(hp string) string {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoteIP returns the address of the connecting peer, without its
|
|
||||||
// port. Behind a reverse proxy it is the proxy. Every log line that
|
|
||||||
// names the client logs it as remoteIP, next to clientIP.
|
|
||||||
func RemoteIP(r *http.Request) string {
|
|
||||||
return ipFromHostPort(r.RemoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientIPKey is the request context key under which Logging stores
|
|
||||||
// the value ClientIP returns.
|
|
||||||
type clientIPKey struct{}
|
|
||||||
|
|
||||||
// ClientIP returns the address the request is attributed to, which
|
|
||||||
// Logging works out once per request with clientAddr in ratelimit.go
|
|
||||||
// and logs as clientIP. The other lines that name the client read it
|
|
||||||
// from here, so all of them agree. It is empty for a request Logging
|
|
||||||
// has not seen.
|
|
||||||
func ClientIP(r *http.Request) string {
|
|
||||||
ip, _ := r.Context().Value(clientIPKey{}).(string)
|
|
||||||
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
@@ -257,13 +233,6 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
lrw.ResponseWriter.WriteHeader(code)
|
lrw.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
|
||||||
// a handler can still flush or set a write deadline through the access
|
|
||||||
// log.
|
|
||||||
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return lrw.ResponseWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
// concreteLogURL renders the request's own URL for the access log
|
// concreteLogURL renders the request's own URL for the access log
|
||||||
// branches that keep it, with the query string replaced by a fixed
|
// branches that keep it, with the query string replaced by a fixed
|
||||||
// marker.
|
// marker.
|
||||||
@@ -340,13 +309,6 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
lrw := newLoggingResponseWriter(w)
|
lrw := newLoggingResponseWriter(w)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// When RemoteAddr is not an address, the peer's own
|
|
||||||
// text is all the request can be attributed to.
|
|
||||||
clientIP := RemoteIP(r)
|
|
||||||
if addr, ok := s.clientAddr(r); ok {
|
|
||||||
clientIP = addr.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
requestID := ""
|
requestID := ""
|
||||||
@@ -381,16 +343,13 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
r.Referer(), logfield.MaxBytes,
|
r.Referer(), logfield.MaxBytes,
|
||||||
),
|
),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP", RemoteIP(r),
|
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||||
"clientIP", clientIP,
|
|
||||||
"status", lrw.statusCode,
|
"status", lrw.statusCode,
|
||||||
"latency_ms", latency.Milliseconds(),
|
"latency_ms", latency.Milliseconds(),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(lrw, r.WithContext(
|
next.ServeHTTP(lrw, r)
|
||||||
context.WithValue(ctx, clientIPKey{}, clientIP),
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,10 +559,7 @@ func bodyLimitedMethod(method string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MaxBodySize returns middleware that limits the size of
|
// MaxBodySize returns middleware that limits the size of
|
||||||
// POST/PUT/PATCH request bodies to maxBytes. A request with any other
|
// POST/PUT/PATCH request bodies to maxBytes. It must be registered
|
||||||
// method passes through uncapped, deliberately: no route behind it
|
|
||||||
// reads a body on GET, HEAD or DELETE. A handler that starts to needs
|
|
||||||
// its method added to bodyLimitedMethod first. It must be registered
|
|
||||||
// before any middleware that parses the body — notably CSRF, which
|
// before any middleware that parses the body — notably CSRF, which
|
||||||
// calls r.PostFormValue — so that form parsing happens under this
|
// calls r.PostFormValue — so that form parsing happens under this
|
||||||
// cap rather than net/http's 10 MB default.
|
// cap rather than net/http's 10 MB default.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
"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/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
@@ -77,7 +78,14 @@ func newTestSessionManager(
|
|||||||
key[i] = byte(i)
|
key[i] = byte(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := session.NewStore(key)
|
store := sessions.NewCookieStore(key)
|
||||||
|
store.Options = &sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 86400 * 7,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
|
||||||
var now func() time.Time
|
var now func() time.Time
|
||||||
|
|
||||||
@@ -730,8 +738,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
const testBodyLimit int64 = 64
|
const testBodyLimit int64 = 64
|
||||||
|
|
||||||
// maxBodySizeResult is what runMaxBodySize's sentinel handler saw,
|
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with
|
||||||
// together with the response.
|
// testBodyLimit. The sentinel records whether it ran and how much of
|
||||||
|
// the body it managed to read, so tests can distinguish "never
|
||||||
|
// reached" from "reached but truncated".
|
||||||
type maxBodySizeResult struct {
|
type maxBodySizeResult struct {
|
||||||
called bool
|
called bool
|
||||||
read int
|
read int
|
||||||
@@ -739,10 +749,6 @@ type maxBodySizeResult struct {
|
|||||||
response *httptest.ResponseRecorder
|
response *httptest.ResponseRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
// runMaxBodySize wraps a sentinel handler in MaxBodySize with
|
|
||||||
// testBodyLimit and serves req through it. The sentinel records
|
|
||||||
// whether it ran and how much of the body it managed to read, so
|
|
||||||
// tests can distinguish "never reached" from "reached but truncated".
|
|
||||||
func runMaxBodySize(
|
func runMaxBodySize(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
@@ -925,7 +931,8 @@ func metricsAuthMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := make([]byte, testKeySize)
|
key := make([]byte, testKeySize)
|
||||||
store := session.NewStore(key)
|
store := sessions.NewCookieStore(key)
|
||||||
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||||
|
|
||||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -202,61 +202,44 @@ func (m *Middleware) forwardedClientAddr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rateLimitKey is the client identity every rate limiter in this
|
// rateLimitKey is the client identity every rate limiter in this
|
||||||
// package buckets on: the address clientAddr attributes the request
|
// package buckets on. Forwarded headers are honoured only when the
|
||||||
// to, reduced to a bucket by bucketKey — full address for IPv4, /64
|
// direct peer (RemoteAddr) is inside the configured trusted-proxy
|
||||||
// prefix for IPv6.
|
// set; otherwise the peer address itself is the key. Without that
|
||||||
|
// gate any client could mint a fresh bucket per request, or starve
|
||||||
|
// another client's bucket, by picking an X-Forwarded-For value —
|
||||||
|
// which makes every limit here decorative against a deliberate
|
||||||
|
// attacker.
|
||||||
|
//
|
||||||
|
// The address that identifies the client is then reduced to a bucket
|
||||||
|
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
||||||
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
||||||
return m.clientKey(r), nil
|
return m.clientKey(r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientKey computes the bucket key described on rateLimitKey.
|
// clientKey computes the bucket key described on rateLimitKey.
|
||||||
func (m *Middleware) clientKey(r *http.Request) string {
|
func (m *Middleware) clientKey(r *http.Request) string {
|
||||||
addr, ok := m.clientAddr(r)
|
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||||
if !ok {
|
if err != nil {
|
||||||
// Not an address we can reason about; key on the raw
|
// Not an address we can reason about; key on the raw
|
||||||
// value, the most specific identity left. Distinct
|
// value, the most specific identity left. Distinct
|
||||||
// RemoteAddr values stay in distinct buckets, so this
|
// RemoteAddr values stay in distinct buckets, so this
|
||||||
// path cannot silently collapse unrelated clients
|
// path cannot silently collapse unrelated clients
|
||||||
// together. On a Unix-socket listener every peer
|
// together. On a Unix-socket listener every peer
|
||||||
// carries the same RemoteAddr and so shares one bucket,
|
// carries the same RemoteAddr and so shares one bucket,
|
||||||
// which is the fail-closed direction. An empty RemoteAddr
|
// which is the fail-closed direction.
|
||||||
// is a different case, which net/http never produces for
|
|
||||||
// a TCP listener and only a hand-built request carries,
|
|
||||||
// but it fails closed the same way: every such request
|
|
||||||
// shares the one bucket keyed on the empty string.
|
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
return bucketKey(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientAddr is the address a request is attributed to. The rate
|
|
||||||
// limiters key on it and the logs name it as clientIP.
|
|
||||||
//
|
|
||||||
// Forwarded headers are honoured only when the direct peer
|
|
||||||
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
|
|
||||||
// the peer address itself is the client. Without that gate any client
|
|
||||||
// could mint a fresh bucket per request, or starve another client's
|
|
||||||
// bucket, by picking an X-Forwarded-For value — which makes every
|
|
||||||
// limit here decorative against a deliberate attacker.
|
|
||||||
//
|
|
||||||
// ok is false when RemoteAddr is not an address at all.
|
|
||||||
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
|
||||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
|
||||||
if err != nil {
|
|
||||||
return netip.Addr{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
peer = normalizeAddr(peer)
|
peer = normalizeAddr(peer)
|
||||||
if !m.isTrustedProxy(peer) {
|
if !m.isTrustedProxy(peer) {
|
||||||
return peer, true
|
return bucketKey(peer)
|
||||||
}
|
}
|
||||||
|
|
||||||
if addr, ok := m.forwardedClientAddr(r); ok {
|
if addr, ok := m.forwardedClientAddr(r); ok {
|
||||||
return addr, true
|
return bucketKey(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return peer, true
|
return bucketKey(peer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tooManyRequests returns the 429 handler used by the
|
// tooManyRequests returns the 429 handler used by the
|
||||||
@@ -279,8 +262,6 @@ func (m *Middleware) tooManyRequests(
|
|||||||
"path", logfield.Truncate(
|
"path", logfield.Truncate(
|
||||||
r.URL.Path, logfield.MaxBytes,
|
r.URL.Path, logfield.MaxBytes,
|
||||||
),
|
),
|
||||||
"remoteIP", RemoteIP(r),
|
|
||||||
"clientIP", ClientIP(r),
|
|
||||||
)
|
)
|
||||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||||
}
|
}
|
||||||
@@ -305,12 +286,8 @@ func (m *Middleware) tooManyRequests(
|
|||||||
func (m *Middleware) floodTooManyRequests(
|
func (m *Middleware) floodTooManyRequests(
|
||||||
logMessage, responseMessage string,
|
logMessage, responseMessage string,
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
m.log.Debug(
|
m.log.Debug(logMessage)
|
||||||
logMessage,
|
|
||||||
"remoteIP", RemoteIP(r),
|
|
||||||
"clientIP", ClientIP(r),
|
|
||||||
)
|
|
||||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1012,23 +1012,6 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitKey_EmptyPeerSharesOneBucket pins what the fallback
|
|
||||||
// does with an empty RemoteAddr: it keys on the empty string, so every
|
|
||||||
// such request shares one bucket. That is the fail-closed direction
|
|
||||||
// and is kept on purpose; only a hand-built request carries an empty
|
|
||||||
// RemoteAddr.
|
|
||||||
func TestRateLimitKey_EmptyPeerSharesOneBucket(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := rateLimitMiddleware(t, &config.Config{})
|
|
||||||
|
|
||||||
assert.Empty(
|
|
||||||
t, clientKeyFor(t, m, ""),
|
|
||||||
"every peer with an empty RemoteAddr must key on the "+
|
|
||||||
"empty string and so share one bucket",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
||||||
// half, and the regression test for the bypass itself: a client that
|
// half, and the regression test for the bypass itself: a client that
|
||||||
// rotates source addresses inside its own routed /64 must stay in one
|
// rotates source addresses inside its own routed /64 must stay in one
|
||||||
|
|||||||
@@ -627,9 +627,11 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
|
|||||||
// net/http's own writer from http.ResponseController, so a handler
|
// net/http's own writer from http.ResponseController, so a handler
|
||||||
// that flushes or sets a deadline starts failing.
|
// that flushes or sets a deadline starts failing.
|
||||||
//
|
//
|
||||||
// The recoverer is the only middleware in the chain here;
|
// The recoverer is the only middleware in the chain here. The access
|
||||||
// TestResponseControllerThroughProductionRouter in internal/server
|
// logger's own wrapper does not implement Unwrap, so a chain
|
||||||
// covers the shipped chain.
|
// containing it fails this regardless of what the recoverer does;
|
||||||
|
// what is being pinned is that the recoverer adds no such opacity of
|
||||||
|
// its own.
|
||||||
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Timeout returns middleware that gives each request limit to finish:
|
|
||||||
// it cancels the request's context once limit has passed, and answers
|
|
||||||
// 504 when the handler then returns without having started its
|
|
||||||
// response.
|
|
||||||
//
|
|
||||||
// It replaces chi's middleware.Timeout, which writes that 504 even
|
|
||||||
// after the handler has sent its own status. A download that outlasts
|
|
||||||
// the limit has already sent its 200 and the whole file, so the late
|
|
||||||
// 504 changes nothing for the client: the access log and the metrics
|
|
||||||
// would record it in place of the 200, and net/http would complain of
|
|
||||||
// a superfluous WriteHeader.
|
|
||||||
func (s *Middleware) Timeout(
|
|
||||||
limit time.Duration,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), limit)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tw := &timeoutResponseWriter{ResponseWriter: w}
|
|
||||||
|
|
||||||
next.ServeHTTP(tw, r.WithContext(ctx))
|
|
||||||
|
|
||||||
if !tw.started &&
|
|
||||||
errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
||||||
w.WriteHeader(http.StatusGatewayTimeout)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// timeoutResponseWriter records whether the handler has started its
|
|
||||||
// response.
|
|
||||||
type timeoutResponseWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
|
|
||||||
started bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *timeoutResponseWriter) WriteHeader(code int) {
|
|
||||||
w.started = true
|
|
||||||
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *timeoutResponseWriter) Write(b []byte) (int, error) {
|
|
||||||
// A Write without a WriteHeader starts the response too: net/http
|
|
||||||
// sends 200 in front of it.
|
|
||||||
w.started = true
|
|
||||||
|
|
||||||
//nolint:wrapcheck // Pass the writer's own error through unchanged.
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
|
||||||
// a handler can still set a write deadline through this wrapper.
|
|
||||||
func (w *timeoutResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return w.ResponseWriter
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTimeout proves the request limit answers 504 to a handler that
|
|
||||||
// outlasts it without starting its response, and leaves a response the
|
|
||||||
// handler has started with the status it sent. Both are what the
|
|
||||||
// access log records.
|
|
||||||
func TestTimeout(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 10 * time.Millisecond
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
sent int // the status the handler sends, or 0 for none
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{name: "not started", sent: 0, want: http.StatusGatewayTimeout},
|
|
||||||
{name: "started", sent: http.StatusOK, want: http.StatusOK},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, buf := capturingMiddleware(t)
|
|
||||||
handler := m.Logging()(m.Timeout(limit)(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if tc.sent != 0 {
|
|
||||||
w.WriteHeader(tc.sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-r.Context().Done()
|
|
||||||
},
|
|
||||||
)))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(t, tc.want, w.Code)
|
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.EqualValues(t, tc.want, entries[0]["status"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -158,10 +158,6 @@ func newServerApp(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
||||||
// running after a start or stop timeout would write there after
|
|
||||||
// the test has returned.
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user